diff --git a/dashboard/.env.example b/dashboard/.env.example index c35b672..feee9d5 100644 --- a/dashboard/.env.example +++ b/dashboard/.env.example @@ -17,4 +17,7 @@ EMAIL_PASS= AUTH_JWT_SECRET= GOOGLE_AUTH_CLIENT_ID= -GOOGLE_AUTH_CLIENT_SECRET= \ No newline at end of file +GOOGLE_AUTH_CLIENT_SECRET= + +STRIPE_SECRET= +STRIPE_WH_SECRET= \ No newline at end of file diff --git a/dashboard/nuxt.config.ts b/dashboard/nuxt.config.ts index c676f73..5bec66e 100644 --- a/dashboard/nuxt.config.ts +++ b/dashboard/nuxt.config.ts @@ -39,6 +39,8 @@ export default defineNuxtConfig({ AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET, GOOGLE_AUTH_CLIENT_ID: process.env.GOOGLE_AUTH_CLIENT_ID, GOOGLE_AUTH_CLIENT_SECRET: process.env.GOOGLE_AUTH_CLIENT_SECRET, + STRIPE_SECRET: process.env.STRIPE_SECRET, + STRIPE_WH_SECRET: process.env.STRIPE_WH_SECRET, public: { PAYPAL_CLIENT_ID: '' } diff --git a/dashboard/package.json b/dashboard/package.json index 59110a0..0f9b120 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -26,6 +26,7 @@ "primevue": "^3.52.0", "redis": "^4.6.13", "sass": "^1.75.0", + "stripe": "^15.8.0", "vue": "^3.4.21", "vue-chart-3": "^3.1.8", "vue-router": "^4.3.0" diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 16d23aa..680dcba 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: sass: specifier: ^1.75.0 version: 1.77.2 + stripe: + specifier: ^15.8.0 + version: 15.8.0 vue: specifier: ^3.4.21 version: 3.4.27(typescript@5.4.2) @@ -3825,6 +3828,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.12.1: + resolution: {integrity: sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4193,6 +4200,10 @@ packages: strip-literal@2.1.0: resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} + stripe@15.8.0: + resolution: {integrity: sha512-7eEPMgehd1I16cXeP7Rcn/JKkPWIadB9vGIeE+vbCzQXaY5R95AoNmkZx0vmlu1H4QIDs7j1pYIKPRm9Dr4LKg==} + engines: {node: '>=12.*'} + stylehacks@6.1.1: resolution: {integrity: sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==} engines: {node: ^14 || ^16 || >=18.0} @@ -9323,6 +9334,10 @@ snapshots: punycode@2.3.1: {} + qs@6.12.1: + dependencies: + side-channel: 1.0.6 + queue-microtask@1.2.3: {} queue-tick@1.0.1: {} @@ -9734,6 +9749,11 @@ snapshots: dependencies: js-tokens: 9.0.0 + stripe@15.8.0: + dependencies: + '@types/node': 20.12.12 + qs: 6.12.1 + stylehacks@6.1.1(postcss@8.4.38): dependencies: browserslist: 4.23.0 diff --git a/dashboard/server/api/pay/[project_id]/create.post.ts b/dashboard/server/api/pay/[project_id]/create.post.ts new file mode 100644 index 0000000..c99bf2c --- /dev/null +++ b/dashboard/server/api/pay/[project_id]/create.post.ts @@ -0,0 +1,45 @@ +import { PREMIUM_PLANS, STRIPE_PLANS } from "@data/PREMIUM_LIMITS"; +import { ProjectModel } from "@schema/ProjectSchema"; +import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; +import StripeService from '~/server/services/StripeService'; + + +export default defineEventHandler(async event => { + + const project_id = getRequestProjectId(event); + if (!project_id) return; + + const user = getRequestUser(event); + const project = await getUserProjectFromId(project_id, user); + if (!project) return; + + const body = await readBody(event); + + const { planId } = body; + + const plan = PREMIUM_PLANS.find(e => e.id == planId); + + if (!plan) { + console.error('PLAN', planId, 'NOT EXIST'); + return setResponseStatus(event, 400, 'Plan not exist'); + } + + const { price } = STRIPE_PLANS[plan.tag]; + + const checkout = await StripeService.cretePayment( + price, + 'https://dashboard.litlyx.com/payment_ok', + project.customer_id + ); + + if (!checkout) { + console.error('Cannot create payment', { plan, price }); + return setResponseStatus(event, 400, 'Cannot create payment'); + } + + const customer = checkout.customer; + await ProjectModel.updateOne({ _id: project_id }, { customer_id: customer }); + + return checkout.url; + +}); \ No newline at end of file diff --git a/dashboard/server/api/pay/webhook.post.ts b/dashboard/server/api/pay/webhook.post.ts new file mode 100644 index 0000000..0fd54b7 --- /dev/null +++ b/dashboard/server/api/pay/webhook.post.ts @@ -0,0 +1,77 @@ + +import StripeService from '~/server/services/StripeService'; +import type Event from 'stripe'; +import { ProjectModel } from '@schema/ProjectSchema'; +import { PREMIUM_LIMITS, getPlanFromPremiumTag, getPlanTagFromStripePrice } from '@data/PREMIUM_LIMITS'; +import { ProjectCountModel } from '@schema/ProjectsCounts'; + +async function onPaymentSuccess(event: Event.InvoicePaidEvent) { + + if (event.data.object.status === 'paid') { + const customer = event.data.object.customer; + + const project = await ProjectModel.findOne({ customer_id: customer }); + if (!project) return { error: 'Project not found' } + + const subscriptionId = event.data.object.subscription; + if (!subscriptionId) return { error: 'SubscriptionId not found' } + + const subscription = await StripeService.getSubscription(subscriptionId as string); + if (!subscription) return { error: 'Subscription not found' } + + const price = subscription.items.data[0].plan.id; + + + const premiumTag = getPlanTagFromStripePrice(price); + if (!premiumTag) return { error: 'Premium tag not found' } + + const plan = getPlanFromPremiumTag(premiumTag); + if (!plan) return { error: 'Plan not found' } + + await ProjectModel.updateOne({ customer_id: customer }, { + premium: true, + premium_type: plan.id, + premium_expire_at: subscription.current_period_end + }); + + const limits = PREMIUM_LIMITS[premiumTag]; + + await ProjectCountModel.create({ + project_id: project._id, + events: 0, + visits: 0, + ai_messages: 0, + limit: limits.COUNT_LIMIT, + ai_limit: limits.AI_MESSAGE_LIMIT, + billing_start_at: subscription.current_period_start, + billing_expire_at: subscription.current_period_end, + }); + + return { ok: true } + } + + return { received: true } +} + +async function onSubscriptionCreated(event: Event.CustomerSubscriptionCreatedEvent) { + return { received: true } +} + +async function onSubscriptionDeleted(event: Event.CustomerSubscriptionDeletedEvent) { + return { received: true } +} + + +export default defineEventHandler(async event => { + + const body = await readRawBody(event); + const signature = getHeader(event, 'stripe-signature') || ''; + + const eventData = StripeService.parseWebhook(body, signature); + if (!eventData) return; + if (eventData.type === 'invoice.paid') return await onPaymentSuccess(eventData); + if (eventData.type === 'customer.subscription.deleted') return await onSubscriptionDeleted(eventData); + if (eventData.type === 'customer.subscription.created') return await onSubscriptionCreated(eventData); + + return { received: true } +}); \ No newline at end of file diff --git a/dashboard/server/init.ts b/dashboard/server/init.ts index eb4f1ff..2223a68 100644 --- a/dashboard/server/init.ts +++ b/dashboard/server/init.ts @@ -1,6 +1,7 @@ import mongoose from "mongoose"; import { Redis } from "~/server/services/CacheService"; import EmailService from '@services/EmailService'; +import StripeService from '~/server/services/StripeService'; const config = useRuntimeConfig(); let connection: mongoose.Mongoose; @@ -16,6 +17,8 @@ export default async () => { config.EMAIL_PASS, ); + StripeService.init(config.STRIPE_SECRET, config.STRIPE_WH_SECRET); + if (!connection || connection.connection.readyState == mongoose.ConnectionStates.disconnected) { console.log('[DATABASE] Connecting'); diff --git a/dashboard/server/services/StripeService.ts b/dashboard/server/services/StripeService.ts new file mode 100644 index 0000000..c6c99e9 --- /dev/null +++ b/dashboard/server/services/StripeService.ts @@ -0,0 +1,54 @@ +import Stripe from 'stripe'; + +class StripeService { + private stripe?: Stripe; + private privateKey?: string; + private webhookSecret?: string; + + init(privateKey: string, webhookSecret: string) { + this.privateKey = privateKey; + this.webhookSecret = webhookSecret; + this.stripe = new Stripe(this.privateKey); + } + parseWebhook(body: any, sig: string) { + if (!this.stripe) { + console.error('Stripe not initialized') + return; + } + if (!this.webhookSecret) { + console.error('Stripe not initialized') + return; + } + return this.stripe.webhooks.constructEvent(body, sig, this.webhookSecret); + } + + async cretePayment(price: string, success_url: string, customer?: string) { + if (!this.stripe) { + console.error('Stripe not initialized') + return; + } + const checkout = await this.stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { price, quantity: 1 } + ], + customer, + success_url, + mode: 'subscription' + }); + + return checkout; + } + + async getSubscription(subscriptionId: string) { + if (!this.stripe) { + console.error('Stripe not initialized') + return; + } + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); + return subscription; + } +} + +const instance = new StripeService(); +export default instance;