From 3e00e15e353a084d6fe87adc71db5ab62b5db3d9 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 18 Dec 2024 00:09:50 +0700 Subject: [PATCH] feat: hold money --- .../src/api/routes/billing.controller.ts | 6 +- .../src/api/routes/users.controller.ts | 1 + .../billing/main.billing.component.tsx | 2 +- .../src/components/layout/check.payment.tsx | 48 ++++++ .../src/components/layout/layout.settings.tsx | 120 ++++++++------ .../src/components/layout/user.context.tsx | 1 + apps/frontend/tailwind.config.js | 10 +- .../organizations/organization.repository.ts | 9 + .../organizations/organization.service.ts | 4 + .../src/database/prisma/schema.prisma | 1 + .../subscriptions/subscription.repository.ts | 20 +++ .../subscriptions/subscription.service.ts | 11 +- .../src/services/stripe.service.ts | 154 +++++++++++++++--- 13 files changed, 296 insertions(+), 91 deletions(-) create mode 100644 apps/frontend/src/components/layout/check.payment.tsx diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index dcbe7a9d..f14c8161 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -24,10 +24,10 @@ export class BillingController { @Param('id') body: string ) { return { - exists: !!(await this._subscriptionService.checkSubscription( + status: await this._stripeService.checkSubscription( org.id, body - )), + ), }; } @@ -39,7 +39,7 @@ export class BillingController { @Req() req: Request ) { const uniqueId = req?.cookies?.track; - return this._stripeService.subscribe(uniqueId, org.id, user.id, body); + return this._stripeService.subscribe(uniqueId, org.id, user.id, body, org.allowTrial); } @Get('/portal') diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 31345ad6..c3d851a5 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -68,6 +68,7 @@ export class UsersController { isLifetime: !!organization?.subscription?.isLifetime, admin: !!user.isSuperAdmin, impersonate: !!req.cookies.impersonate, + allowTrial: organization?.allowTrial, // @ts-ignore publicApi: organization?.users[0]?.role === 'SUPERADMIN' || organization?.users[0]?.role === 'ADMIN' ? organization?.apiKey diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 9a220760..bfbbc30d 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -481,7 +481,7 @@ export const MainBillingComponent: FC<{ .format('D MMM, YYYY')}` : 'Cancel subscription' : // @ts-ignore - user?.tier === 'FREE' || user?.tier?.current === 'FREE' + (user?.tier === 'FREE' || user?.tier?.current === 'FREE') && user.allowTrial ? 'Start 7 days free trial' : 'Purchase'} diff --git a/apps/frontend/src/components/layout/check.payment.tsx b/apps/frontend/src/components/layout/check.payment.tsx new file mode 100644 index 00000000..a3e8cc34 --- /dev/null +++ b/apps/frontend/src/components/layout/check.payment.tsx @@ -0,0 +1,48 @@ +import { FC, useCallback, useEffect, useState } from 'react'; +import Loading from 'react-loading'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { useToaster } from '@gitroom/react/toaster/toaster'; + +export const CheckPayment: FC<{ check: string; mutate: () => void }> = (props) => { + const [showLoader, setShowLoader] = useState(true); + const fetch = useFetch(); + const toaster = useToaster(); + + const checkSubscription = useCallback(async () => { + const {status} = await (await fetch('/billing/check/' + props.check)).json(); + if (status === 0) { + await timer(1000); + return checkSubscription(); + } + + if (status === 1) { + toaster.show( + 'We could not validate your payment method, please try again', + 'warning' + ); + setShowLoader(false); + } + + if (status === 2) { + setShowLoader(false); + props.mutate(); + } + }, []); + + useEffect(() => { + checkSubscription(); + }, []); + + if (showLoader) { + return ( +
+
+ +
+
+ ); + } + + return null; +}; diff --git a/apps/frontend/src/components/layout/layout.settings.tsx b/apps/frontend/src/components/layout/layout.settings.tsx index 49cf61c2..0bc19cb7 100644 --- a/apps/frontend/src/components/layout/layout.settings.tsx +++ b/apps/frontend/src/components/layout/layout.settings.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, useCallback } from 'react'; +import { ReactNode, useCallback, useEffect } from 'react'; import { Title } from '@gitroom/frontend/components/layout/title'; import { ContextWrapper } from '@gitroom/frontend/components/layout/user.context'; import { TopMenu } from '@gitroom/frontend/components/layout/top.menu'; @@ -8,7 +8,7 @@ import { MantineWrapper } from '@gitroom/react/helpers/mantine.wrapper'; import { ToolTip } from '@gitroom/frontend/components/layout/top.tip'; import { ShowMediaBoxModal } from '@gitroom/frontend/components/media/media.component'; import Image from 'next/image'; -import { Toaster } from '@gitroom/react/toaster/toaster'; +import { Toaster, useToaster } from '@gitroom/react/toaster/toaster'; import { ShowPostSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector'; import { OrganizationSelector } from '@gitroom/frontend/components/layout/organization.selector'; import NotificationComponent from '@gitroom/frontend/components/notifications/notification.component'; @@ -37,6 +37,8 @@ const ModeComponent = dynamic( ); import { extend } from 'dayjs'; +import { useSearchParams } from 'next/navigation'; +import { CheckPayment } from '@gitroom/frontend/components/layout/check.payment'; extend(utc); extend(weekOfYear); @@ -47,11 +49,12 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => { const fetch = useFetch(); const { isGeneral } = useVariables(); const { backendUrl, billingEnabled } = useVariables(); + const searchParams = useSearchParams(); const load = useCallback(async (path: string) => { return await (await fetch(path)).json(); }, []); - const { data: user } = useSWR('/user/self', load, { + const { data: user, mutate } = useSWR('/user/self', load, { revalidateOnFocus: false, revalidateOnReconnect: false, revalidateIfStale: false, @@ -69,6 +72,12 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => { > + {user.tier === 'FREE' && searchParams.get('check') && ( + + )} @@ -132,7 +141,10 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => { ) : ( <> )} -
+
@@ -150,59 +162,61 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => { To Manage All Your Social Media Channels
-
-
-
- - - + {user?.allowTrial && ( +
+
+
+ + + +
+
100% no-risk trial
-
100% no-risk trial
-
-
-
- - - +
+
+ + + +
+
Pay nothing for the first 7 days
-
Pay nothing for the first 7 days
-
-
-
- - - +
+
+ + + +
+
Cancel anytime, hassle-free
-
Cancel anytime, hassle-free
-
+ )}
diff --git a/apps/frontend/src/components/layout/user.context.tsx b/apps/frontend/src/components/layout/user.context.tsx index 88d19fd3..d6be4e9e 100644 --- a/apps/frontend/src/components/layout/user.context.tsx +++ b/apps/frontend/src/components/layout/user.context.tsx @@ -17,6 +17,7 @@ export const UserContext = createContext< totalChannels: number; isLifetime?: boolean; impersonate: boolean; + allowTrial: boolean; }) >(undefined); diff --git a/apps/frontend/tailwind.config.js b/apps/frontend/tailwind.config.js index 32b81732..50a646ac 100644 --- a/apps/frontend/tailwind.config.js +++ b/apps/frontend/tailwind.config.js @@ -131,11 +131,11 @@ module.exports = { '100%': { overflow: 'hidden' }, }, fadeDown: { - '0%': { opacity: 0, transform: 'translateY(-30px)' }, - '10%': { opacity: 1, transform: 'translateY(0)' }, - '85%': { opacity: 1, transform: 'translateY(0)' }, - '90%': { opacity: 1, transform: 'translateY(10px)' }, - '100%': { opacity: 0, transform: 'translateY(-30px)' }, + '0%': { opacity: 0, marginTop: -30}, + '10%': { opacity: 1, marginTop: 0 }, + '85%': { opacity: 1, marginTop: 0 }, + '90%': { opacity: 1, marginTop: 10 }, + '100%': { opacity: 0, marginTop: -30 }, }, normalFadeDown: { '0%': { opacity: 0, transform: 'translateY(-30px)' }, diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts index 4da999a9..ed5c5f07 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -215,6 +215,7 @@ export class OrganizationRepository { data: { name: body.company, apiKey: AuthService.fixedEncryption(makeId(20)), + allowTrial: true, users: { create: { role: Role.SUPERADMIN, @@ -246,6 +247,14 @@ export class OrganizationRepository { }); } + getOrgByCustomerId(customerId: string) { + return this._organization.model.organization.findFirst({ + where: { + paymentId: customerId, + }, + }); + } + async getTeam(orgId: string) { return this._organization.model.organization.findUnique({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts index 37235d61..48dec476 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts @@ -60,6 +60,10 @@ export class OrganizationService { return this._organizationRepository.getTeam(orgId); } + getOrgByCustomerId(customerId: string) { + return this._organizationRepository.getOrgByCustomerId(customerId); + } + async inviteTeamMember(orgId: string, body: AddTeamMemberDto) { const timeLimit = dayjs().add(1, 'hour').format('YYYY-MM-DD HH:mm:ss'); const id = makeId(5); diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 088c2a0f..460b6363 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -25,6 +25,7 @@ model Organization { Integration Integration[] post Post[] @relation("organization") submittedPost Post[] @relation("submittedForOrg") + allowTrial Boolean @default(false) Comments Comments[] notifications Notifications[] buyerOrganization MessagesGroup[] diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts index 04ccb431..6fc9517c 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -64,6 +64,17 @@ export class SubscriptionRepository { }); } + getCustomerIdByOrgId(organizationId: string) { + return this._organization.model.organization.findFirst({ + where: { + id: organizationId, + }, + select: { + paymentId: true, + }, + }); + } + checkSubscription(organizationId: string, subscriptionId: string) { return this._subscription.model.subscription.findFirst({ where: { @@ -158,6 +169,15 @@ export class SubscriptionRepository { }, }); + await this._organization.model.organization.update({ + where: { + id: findOrg.id, + }, + data: { + allowTrial: false, + }, + }); + if (code) { await this._usedCodes.model.usedCodes.create({ data: { diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index 4f11c0f9..6b5ae00d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -6,13 +6,14 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o import { Organization } from '@prisma/client'; import dayjs from 'dayjs'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; @Injectable() export class SubscriptionService { constructor( private readonly _subscriptionRepository: SubscriptionRepository, private readonly _integrationService: IntegrationService, - private readonly _organizationService: OrganizationService + private readonly _organizationService: OrganizationService, ) {} getSubscriptionByOrganizationId(organizationId: string) { @@ -55,8 +56,8 @@ export class SubscriptionService { ); } - checkSubscription(organizationId: string, subscriptionId: string) { - return this._subscriptionRepository.checkSubscription( + async checkSubscription(organizationId: string, subscriptionId: string) { + return await this._subscriptionRepository.checkSubscription( organizationId, subscriptionId ); @@ -197,9 +198,7 @@ export class SubscriptionService { 'MONTHLY', null, undefined, - orgId + orgId ); } - - } diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 347995e4..5d7a2e12 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -45,20 +45,79 @@ export class StripeService { ); } - createSubscription(event: Stripe.CustomerSubscriptionCreatedEvent) { + async checkValidCard( + event: + | Stripe.CustomerSubscriptionCreatedEvent + | Stripe.CustomerSubscriptionUpdatedEvent + ) { + if (event.data.object.status === 'incomplete') { + return false; + } + + const getOrgFromCustomer = await this._organizationService.getOrgByCustomerId(event.data.object.customer as string); + + if (!getOrgFromCustomer?.allowTrial) { + return true; + } + + console.log('Checking card'); + + const paymentMethods = await stripe.paymentMethods.list({ + customer: event.data.object.customer as string, + }); + + // find the last one created + const latestMethod = paymentMethods.data.reduce((prev, current) => { + if (prev.created < current.created) { + return current; + } + return prev; + }); + + const paymentIntent = await stripe.paymentIntents.create({ + amount: 100, + currency: 'usd', + payment_method: latestMethod.id, + customer: event.data.object.customer as string, + automatic_payment_methods: { + allow_redirects: 'never', + enabled: true, + }, + capture_method: 'manual', // Authorize without capturing + confirm: true, // Confirm the PaymentIntent + }); + + if (paymentIntent.status !== 'requires_capture') { + console.error('Cant charge'); + await stripe.paymentMethods.detach(paymentMethods.data[0].id); + await stripe.subscriptions.cancel(event.data.object.id as string); + return false; + } + + await stripe.paymentIntents.cancel(paymentIntent.id as string); + return true; + } + + async createSubscription(event: Stripe.CustomerSubscriptionCreatedEvent) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const { - id, + uniqueId, billing, period, }: { billing: 'STANDARD' | 'PRO'; period: 'MONTHLY' | 'YEARLY'; - id: string; + uniqueId: string; } = event.data.object.metadata; + + const check = await this.checkValidCard(event); + if (!check) { + return { ok: false }; + } + return this._subscriptionService.createOrUpdateSubscription( - id, + uniqueId, event.data.object.customer as string, pricing[billing].channel!, billing, @@ -66,20 +125,26 @@ export class StripeService { event.data.object.cancel_at ); } - updateSubscription(event: Stripe.CustomerSubscriptionUpdatedEvent) { + async updateSubscription(event: Stripe.CustomerSubscriptionUpdatedEvent) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const { - id, + uniqueId, billing, period, }: { billing: 'STANDARD' | 'PRO'; period: 'MONTHLY' | 'YEARLY'; - id: string; + uniqueId: string; } = event.data.object.metadata; + + const check = await this.checkValidCard(event); + if (!check) { + return { ok: false }; + } + return this._subscriptionService.createOrUpdateSubscription( - id, + uniqueId, event.data.object.customer as string, pricing[billing].channel!, billing, @@ -218,6 +283,15 @@ export class StripeService { } } + async getCustomerSubscriptions(organizationId: string) { + const org = (await this._organizationService.getOrgById(organizationId))!; + const customer = org.paymentId; + return stripe.subscriptions.list({ + customer: customer!, + status: 'all', + }); + } + async setToCancel(organizationId: string) { const id = makeId(10); const org = await this._organizationService.getOrgById(organizationId); @@ -228,7 +302,7 @@ export class StripeService { customer, status: 'all', }) - ).data, + ).data.filter((f) => f.status !== 'canceled'), }; const { cancel_at } = await stripe.subscriptions.update( @@ -275,7 +349,8 @@ export class StripeService { customer: string, body: BillingSubscribeDto, price: string, - userId: string + userId: string, + allowTrial: boolean ) { const isUtm = body.utm ? `&utm_source=${body.utm}` : ''; const { url } = await stripe.checkout.sessions.create({ @@ -286,7 +361,7 @@ export class StripeService { `/launches?onboarding=true&check=${uniqueId}${isUtm}`, mode: 'subscription', subscription_data: { - trial_period_days: 7, + ...(allowTrial ? { trial_period_days: 7 } : {}), metadata: { service: 'gitroom', ...body, @@ -370,6 +445,34 @@ export class StripeService { return accountLink.url; } + async checkSubscription(organizationId: string, subscriptionId: string) { + const orgValue = await this._subscriptionService.checkSubscription( + organizationId, + subscriptionId + ); + + if (orgValue) { + return 2; + } + + const getCustomerSubscriptions = await this.getCustomerSubscriptions( + organizationId + ); + if (getCustomerSubscriptions.data.length === 0) { + return 0; + } + + if ( + getCustomerSubscriptions.data.find( + (p) => p.metadata.uniqueId === subscriptionId + )?.canceled_at + ) { + return 1; + } + + return 0; + } + async payAccountStepOne( userId: string, organization: Organization, @@ -431,7 +534,8 @@ export class StripeService { uniqueId: string, organizationId: string, userId: string, - body: BillingSubscribeDto + body: BillingSubscribeDto, + allowTrial: boolean ) { const id = makeId(10); const priceData = pricing[body.billing]; @@ -481,6 +585,21 @@ export class StripeService { }, })); + const getCurrentSubscriptions = + await this._subscriptionService.getSubscription(organizationId); + + if (!getCurrentSubscriptions) { + return this.createCheckoutSession( + uniqueId, + id, + customer, + body, + findPrice!.id, + userId, + allowTrial + ); + } + const currentUserSubscription = { data: ( await stripe.subscriptions.list({ @@ -490,17 +609,6 @@ export class StripeService { ).data.filter((f) => f.status === 'active' || f.status === 'trialing'), }; - if (!currentUserSubscription.data.length) { - return this.createCheckoutSession( - uniqueId, - id, - customer, - body, - findPrice!.id, - userId - ); - } - try { await stripe.subscriptions.update(currentUserSubscription.data[0].id, { cancel_at_period_end: false,