From 854d6eb528c11e3b8f67d7c25b88f2c8cc92232a Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 5 Jun 2024 15:40:51 +0200 Subject: [PATCH] add pricing --- broker/src/Controller.ts | 4 +- broker/src/routes/v1/Router.ts | 27 ++- dashboard/components/pricing/PricingCard.vue | 19 +- .../components/pricing/PricingDrawer.vue | 9 +- dashboard/pages/admin/index.vue | 13 +- dashboard/pages/plans.vue | 3 + .../api/ai/[project_id]/chats_remaining.ts | 8 +- .../server/api/auth/google_login.post.ts | 6 + .../api/pay/[project_id]/create.post.ts | 13 +- .../server/api/pay/[project_id]/invoices.ts | 27 ++- dashboard/server/api/pay/webhook.post.ts | 192 ++++++++++++++---- dashboard/server/api/project/create.post.ts | 21 +- dashboard/server/api/project/delete.delete.ts | 26 ++- dashboard/server/api/project/plan.ts | 28 +-- dashboard/server/services/AiService.ts | 6 +- dashboard/server/services/StripeService.ts | 62 ++++-- shared/data/PREMIUM.ts | 66 ++++++ shared/data/PREMIUM_LIMITS.ts | 81 -------- shared/functions/UtilsProjectCounts.ts | 70 ------- shared/schema/ProjectSchema.ts | 12 +- shared/schema/ProjectsCounts.ts | 10 - shared/schema/ProjectsLimits.ts | 26 +++ 22 files changed, 435 insertions(+), 294 deletions(-) create mode 100644 shared/data/PREMIUM.ts delete mode 100644 shared/data/PREMIUM_LIMITS.ts delete mode 100644 shared/functions/UtilsProjectCounts.ts create mode 100644 shared/schema/ProjectsLimits.ts diff --git a/broker/src/Controller.ts b/broker/src/Controller.ts index 2bb57af..9b4fd30 100644 --- a/broker/src/Controller.ts +++ b/broker/src/Controller.ts @@ -1,9 +1,9 @@ -import { TProjectCount } from "@schema/ProjectsCounts"; import { ProjectModel } from "@schema/ProjectSchema"; import { UserModel } from "@schema/UserSchema"; import { LimitNotifyModel } from "@schema/broker/LimitNotifySchema"; import EmailService from '@services/EmailService'; import { requireEnv } from "../../shared/utilts/requireEnv"; +import { TProjectLimit } from "@schema/ProjectsLimits"; EmailService.createTransport( @@ -13,7 +13,7 @@ EmailService.createTransport( requireEnv('EMAIL_PASS'), ); -export async function checkLimitsForEmail(projectCounts: TProjectCount) { +export async function checkLimitsForEmail(projectCounts: TProjectLimit) { if ((projectCounts.visits + projectCounts.events) >= (projectCounts.limit / 2)) { const notify = await LimitNotifyModel.findOne({ project_id: projectCounts._id }); diff --git a/broker/src/routes/v1/Router.ts b/broker/src/routes/v1/Router.ts index 3ba0e4a..cb598eb 100644 --- a/broker/src/routes/v1/Router.ts +++ b/broker/src/routes/v1/Router.ts @@ -1,7 +1,6 @@ import { Router, json } from "express"; import { createSessionHash, getIPFromRequest } from "../../utils/Utils"; -import { checkProjectCount } from "@functions/UtilsProjectCounts"; import { SessionModel } from "@schema/metrics/SessionSchema"; import { EVENT_LOG_LIMIT_PERCENT } from '@data/broker/Limits'; @@ -13,6 +12,8 @@ import { VisitModel } from "@schema/metrics/VisitSchema"; import { EventModel } from "@schema/metrics/EventSchema"; import { ProjectCountModel } from "@schema/ProjectsCounts"; import { checkLimitsForEmail } from "../../Controller"; +import { ProjectLimitModel } from "@schema/ProjectsLimits"; +import { ProjectModel } from "@schema/ProjectSchema"; const router = Router(); @@ -55,13 +56,21 @@ router.post('/metrics/push', json(jsonOptions), async (req, res) => { const { pid } = req.body; - const projectCounts = await checkProjectCount(pid); + const projectExist = await ProjectModel.exists({ _id: pid }); + if (!projectExist) return res.status(400).json({ error: 'Project not exist' }); + + const projectLimits = await ProjectLimitModel.findOne({ project_id: pid }); + + if (!projectLimits) return res.status(400).json({ error: 'No limits found' }); + + const TOTAL_COUNT = projectLimits.events + projectLimits.visits; + const COUNT_LIMIT = projectLimits.limit; + if ((TOTAL_COUNT * EVENT_LOG_LIMIT_PERCENT) > COUNT_LIMIT) { + return res.status(200).json({ error: 'Limit reached' }); + }; + await checkLimitsForEmail(projectLimits); - const TOTAL_COUNT = projectCounts.events + projectCounts.visits; - const LIMIT = projectCounts.limit; - if ((TOTAL_COUNT * EVENT_LOG_LIMIT_PERCENT) > LIMIT) return; - await checkLimitsForEmail(projectCounts); const ip = getIPFromRequest(req); @@ -113,7 +122,11 @@ router.post('/metrics/push', json(jsonOptions), async (req, res) => { const fieldToInc = type === EventType.VISIT ? 'visits' : 'events'; - await ProjectCountModel.updateOne({ _id: projectCounts._id }, { $inc: { [fieldToInc]: 1 } }); + await ProjectCountModel.updateOne({ project_id: pid }, { $inc: { [fieldToInc]: 1 } }, { upsert: true }); + + await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { [fieldToInc]: 1 } }); + + return res.sendStatus(200); diff --git a/dashboard/components/pricing/PricingCard.vue b/dashboard/components/pricing/PricingCard.vue index 76dc437..352f45e 100644 --- a/dashboard/components/pricing/PricingCard.vue +++ b/dashboard/components/pricing/PricingCard.vue @@ -5,16 +5,22 @@ export type PricingCardProp = { cost: string, features: string[], desc: string, - active: boolean + active: boolean, + planId: number } const props = defineProps<{ data: PricingCardProp }>(); +const activeProject = useActiveProject(); -const router = useRouter(); - -function onUpgradeClick() { - router.push('/book_demo') +async function onUpgradeClick() { + const res = await $fetch(`/api/pay/${activeProject.value?._id.toString()}/create`, { + ...signHeaders({ 'content-type': 'application/json' }), + method: 'POST', + body: JSON.stringify({ planId: props.data.planId }) + }) + if (!res) alert('Something went wrong'); + window.open(res); } @@ -37,7 +43,8 @@ function onUpgradeClick() {
Current active plan
-
+
Upgrade
diff --git a/dashboard/components/pricing/PricingDrawer.vue b/dashboard/components/pricing/PricingDrawer.vue index 4599154..ce6c46e 100644 --- a/dashboard/components/pricing/PricingDrawer.vue +++ b/dashboard/components/pricing/PricingDrawer.vue @@ -22,7 +22,8 @@ const starterTierCardData = ref({ can experience some data loss.To have a dedicated server we suggest to upgrade the plan to an higher one!`, - active: activeProject.value?.premium === false + active: activeProject.value?.premium === false, + planId: 0 }); const accelerationTierCardData = ref({ @@ -39,7 +40,8 @@ const accelerationTierCardData = ref({ "Low priority email support" ], desc: `Your project is entering a growth phase. We simplify data analysis for you. For more support, try our Expansion plan—it's worth it!`, - active: activeProject.value?.premium_type === 1 + active: activeProject.value?.premium_type === 1, + planId: 1 }); const expansionTierCardData = ref({ @@ -56,7 +58,8 @@ const expansionTierCardData = ref({ "high priority email support" ], desc: `We will support you with everything we can offer and give you the full power of our service. If you need more space and are growing, contact us for a custom offer!`, - active: activeProject.value?.premium_type === 2 + active: activeProject.value?.premium_type === 2, + planId: 2 }); diff --git a/dashboard/pages/admin/index.vue b/dashboard/pages/admin/index.vue index 4c82d82..41b9ef4 100644 --- a/dashboard/pages/admin/index.vue +++ b/dashboard/pages/admin/index.vue @@ -75,17 +75,6 @@ function onHideClicked() { const activeProject = useActiveProject(); -async function payment() { - // const res = await $fetch(`/api/pay/${activeProject.value?._id.toString()}/create`, { - // ...signHeaders({ 'content-type': 'application/json' }), - // method: 'POST', - // body: JSON.stringify({ planId: 1 }) - // }) - // console.log(res); - const res = await $fetch(`/api/pay/${activeProject.value?._id.toString()}/invoices`, signHeaders()) - console.log(res); -} - @@ -93,7 +82,7 @@ async function payment() {
-
Nascondi dalla barra
diff --git a/dashboard/pages/plans.vue b/dashboard/pages/plans.vue index 7dc9ec7..fdba3c8 100644 --- a/dashboard/pages/plans.vue +++ b/dashboard/pages/plans.vue @@ -107,6 +107,9 @@ function openInvoice(link: string) {
{{ daysLeft }} days left
+
+ Subscription: {{ planData.subscription_status }} +
diff --git a/dashboard/server/api/ai/[project_id]/chats_remaining.ts b/dashboard/server/api/ai/[project_id]/chats_remaining.ts index 596e8dd..e306a4d 100644 --- a/dashboard/server/api/ai/[project_id]/chats_remaining.ts +++ b/dashboard/server/api/ai/[project_id]/chats_remaining.ts @@ -1,10 +1,10 @@ +import { ProjectLimitModel } from "@schema/ProjectsLimits"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; -import { checkProjectCount } from '@functions/UtilsProjectCounts'; export async function getAiChatRemainings(project_id: string) { - const counts = await checkProjectCount(project_id) - if (!counts) return 0; - const chatsRemaining = counts.ai_limit - counts.ai_messages; + const limits = await ProjectLimitModel.findOne({ _id: project_id }) + if (!limits) return 0; + const chatsRemaining = limits.ai_limit - limits.ai_messages; if (isNaN(chatsRemaining)) return 0; return chatsRemaining; } diff --git a/dashboard/server/api/auth/google_login.post.ts b/dashboard/server/api/auth/google_login.post.ts index 6de7494..f11a59d 100644 --- a/dashboard/server/api/auth/google_login.post.ts +++ b/dashboard/server/api/auth/google_login.post.ts @@ -3,6 +3,8 @@ import { OAuth2Client } from 'google-auth-library'; import { createUserJwt } from '~/server/AuthManager'; import { UserModel } from '@schema/UserSchema'; import EmailService from '@services/EmailService'; +import { ProjectModel } from '@schema/ProjectSchema'; +import StripeService from '~/server/services/StripeService'; const { GOOGLE_AUTH_CLIENT_SECRET, GOOGLE_AUTH_CLIENT_ID } = useRuntimeConfig() @@ -11,6 +13,8 @@ const client = new OAuth2Client({ clientSecret: GOOGLE_AUTH_CLIENT_SECRET }); + + export default defineEventHandler(async event => { const body = await readBody(event) @@ -33,8 +37,10 @@ export default defineEventHandler(async event => { const user = await UserModel.findOne({ email: payload.email }); + if (user) return { error: false, access_token: createUserJwt({ email: user.email, name: user.name }) } + const newUser = new UserModel({ email: payload.email, given_name: payload.given_name, diff --git a/dashboard/server/api/pay/[project_id]/create.post.ts b/dashboard/server/api/pay/[project_id]/create.post.ts index ccd1ac8..8f31072 100644 --- a/dashboard/server/api/pay/[project_id]/create.post.ts +++ b/dashboard/server/api/pay/[project_id]/create.post.ts @@ -1,5 +1,4 @@ -import { PREMIUM_PLANS, STRIPE_PLANS } from "@data/PREMIUM_LIMITS"; -import { ProjectModel } from "@schema/ProjectSchema"; +import { getPlanFromId } from "@data/PREMIUM"; import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; import StripeService from '~/server/services/StripeService'; @@ -17,24 +16,22 @@ export default defineEventHandler(async event => { const { planId } = body; - const plan = PREMIUM_PLANS.find(e => e.id == planId); + const PLAN = getPlanFromId(planId); - if (!plan) { + 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, + PLAN.PRICE, 'https://dashboard.litlyx.com/payment_ok', project_id, project.customer_id ); if (!checkout) { - console.error('Cannot create payment', { plan, price }); + console.error('Cannot create payment', { plan: PLAN }); return setResponseStatus(event, 400, 'Cannot create payment'); } diff --git a/dashboard/server/api/pay/[project_id]/invoices.ts b/dashboard/server/api/pay/[project_id]/invoices.ts index 31460a5..b355835 100644 --- a/dashboard/server/api/pay/[project_id]/invoices.ts +++ b/dashboard/server/api/pay/[project_id]/invoices.ts @@ -1,4 +1,5 @@ import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA"; +import { Redis } from "~/server/services/CacheService"; import StripeService from '~/server/services/StripeService'; @@ -21,17 +22,21 @@ export default defineEventHandler(async event => { if (!project.customer_id) return []; - const invoices = await StripeService.getInvoices(project.customer_id); + return await Redis.useCache({ key: `invoices:${project_id}`, exp: 10 }, async () => { - return invoices?.data.map(e => { - const result: InvoiceData = { - link: e.invoice_pdf || '', - id: e.number || '', - date: e.created * 1000, - status: e.status || 'NO_STATUS', - cost: e.amount_due - } - return result; - }) + const invoices = await StripeService.getInvoices(project.customer_id); + + return invoices?.data.map(e => { + const result: InvoiceData = { + link: e.invoice_pdf || '', + id: e.number || '', + date: e.created * 1000, + status: e.status || 'NO_STATUS', + cost: e.amount_due + } + return result; + }); + + }); }); \ No newline at end of file diff --git a/dashboard/server/api/pay/webhook.post.ts b/dashboard/server/api/pay/webhook.post.ts index 4c49b00..b625ddd 100644 --- a/dashboard/server/api/pay/webhook.post.ts +++ b/dashboard/server/api/pay/webhook.post.ts @@ -2,69 +2,187 @@ 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 { PREMIUM_PLAN, getPlanFromPrice } from '@data/PREMIUM'; import { ProjectCountModel } from '@schema/ProjectsCounts'; +import { ProjectLimitModel } from '@schema/ProjectsLimits'; +import { UserModel } from '@schema/UserSchema'; async function onPaymentSuccess(event: Event.InvoicePaidEvent) { - if (event.data.object.status === 'paid') { + // if (event.data.object.status === 'paid') { - const pid = event.data.object.subscription_details?.metadata?.pid; + // const data = event.data.object; - const project = await ProjectModel.findById(pid); - if (!project) return { error: 'Project not found' } + // const pid = data.subscription_details?.metadata?.pid; + // if (!pid) return { error: 'ProjectId not found' } - const subscriptionId = event.data.object.subscription; - if (!subscriptionId) return { error: 'SubscriptionId not found' } + // const project = await ProjectModel.findById(pid); + // if (!project) return { error: 'Project not found' } - const price = event.data.object.lines.data[0].plan?.id; - if (!price) return { error: 'Price not found' } + // const price = data.lines.data[0].plan?.id; + // if (!price) return { error: 'Price not found' } - const premiumTag = getPlanTagFromStripePrice(price); - if (!premiumTag) return { error: 'Premium tag not found' } + // const PLAN = getPlanFromPrice(price); + // if (!PLAN) return { error: 'Plan not found' } - const plan = getPlanFromPremiumTag(premiumTag); - if (!plan) return { error: 'Plan not found' } + // await ProjectModel.updateOne({ _id: pid }, { + // premium: true, + // customer_id: data.customer, + // premium_type: PLAN.ID, + // premium_expire_at: data.lines.data[0].period.end * 1000 + // }); - await ProjectModel.updateOne({ _id: pid }, { - premium: true, - customer_id: event.data.object.customer, - premium_type: plan.id, - premium_expire_at: event.data.object.lines.data[0].period.end * 1000 - }); + // await ProjectCountModel.create({ + // project_id: project._id, + // events: 0, + // visits: 0, + // ai_messages: 0, + // limit: PLAN.COUNT_LIMIT, + // ai_limit: PLAN.AI_MESSAGE_LIMIT, + // billing_start_at: event.data.object.lines.data[0].period.start * 1000, + // billing_expire_at: event.data.object.lines.data[0].period.end * 1000, + // }); - 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: event.data.object.lines.data[0].period.start * 1000, - billing_expire_at: event.data.object.lines.data[0].period.end * 1000, - }); - - return { ok: true } - } + // return { ok: true } + // } return { received: true } } async function onSubscriptionCreated(event: Event.CustomerSubscriptionCreatedEvent) { - return { received: true } + + const project = await ProjectModel.findOne({ customer_id: event.data.object.customer }); + if (!project) return { error: 'Project not found' } + + const price = event.data.object.items.data[0].price.id; + if (!price) return { error: 'Price not found' } + + const PLAN = getPlanFromPrice(price); + if (!PLAN) return { error: 'Plan not found' } + + + if (project.subscription_id != event.data.object.id) { + await StripeService.deleteSubscription(project.subscription_id); + } + + project.premium = PLAN.ID != 0; + project.premium_type = PLAN.ID; + project.subscription_id = event.data.object.id; + project.premium_expire_at = new Date(event.data.object.current_period_end * 1000); + + await Promise.all([ + + project.save(), + + ProjectLimitModel.updateOne({ project_id: project._id }, { + events: 0, + visits: 0, + ai_messages: 0, + limit: PLAN.COUNT_LIMIT, + ai_limit: PLAN.AI_MESSAGE_LIMIT, + billing_start_at: event.data.object.current_period_start * 1000, + billing_expire_at: event.data.object.current_period_end * 1000, + }, { upsert: true }) + + ]); + + return { ok: true } } async function onSubscriptionDeleted(event: Event.CustomerSubscriptionDeletedEvent) { - return { received: true } + + const project = await ProjectModel.findOne({ + customer_id: event.data.object.customer, + subscription_id: event.data.object.id + }); + + if (!project) return { error: 'Project not found' } + + const PLAN = PREMIUM_PLAN['FREE']; + + const targetCustomer = await StripeService.getCustomer(project.customer_id); + + let customer: Event.Customer; + + if (!targetCustomer.deleted) { + customer = targetCustomer; + } else { + const user = await UserModel.findById(project._id, { email: 1 }); + if (!user) return { error: 'User not found' } + const newCustomer = await StripeService.createCustomer(user.email); + customer = newCustomer; + } + + const freeSubscription = await StripeService.createFreeSubscription(customer.id); + + + project.premium = false; + project.premium_type = PLAN.ID; + project.subscription_id = freeSubscription.id; + project.premium_expire_at = new Date(freeSubscription.current_period_end * 1000); + + + await Promise.all([ + + project.save(), + + ProjectLimitModel.updateOne({ project_id: project._id }, { + events: 0, + visits: 0, + ai_messages: 0, + limit: PLAN.COUNT_LIMIT, + ai_limit: PLAN.AI_MESSAGE_LIMIT, + billing_start_at: event.data.object.current_period_start * 1000, + billing_expire_at: event.data.object.current_period_end * 1000, + }, { upsert: true }) + + ]); + + return { ok: true } } async function onSubscriptionUpdated(event: Event.CustomerSubscriptionUpdatedEvent) { - return { received: true } + + const project = await ProjectModel.findOne({ + customer_id: event.data.object.customer, + subscription_id: event.data.object.id + }); + + if (!project) return { error: 'Project not found' } + + const price = event.data.object.items.data[0].price.id; + if (!price) return { error: 'Price not found' } + + const PLAN = getPlanFromPrice(price); + if (!PLAN) return { error: 'Plan not found' } + + project.premium = PLAN.ID != 0; + project.premium_type = PLAN.ID; + project.subscription_id = event.data.object.id; + project.premium_expire_at = new Date(event.data.object.current_period_end * 1000); + + await Promise.all([ + + project.save(), + + ProjectLimitModel.updateOne({ project_id: project._id }, { + events: 0, + visits: 0, + ai_messages: 0, + limit: PLAN.COUNT_LIMIT, + ai_limit: PLAN.AI_MESSAGE_LIMIT, + billing_start_at: event.data.object.current_period_start * 1000, + billing_expire_at: event.data.object.current_period_end * 1000, + }, { upsert: true }) + + ]); + + return { ok: true } } + + export default defineEventHandler(async event => { const body = await readRawBody(event); diff --git a/dashboard/server/api/project/create.post.ts b/dashboard/server/api/project/create.post.ts index e93e5be..c520d93 100644 --- a/dashboard/server/api/project/create.post.ts +++ b/dashboard/server/api/project/create.post.ts @@ -1,4 +1,5 @@ import { ProjectModel, TProject } from "@schema/ProjectSchema"; +import StripeService from '~/server/services/StripeService'; export default defineEventHandler(async event => { @@ -15,9 +16,23 @@ export default defineEventHandler(async event => { const existingUserProjects = await ProjectModel.countDocuments({ owner: userData.id }); if (existingUserProjects == 3) return setResponseStatus(event, 400, 'Already have 3 projects'); - const newProject = new ProjectModel({ owner: userData.id, name: newProjectName }); - const saved = await newProject.save(); + const customer = await StripeService.createCustomer(userData.user.email); + if (!customer) return setResponseStatus(event, 400, 'Error creating customer'); - return saved.toJSON() as TProject; + const subscription = await StripeService.createFreeSubscription(customer.id); + if (!subscription) return setResponseStatus(event, 400, 'Error creating subscription'); + + const project = await ProjectModel.create({ + owner: userData.id, + name: newProjectName, + premium: false, + premium_type: 0, + customer_id: customer.id, + subscription_id: subscription.id, + premium_expire_at: subscription.current_period_end * 1000 + }); + + + return project.toJSON() as TProject; }); \ No newline at end of file diff --git a/dashboard/server/api/project/delete.delete.ts b/dashboard/server/api/project/delete.delete.ts index 7920e6f..1587ac9 100644 --- a/dashboard/server/api/project/delete.delete.ts +++ b/dashboard/server/api/project/delete.delete.ts @@ -1,4 +1,7 @@ import { ProjectModel } from "@schema/ProjectSchema"; +import { ProjectCountModel } from "@schema/ProjectsCounts"; +import { ProjectLimitModel } from "@schema/ProjectsLimits"; +import StripeService from '~/server/services/StripeService'; export default defineEventHandler(async event => { @@ -9,10 +12,29 @@ export default defineEventHandler(async event => { const userData = getRequestUser(event); if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged'); + const project = await ProjectModel.findById(projectId); + if (!project) return setResponseStatus(event, 400, 'Project not exist'); + const projects = await ProjectModel.countDocuments({ owner: userData.id }); if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project'); - const deletation = await ProjectModel.deleteOne({ owner: userData.id, _id: projectId }); - return { ok: deletation.acknowledged }; + if (project.premium === true) return setResponseStatus(event, 400, 'Cannot delete premium project'); + + await StripeService.deleteCustomer(project.customer_id); + + const countDeletation = await ProjectCountModel.deleteOne({ owner: userData.id, _id: projectId }); + const limitdeletation = await ProjectLimitModel.deleteOne({ owner: userData.id, _id: projectId }); + const projectDeletation = await ProjectModel.deleteOne({ owner: userData.id, _id: projectId }); + + const ok = countDeletation.acknowledged && limitdeletation.acknowledged && projectDeletation.acknowledged + + return { + ok, + data: [ + countDeletation.acknowledged, + limitdeletation.acknowledged, + projectDeletation.acknowledged + ] + }; }); \ No newline at end of file diff --git a/dashboard/server/api/project/plan.ts b/dashboard/server/api/project/plan.ts index 6e8426d..30ae73f 100644 --- a/dashboard/server/api/project/plan.ts +++ b/dashboard/server/api/project/plan.ts @@ -1,8 +1,7 @@ -import { ProjectModel, TProject } from "@schema/ProjectSchema"; -import { ProjectCountModel } from "@schema/ProjectsCounts"; +import { ProjectModel } from "@schema/ProjectSchema"; +import { ProjectLimitModel } from "@schema/ProjectsLimits"; import { UserSettingsModel } from "@schema/UserSettings"; - -const { BROKER_UPDATE_EXPIRE_TIME_PATH } = useRuntimeConfig(); +import StripeService from '~/server/services/StripeService'; export default defineEventHandler(async event => { @@ -17,25 +16,20 @@ export default defineEventHandler(async event => { const project = await ProjectModel.findById(project_id); if (!project) return setResponseStatus(event, 400, 'Project not found'); + const subscription = await StripeService.getSubscription(project.subscription_id); - let projectCounts = await ProjectCountModel.findOne({ project_id }, {}, { - sort: { billing_expire_at: -1 } - }); + const projectLimits = await ProjectLimitModel.findOne({ project_id }); + if (!projectLimits) return setResponseStatus(event, 400, 'Project limits not found'); - if (!projectCounts || Date.now() > new Date(projectCounts.billing_expire_at).getTime()) { - await fetch(BROKER_UPDATE_EXPIRE_TIME_PATH + project._id.toString()); - projectCounts = await ProjectCountModel.findOne({ project_id }, {}, { sort: { billing_expire_at: -1 } }); - } - - if (!projectCounts) return setResponseStatus(event, 400, 'Project counts not found'); const result = { premium: project.premium, premium_type: project.premium_type, - billing_start_at: projectCounts.billing_start_at, - billing_expire_at: projectCounts.billing_expire_at, - limit: projectCounts.limit, - count: projectCounts.events + projectCounts.visits, + billing_start_at: projectLimits.billing_start_at, + billing_expire_at: projectLimits.billing_expire_at, + limit: projectLimits.limit, + count: projectLimits.events + projectLimits.visits, + subscription_status: subscription.status } return result; diff --git a/dashboard/server/services/AiService.ts b/dashboard/server/services/AiService.ts index d53adb3..39ab25b 100644 --- a/dashboard/server/services/AiService.ts +++ b/dashboard/server/services/AiService.ts @@ -5,7 +5,7 @@ import OpenAI from "openai"; import { AiChatModel } from '@schema/ai/AiChatSchema'; import { AI_EventsFunctions, AI_EventsTools } from '../api/ai/functions/AI_Events'; import { ProjectCountModel } from '@schema/ProjectsCounts'; -import { getCurrentProjectCountId } from '@functions/UtilsProjectCounts'; +import { ProjectLimitModel } from '@schema/ProjectsLimits'; const { AI_ORG, AI_PROJECT, AI_KEY } = useRuntimeConfig(); @@ -135,9 +135,7 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_ } - const currentCountId = await getCurrentProjectCountId(pid); - if (!currentCountId) console.error('Project not exist'); - await ProjectCountModel.updateOne({ _id: currentCountId }, { $inc: { ai_messages: 1 } }) + await ProjectLimitModel.updateOne({ project_id: pid }, { $inc: { ai_messages: 1 } }) return responseMessage.content; } diff --git a/dashboard/server/services/StripeService.ts b/dashboard/server/services/StripeService.ts index 63c2733..733c52a 100644 --- a/dashboard/server/services/StripeService.ts +++ b/dashboard/server/services/StripeService.ts @@ -1,3 +1,4 @@ +import { getPlanFromTag } from '@data/PREMIUM'; import Stripe from 'stripe'; class StripeService { @@ -10,11 +11,9 @@ class StripeService { 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.stripe) throw Error('Stripe not initialized'); if (!this.webhookSecret) { console.error('Stripe not initialized') return; @@ -23,10 +22,7 @@ class StripeService { } async cretePayment(price: string, success_url: string, pid: string, customer?: string) { - if (!this.stripe) { - console.error('Stripe not initialized') - return; - } + if (!this.stripe) throw Error('Stripe not initialized'); const checkout = await this.stripe.checkout.sessions.create({ payment_method_types: ['card'], @@ -44,11 +40,14 @@ class StripeService { return checkout; } + async deleteSubscription(subscriptionId: string) { + if (!this.stripe) throw Error('Stripe not initialized'); + const subscription = await this.stripe.subscriptions.cancel(subscriptionId); + return subscription; + } + async getSubscription(subscriptionId: string) { - if (!this.stripe) { - console.error('Stripe not initialized') - return; - } + if (!this.stripe) throw Error('Stripe not initialized'); const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); return subscription; } @@ -57,6 +56,45 @@ class StripeService { const invoices = await this.stripe?.invoices.list({ customer: customer_id }); return invoices; } + + + async getCustomer(customer_id: string) { + if (!this.stripe) throw Error('Stripe not initialized'); + const customer = await this.stripe.customers.retrieve(customer_id, { expand: [] }) + return customer; + } + + async createCustomer(email: string) { + if (!this.stripe) throw Error('Stripe not initialized'); + const customer = await this.stripe.customers.create({ email }); + return customer; + } + + async deleteCustomer(customer_id: string) { + if (!this.stripe) throw Error('Stripe not initialized'); + const { deleted } = await this.stripe.customers.del(customer_id); + return deleted; + } + + + + + async createFreeSubscription(customer_id: string) { + if (!this.stripe) throw Error('Stripe not initialized'); + + const FREE_PLAN = getPlanFromTag('FREE'); + + const subscription = await this.stripe.subscriptions.create({ + customer: customer_id, + items: [ + { price: FREE_PLAN.PRICE, quantity: 1 } + ] + }); + + return subscription; + + } + } const instance = new StripeService(); diff --git a/shared/data/PREMIUM.ts b/shared/data/PREMIUM.ts new file mode 100644 index 0000000..19bcf47 --- /dev/null +++ b/shared/data/PREMIUM.ts @@ -0,0 +1,66 @@ + +export type PREMIUM_TAG = typeof PREMIUM_TAGS[number]; + +export const PREMIUM_TAGS = [ + 'FREE', 'PLAN_1', 'PLAN_2', 'PLAN_3', 'PLAN_99' +] as const; + + +export type PREMIUM_DATA = { + COUNT_LIMIT: number, + AI_MESSAGE_LIMIT: number, + PRICE: string, + ID: number +} + +export const PREMIUM_PLAN: Record = { + FREE: { + ID: 0, + COUNT_LIMIT: 3_000, + AI_MESSAGE_LIMIT: 10, + PRICE: 'price_1PNbHYB2lPUiVs9VZP32xglF' + }, + PLAN_1: { + ID: 1, + COUNT_LIMIT: 150_000, + AI_MESSAGE_LIMIT: 100, + PRICE: 'price_1PNZjVB2lPUiVs9VrsTbJL04' + }, + PLAN_2: { + ID: 2, + COUNT_LIMIT: 500_000, + AI_MESSAGE_LIMIT: 5_000, + PRICE: '' + }, + PLAN_3: { + ID: 3, + COUNT_LIMIT: 2_000_000, + AI_MESSAGE_LIMIT: 10_000, + PRICE: '' + }, + PLAN_99: { + ID: 99, + COUNT_LIMIT: 10_000_000, + AI_MESSAGE_LIMIT: 100_000, + PRICE: '' + } +} + + +export function getPlanFromTag(tag: PREMIUM_TAG) { + return PREMIUM_PLAN[tag]; +} + +export function getPlanFromId(id: number) { + for (const tag of PREMIUM_TAGS) { + const plan = getPlanFromTag(tag); + if (plan.ID === id) return plan; + } +} + +export function getPlanFromPrice(price: string) { + for (const tag of PREMIUM_TAGS) { + const plan = getPlanFromTag(tag); + if (plan.PRICE === price) return plan; + } +} \ No newline at end of file diff --git a/shared/data/PREMIUM_LIMITS.ts b/shared/data/PREMIUM_LIMITS.ts deleted file mode 100644 index b652fde..0000000 --- a/shared/data/PREMIUM_LIMITS.ts +++ /dev/null @@ -1,81 +0,0 @@ - - -export const PREMIUM_PLANS = [ - { id: 0, tag: 'FREE', name: 'Free' }, - { id: 1, tag: 'PLAN_1', name: 'Premium 1' }, - { id: 2, tag: 'PLAN_2', name: 'Premium 2' }, - { id: 3, tag: 'PLAN_3', name: 'Premium 3' }, - { id: 99, tag: 'PLAN_99', name: 'Premium 99' }, -] as const; - -export function getPlanFromPremiumType(premium_type?: number) { - if (!premium_type) return PREMIUM_PLANS[0]; - const plan = PREMIUM_PLANS.find(e => e.id === premium_type); - if (!plan) return PREMIUM_PLANS[0]; - return plan; -} - -export function getPlanFromPremiumTag(tag: PREMIUM_PLAN_TAG) { - const plan = PREMIUM_PLANS.find(e => e.tag === tag); - return plan; -} - -export type PREMIUM_PLAN_TAG = typeof PREMIUM_PLANS[number]['tag']; - -export type PROJECT_LIMIT = { - COUNT_LIMIT: number, - AI_MESSAGE_LIMIT: number, -} - -export const PREMIUM_LIMITS: Record = { - FREE: { - COUNT_LIMIT: 3_000, - AI_MESSAGE_LIMIT: 10 - }, - PLAN_1: { - COUNT_LIMIT: 150_000, - AI_MESSAGE_LIMIT: 100 - }, - PLAN_2: { - COUNT_LIMIT: 500_000, - AI_MESSAGE_LIMIT: 5_000 - }, - PLAN_3: { - COUNT_LIMIT: 2_000_000, - AI_MESSAGE_LIMIT: 10_000 - }, - PLAN_99: { - COUNT_LIMIT: 10_000_000, - AI_MESSAGE_LIMIT: 100_000 - } -} - - -export type STRIPE_PLAN = { - price: string -} - -export const STRIPE_PLANS: Record = { - FREE: { - price: 'price_1PNbHYB2lPUiVs9VZP32xglF' - }, - PLAN_1: { - price: 'price_1PNZjVB2lPUiVs9VrsTbJL04' - }, - PLAN_2: { - price: '' - }, - PLAN_3: { - price: '' - }, - PLAN_99: { - price: '' - } -} - -export function getPlanTagFromStripePrice(price: string): PREMIUM_PLAN_TAG | undefined { - for (const plan of PREMIUM_PLANS.map(e => e.tag)) { - const stripePrice = STRIPE_PLANS[plan].price; - if (stripePrice === price) return plan; - } -} \ No newline at end of file diff --git a/shared/functions/UtilsProjectCounts.ts b/shared/functions/UtilsProjectCounts.ts deleted file mode 100644 index 4173c44..0000000 --- a/shared/functions/UtilsProjectCounts.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ProjectCountModel } from '../schema/ProjectsCounts'; -import { ProjectModel } from '../schema/ProjectSchema'; -import { LimitNotifyModel } from '../schema/broker/LimitNotifySchema'; -import { PREMIUM_LIMITS, getPlanFromPremiumType } from '../data/PREMIUM_LIMITS'; -import { MONTH } from '../utilts/TIME'; - - -export async function getCurrentProjectCountId(project_id: string) { - const projectCount = await ProjectCountModel.findOne({ project_id }, { _id: 1 }, { sort: { billing_expire_at: -1 } }); - return projectCount?._id.toString(); -} - -export async function getAllLimitsFromProjectId(project_id: string) { - const targetProject = await ProjectModel.findById(project_id, { - premium: 1, premium_type: 1, premium_expire_at: 1 - }); - if (!targetProject) return PREMIUM_LIMITS.FREE; - if (!targetProject.premium) return PREMIUM_LIMITS.FREE; - const plan = getPlanFromPremiumType(targetProject.premium_type); - return PREMIUM_LIMITS[plan.tag]; -} - -export async function checkProjectCount(project_id: string) { - - const targetProject = await ProjectModel.findById(project_id, { - premium: 1, premium_type: 1, premium_expire_at: 1 - }); - - if (!targetProject) return; - - if (new Date(targetProject.premium_expire_at).getTime() < Date.now()) { - await ProjectModel.updateOne({ _id: project_id }, { - premium: false, - $unset: { - premium_type: 1, - premium_expire_at: 1 - }, - }); - } - - const limits = await getAllLimitsFromProjectId(project_id); - - const projectCounts = await ProjectCountModel.findOne({ project_id }, {}, { sort: { billing_expire_at: -1 } }); - - const billingExpireAt = projectCounts ? new Date(projectCounts.billing_expire_at).getTime() : -1; - - if (projectCounts && Date.now() < billingExpireAt) { - if (projectCounts.ai_limit) return projectCounts.toJSON(); - projectCounts.ai_limit = limits.AI_MESSAGE_LIMIT; - const saved = await projectCounts.save(); - return saved.toJSON(); - } - - const newProjectCounts = await ProjectCountModel.create({ - project_id, - events: 0, - visits: 0, - limit: limits.COUNT_LIMIT, - ai_messages: 0, - ai_limit: limits.AI_MESSAGE_LIMIT, - billing_start_at: projectCounts ? billingExpireAt : Date.now(), - billing_expire_at: (projectCounts ? billingExpireAt : Date.now()) + MONTH - }); - - await LimitNotifyModel.updateOne({ project_id }, { limit1: false, limit2: false, limit3: false }); - - return newProjectCounts.toJSON(); - - -} \ No newline at end of file diff --git a/shared/schema/ProjectSchema.ts b/shared/schema/ProjectSchema.ts index d1151e9..5470139 100644 --- a/shared/schema/ProjectSchema.ts +++ b/shared/schema/ProjectSchema.ts @@ -5,8 +5,9 @@ export type TProject = { owner: Schema.Types.ObjectId, name: string, premium: boolean, - premium_type?: number, - customer_id?: string, + premium_type: number, + customer_id: string, + subscription_id: string, premium_expire_at: Date, created_at: Date } @@ -15,9 +16,10 @@ const ProjectSchema = new Schema({ owner: { type: Types.ObjectId, index: 1 }, name: { type: String, required: true }, premium: { type: Boolean, default: false }, - premium_type: { type: Number }, - customer_id: { type: String }, - premium_expire_at: { type: Date }, + premium_type: { type: Number, default: 0 }, + customer_id: { type: String, required: true }, + subscription_id: { type: String, required: true }, + premium_expire_at: { type: Date, required: true }, created_at: { type: Date, default: () => Date.now() }, }) diff --git a/shared/schema/ProjectsCounts.ts b/shared/schema/ProjectsCounts.ts index ebde4f2..915dd5f 100644 --- a/shared/schema/ProjectsCounts.ts +++ b/shared/schema/ProjectsCounts.ts @@ -5,22 +5,12 @@ export type TProjectCount = { project_id: Schema.Types.ObjectId, events: number, visits: number, - ai_messages: number, - limit: number, - ai_limit: number, - billing_expire_at: Date, - billing_start_at: Date, } const ProjectCountSchema = new Schema({ project_id: { type: Types.ObjectId, index: 1 }, events: { type: Number, required: true, default: 0 }, visits: { type: Number, required: true, default: 0 }, - ai_messages: { type: Number, required: true, default: 0 }, - limit: { type: Number, required: true }, - ai_limit: { type: Number, required: true }, - billing_start_at: { type: Date, required: true }, - billing_expire_at: { type: Date, required: true }, }); export const ProjectCountModel = model('project_counts', ProjectCountSchema); \ No newline at end of file diff --git a/shared/schema/ProjectsLimits.ts b/shared/schema/ProjectsLimits.ts new file mode 100644 index 0000000..b619225 --- /dev/null +++ b/shared/schema/ProjectsLimits.ts @@ -0,0 +1,26 @@ +import { model, Schema, Types } from 'mongoose'; + +export type TProjectLimit = { + _id: Schema.Types.ObjectId, + project_id: Schema.Types.ObjectId, + events: number, + visits: number, + ai_messages: number, + limit: number, + ai_limit: number, + billing_expire_at: Date, + billing_start_at: Date, +} + +const ProjectLimitSchema = new Schema({ + project_id: { type: Types.ObjectId, index: true, unique: true }, + events: { type: Number, required: true, default: 0 }, + visits: { type: Number, required: true, default: 0 }, + ai_messages: { type: Number, required: true, default: 0 }, + limit: { type: Number, required: true }, + ai_limit: { type: Number, required: true }, + billing_start_at: { type: Date, required: true }, + billing_expire_at: { type: Date, required: true }, +}); + +export const ProjectLimitModel = model('project_limits', ProjectLimitSchema); \ No newline at end of file