From 626156499fad2bcb71ddfe77dac0f2774aac9efe Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:32:05 +0700 Subject: [PATCH 1/2] feat(billing): sell DOS Plus/Pro instead of Postiz Stripe packages Crove is first-party DOS, so checkout must grant the same user_plans row used by app.dos.ai. Co-authored-by: Cursor --- .env.example | 6 + apps/backend/src/api/api.module.ts | 4 + .../src/api/routes/billing.controller.ts | 68 +++++++++- .../routes/no.auth.integrations.controller.ts | 3 +- .../src/api/routes/users.controller.ts | 46 +++++-- .../auth/permissions/permissions.service.ts | 5 +- apps/frontend/src/app/(app)/layout.tsx | 2 +- apps/frontend/src/app/(extension)/layout.tsx | 2 +- apps/frontend/src/app/(provider)/layout.tsx | 2 +- .../billing/first.billing.component.tsx | 81 ++++++++++-- .../billing/main.billing.component.tsx | 74 +++++++---- .../src/components/layout/user.context.tsx | 2 + .../jest.dos-billing.config.cjs | 9 ++ .../integrations/integration.service.ts | 3 +- .../organizations/organization.repository.ts | 3 +- .../organizations/organization.service.ts | 3 +- .../subscriptions/subscription.repository.ts | 36 ++++++ .../subscriptions/subscription.service.ts | 22 ++++ .../src/dos-billing/crove-billing-gate.ts | 13 ++ .../src/dos-billing/dos-me-billing.client.ts | 109 ++++++++++++++++ .../src/dos-billing/dos-plan.map.spec.ts | 49 +++++++ .../src/dos-billing/dos-plan.map.ts | 60 +++++++++ .../dos-billing/dos-shared-billing.service.ts | 122 ++++++++++++++++++ 23 files changed, 662 insertions(+), 62 deletions(-) create mode 100644 libraries/nestjs-libraries/jest.dos-billing.config.cjs create mode 100644 libraries/nestjs-libraries/src/dos-billing/crove-billing-gate.ts create mode 100644 libraries/nestjs-libraries/src/dos-billing/dos-me-billing.client.ts create mode 100644 libraries/nestjs-libraries/src/dos-billing/dos-plan.map.spec.ts create mode 100644 libraries/nestjs-libraries/src/dos-billing/dos-plan.map.ts create mode 100644 libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts diff --git a/.env.example b/.env.example index eac62203d4..02e9c9761f 100644 --- a/.env.example +++ b/.env.example @@ -126,6 +126,12 @@ STRIPE_SECRET_KEY="" STRIPE_SIGNING_KEY="" STRIPE_SIGNING_KEY_CONNECT="" +# DOS shared billing — Crove sells DOS Plus ($9) / Pro ($19), not Postiz Stripe packages. +# Keep STRIPE_PUBLISHABLE_KEY set so channel gating stays on. Checkout goes to DOS-Me. +# DOS_SHARED_BILLING=true +# DOS_ME_API_URL=https://api.dos.me +# DOS_ME_INTERNAL_API_KEY="" + # Developer Settings NX_ADD_PLUGINS=false IS_GENERAL="true" # required for now diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 5c35e60dcd..dfe64282dc 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -43,6 +43,8 @@ import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google. import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider'; import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider'; import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider'; +import { DosMeBillingClient } from '@gitroom/nestjs-libraries/dos-billing/dos-me-billing.client'; +import { DosSharedBillingService } from '@gitroom/nestjs-libraries/dos-billing/dos-shared-billing.service'; const authenticatedController = [ UsersController, @@ -96,6 +98,8 @@ const authenticatedController = [ FarcasterProvider, WalletProvider, OauthProvider, + DosMeBillingClient, + DosSharedBillingService, ], get exports() { return [...this.imports, ...this.providers]; diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index 2c206e74fb..87b9d88470 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -11,6 +11,7 @@ import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/n import { Request } from 'express'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; +import { DosSharedBillingService } from '@gitroom/nestjs-libraries/dos-billing/dos-shared-billing.service'; @ApiTags('Billing') @Controller('/billing') @@ -19,7 +20,8 @@ export class BillingController { private _subscriptionService: SubscriptionService, private _stripeService: StripeService, private _notificationService: NotificationService, - private _usersService: UsersService + private _usersService: UsersService, + private _dosBilling: DosSharedBillingService ) {} private async assertNoOtherSubscribedAccount(user: User) { @@ -82,6 +84,10 @@ export class BillingController { return { blocked: true }; } + if (this._dosBilling.enabled()) { + return this.subscribeThroughDos(user, body.billing); + } + const uniqueId = req?.cookies?.track; return this._stripeService.embedded( uniqueId, @@ -103,6 +109,10 @@ export class BillingController { return { blocked: true }; } + if (this._dosBilling.enabled()) { + return this.subscribeThroughDos(user, body.billing); + } + const uniqueId = req?.cookies?.track; return this._stripeService.subscribe( uniqueId, @@ -114,7 +124,17 @@ export class BillingController { } @Get('/portal') - async modifyPayment(@GetOrgFromRequest() org: Organization) { + async modifyPayment( + @GetOrgFromRequest() org: Organization, + @GetUserFromRequest() user: User + ) { + if (this._dosBilling.enabled()) { + const { url } = await this._dosBilling.portal( + user, + process.env.FRONTEND_URL || 'https://post.crove.com' + ); + return { portal: url }; + } const customer = await this._stripeService.getCustomerByOrganizationId( org.id ); @@ -125,7 +145,15 @@ export class BillingController { } @Get('/') - getCurrentBilling(@GetOrgFromRequest() org: Organization) { + async getCurrentBilling( + @GetOrgFromRequest() org: Organization, + @GetUserFromRequest() user: User + ) { + if (this._dosBilling.enabled() && user && !user.isSuperAdmin) { + try { + await this._dosBilling.syncOrg(user, org.id); + } catch {} + } return this._subscriptionService.getSubscriptionByOrganizationId(org.id); } @@ -142,17 +170,49 @@ export class BillingController { user.email ); + if (this._dosBilling.enabled()) { + await this._dosBilling.cancel(user); + try { + await this._dosBilling.syncOrg(user, org.id); + } catch {} + const sub = + await this._subscriptionService.getSubscriptionByOrganizationId(org.id); + return { cancel_at: sub?.cancelAt || null }; + } + return this._stripeService.setToCancel(org.id); } @Post('/prorate') - prorate( + async prorate( @GetOrgFromRequest() org: Organization, + @GetUserFromRequest() user: User, @Body() body: BillingSubscribeDto ) { + if (this._dosBilling.enabled()) { + return { price: body.billing === 'PRO' ? 19 : 9 }; + } return this._stripeService.prorate(org.id, body); } + private async subscribeThroughDos(user: User, billing: string) { + const result = await this._dosBilling.checkout( + user, + billing, + process.env.FRONTEND_URL || 'https://post.crove.com' + ); + if (result.updated) { + return {}; + } + if (result.url) { + return { url: result.url }; + } + if (result.portal_url) { + return { portal: result.portal_url }; + } + return result; + } + @Get('/charges') async getCharges( @GetUserFromRequest() user: User, diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index 3ac67cb541..2eb0590320 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -12,6 +12,7 @@ import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integratio import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; +import { isCroveBillingGated } from '@gitroom/nestjs-libraries/dos-billing/crove-billing-gate'; import { ApiTags } from '@nestjs/swagger'; import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; @@ -200,7 +201,7 @@ export class NoAuthIntegrationsController { } if ( - process.env.STRIPE_PUBLISHABLE_KEY && + isCroveBillingGated() && org.isTrailing && (await this._integrationService.checkPreviousConnections( org.id, diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 26f3441e3b..8da0a52cd9 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -31,6 +31,8 @@ import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent'; import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum'; import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { DosSharedBillingService } from '@gitroom/nestjs-libraries/dos-billing/dos-shared-billing.service'; +import { isCroveBillingGated } from '@gitroom/nestjs-libraries/dos-billing/crove-billing-gate'; import { AuthorizationActions, Sections, @@ -45,7 +47,8 @@ export class UsersController { private _authService: AuthService, private _orgService: OrganizationService, private _userService: UsersService, - private _trackService: TrackService + private _trackService: TrackService, + private _dosBilling: DosSharedBillingService ) {} @Get('/chatbase-token') @@ -107,28 +110,45 @@ export class UsersController { } const impersonate = req.cookies.impersonate || req.headers.impersonate; + const billingGated = isCroveBillingGated(); + const sharedDosBilling = this._dosBilling.enabled(); + let totalChannels = !billingGated + ? 10000 + : // @ts-ignore + organization?.subscription?.totalChannels || pricing.FREE.channel; + let tier = + // @ts-ignore + organization?.subscription?.subscriptionTier || + (!billingGated ? 'ULTIMATE' : 'FREE'); + let dosPlan: 'free' | 'plus' | 'pro' | null = null; + + if (sharedDosBilling && !user.isSuperAdmin) { + try { + const mapped = await this._dosBilling.syncOrg(user, organization.id); + totalChannels = mapped.channels; + tier = mapped.tier; + dosPlan = mapped.dosPlan; + } catch { + // Fail closed to the last local subscription rather than unlock ULTIMATE. + } + } + // @ts-ignore return { ...user, orgId: organization.id, - totalChannels: !process.env.STRIPE_PUBLISHABLE_KEY - ? 10000 - : // @ts-ignore - organization?.subscription?.totalChannels || pricing.FREE.channel, - tier: - // @ts-ignore - organization?.subscription?.subscriptionTier || - (!process.env.STRIPE_PUBLISHABLE_KEY ? 'ULTIMATE' : 'FREE'), + totalChannels, + tier, + sharedDosBilling, + dosPlan, // @ts-ignore role: organization?.users[0]?.role, // @ts-ignore isLifetime: !!organization?.subscription?.isLifetime, admin: !!user.isSuperAdmin, impersonate: !!impersonate, - isTrailing: !process.env.STRIPE_PUBLISHABLE_KEY - ? false - : organization?.isTrailing, - allowTrial: organization?.allowTrial, + isTrailing: !billingGated ? false : organization?.isTrailing, + allowTrial: sharedDosBilling ? false : organization?.allowTrial, streakSince: organization?.streakSince || null, publicApi: // @ts-ignore diff --git a/apps/backend/src/services/auth/permissions/permissions.service.ts b/apps/backend/src/services/auth/permissions/permissions.service.ts index 75223fbbf1..c8bc18b16b 100644 --- a/apps/backend/src/services/auth/permissions/permissions.service.ts +++ b/apps/backend/src/services/auth/permissions/permissions.service.ts @@ -1,6 +1,7 @@ import { Ability, AbilityBuilder, AbilityClass } from '@casl/ability'; import { Injectable } from '@nestjs/common'; import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; +import { isCroveBillingGated } from '@gitroom/nestjs-libraries/dos-billing/crove-billing-gate'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; @@ -24,7 +25,7 @@ export class PermissionsService { const tier = subscription?.subscriptionTier || - (!process.env.STRIPE_PUBLISHABLE_KEY ? 'PRO' : 'FREE'); + (!isCroveBillingGated() ? 'PRO' : 'FREE'); const { channel, ...all } = pricing[tier]; return { @@ -49,7 +50,7 @@ export class PermissionsService { if ( requestedPermission.length === 0 || - !process.env.STRIPE_PUBLISHABLE_KEY + !isCroveBillingGated() ) { for (const [action, section] of requestedPermission) { can(action, section); diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index def87b1b5a..ca19a09750 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -66,7 +66,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { plontoKey={process.env.NEXT_PUBLIC_POLOTNO!} stripeClient={process.env.STRIPE_PUBLISHABLE_KEY!} isChatBase={!!process.env.CHATBASE_TOKEN} - billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY} + billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY || process.env.DOS_SHARED_BILLING === 'true'} discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!} frontEndUrl={process.env.FRONTEND_URL!} isGeneral={!!process.env.IS_GENERAL} diff --git a/apps/frontend/src/app/(extension)/layout.tsx b/apps/frontend/src/app/(extension)/layout.tsx index 0b2aa0ae8e..0bd652349f 100644 --- a/apps/frontend/src/app/(extension)/layout.tsx +++ b/apps/frontend/src/app/(extension)/layout.tsx @@ -36,7 +36,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { environment={process.env.NODE_ENV!} backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!} plontoKey={process.env.NEXT_PUBLIC_POLOTNO!} - billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY} + billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY || process.env.DOS_SHARED_BILLING === 'true'} discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!} frontEndUrl={process.env.FRONTEND_URL!} isGeneral={!!process.env.IS_GENERAL} diff --git a/apps/frontend/src/app/(provider)/layout.tsx b/apps/frontend/src/app/(provider)/layout.tsx index f0999d4844..036e508245 100644 --- a/apps/frontend/src/app/(provider)/layout.tsx +++ b/apps/frontend/src/app/(provider)/layout.tsx @@ -38,7 +38,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { environment={process.env.NODE_ENV!} backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!} plontoKey={process.env.NEXT_PUBLIC_POLOTNO!} - billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY} + billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY || process.env.DOS_SHARED_BILLING === 'true'} discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!} frontEndUrl={process.env.FRONTEND_URL!} isGeneral={!!process.env.IS_GENERAL} diff --git a/apps/frontend/src/components/billing/first.billing.component.tsx b/apps/frontend/src/components/billing/first.billing.component.tsx index ee8d8b1657..bd4d631cad 100644 --- a/apps/frontend/src/components/billing/first.billing.component.tsx +++ b/apps/frontend/src/components/billing/first.billing.component.tsx @@ -53,15 +53,20 @@ export const FirstBillingComponent = () => { const [stripe, setStripe] = useState>(null); const [tier, setTier] = useState('STANDARD'); const [period, setPeriod] = useState('MONTHLY'); + const [dosCheckoutLoading, setDosCheckoutLoading] = useState(false); const fetch = useFetch(); const modals = useModals(); const t = useT(); const [datafast_visitor_id] = useCookie('datafast_visitor_id', ''); const [datafast_session_id] = useCookie('datafast_session_id', ''); + const sharedDosBilling = !!user?.sharedDosBilling; useEffect(() => { + if (sharedDosBilling) { + return; + } setStripe(loadStripe(stripeClient)); - }, []); + }, [sharedDosBilling, stripeClient]); const loadCheckout = useCallback(async () => { return ( @@ -79,6 +84,30 @@ export const FirstBillingComponent = () => { ).json(); }, [tier, period]); + const startDosCheckout = useCallback(async () => { + setDosCheckoutLoading(true); + try { + const result = await ( + await fetch('/billing/subscribe', { + method: 'POST', + body: JSON.stringify({ + billing: tier, + period: 'MONTHLY', + }), + }) + ).json(); + if (result.url) { + window.location.href = result.url; + return; + } + if (result.portal) { + window.location.href = result.portal; + } + } finally { + setDosCheckoutLoading(false); + } + }, [fetch, tier]); + const showYouTube = () => { modals.openModal({ title: 'Grow Fast With Postiz (Play the video)', @@ -95,7 +124,7 @@ export const FirstBillingComponent = () => { }; const { data, isLoading } = useSWR( - `/billing-${tier}-${period}`, + sharedDosBilling ? null : `/billing-${tier}-${period}`, loadCheckout, { revalidateOnFocus: false, @@ -107,8 +136,13 @@ export const FirstBillingComponent = () => { ); const price = useMemo( - () => Object.entries(pricing).filter(([key, value]) => key !== 'FREE'), - [] + () => + Object.entries(pricing).filter(([key]) => + sharedDosBilling + ? key === 'STANDARD' || key === 'PRO' + : key !== 'FREE' + ), + [sharedDosBilling] ); const JoinOver = () => { @@ -212,6 +246,25 @@ export const FirstBillingComponent = () => { 'Another account with this email already has an active subscription. Please log off and sign in to that account to manage your subscription.' )} + ) : sharedDosBilling ? ( +
+
+ {t( + 'billing_dos_shared_plan', + 'Crove uses your DOS plan. Plus is $9/month (5 channels). Pro is $19/month (30 channels). One checkout covers DOS.AI and Crove.' + )} +
+ +
) : !isLoading && data && stripe ? ( { > {t('billing_monthly', 'Monthly')} + {!sharedDosBilling && (
{ {t('billing_20_percent_off', '20% Off')}
+ )}
@@ -274,16 +329,22 @@ export const FirstBillingComponent = () => { )} >
- {capitalize(key)} + {sharedDosBilling && key === 'STANDARD' + ? 'Plus' + : sharedDosBilling && key === 'PRO' + ? 'Pro' + : capitalize(key)}
$ - { - value[ - period === 'MONTHLY' ? 'month_price' : 'year_price' - ] - } + {sharedDosBilling + ? key === 'PRO' + ? 19 + : 9 + : value[ + period === 'MONTHLY' ? 'month_price' : 'year_price' + ]} {' '} {period === 'MONTHLY' ? t('billing_per_month', '/ month') diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 523e442b85..d87420e463 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -217,6 +217,7 @@ export const MainBillingComponent: FC<{ const fetch = useFetch(); const toast = useToaster(); const user = useUser(); + const sharedDosBilling = !!user?.sharedDosBilling; const dub = useDubClickId(); const modal = useModals(); const router = useRouter(); @@ -261,14 +262,16 @@ export const MainBillingComponent: FC<{ if (!subscription) { return 'FREE'; } - if (period === 'YEARLY' && monthlyOrYearly === 'off') { - return ''; - } - if (period === 'MONTHLY' && monthlyOrYearly === 'on') { - return ''; + if (!sharedDosBilling) { + if (period === 'YEARLY' && monthlyOrYearly === 'off') { + return ''; + } + if (period === 'MONTHLY' && monthlyOrYearly === 'on') { + return ''; + } } return subscription?.subscriptionTier; - }, [subscription, initialChannels, monthlyOrYearly, period]); + }, [subscription, initialChannels, monthlyOrYearly, period, sharedDosBilling]); const moveToCheckout = useCallback( (billing: 'STANDARD' | 'PRO' | 'FREE', reactivate = false) => async () => { @@ -314,25 +317,27 @@ export const MainBillingComponent: FC<{ 'Cancel Subscription' )) ) { - const checkDiscount = await ( - await fetch('/billing/check-discount') - ).json(); - if (checkDiscount.offerCoupon) { - const info = await new Promise((res) => { - modal.openModal({ - title: 'Before you cancel', - withCloseButton: true, - classNames: { - modal: 'bg-transparent text-textColor', - }, - children: , + if (!sharedDosBilling) { + const checkDiscount = await ( + await fetch('/billing/check-discount') + ).json(); + if (checkDiscount.offerCoupon) { + const info = await new Promise((res) => { + modal.openModal({ + title: 'Before you cancel', + withCloseButton: true, + classNames: { + modal: 'bg-transparent text-textColor', + }, + children: , + }); }); - }); - modal.closeAll(); + modal.closeAll(); - if (info) { - return; + if (info) { + return; + } } } @@ -453,6 +458,7 @@ export const MainBillingComponent: FC<{
{t('plans', 'Plans')}
+ {!sharedDosBilling && (
{t('monthly', 'MONTHLY')}
@@ -460,27 +466,43 @@ export const MainBillingComponent: FC<{
{t('yearly', 'YEARLY')}
+ )}
{finishTrial && setFinishTrial(false)} />}
{Object.entries(pricing) - .filter((f) => !isGeneral || f[0] !== 'FREE') + .filter((f) => { + if (sharedDosBilling) { + return ['FREE', 'STANDARD', 'PRO'].includes(f[0]); + } + return !isGeneral || f[0] !== 'FREE'; + }) .map(([name, values]) => (
-
{name}
+
+ {sharedDosBilling && name === 'STANDARD' ? 'Plus' : name} +
$ - {monthlyOrYearly === 'on' + {sharedDosBilling + ? name === 'PRO' + ? 19 + : name === 'STANDARD' + ? 9 + : 0 + : monthlyOrYearly === 'on' ? values.year_price : values.month_price}
- {monthlyOrYearly === 'on' ? '/year' : '/month'} + {sharedDosBilling || monthlyOrYearly !== 'on' + ? '/month' + : '/year'}
diff --git a/apps/frontend/src/components/layout/user.context.tsx b/apps/frontend/src/components/layout/user.context.tsx index c6490c5f06..d3f0588eda 100644 --- a/apps/frontend/src/components/layout/user.context.tsx +++ b/apps/frontend/src/components/layout/user.context.tsx @@ -19,6 +19,8 @@ export const UserContext = createContext< allowTrial: boolean; isTrailing: boolean; streakSince: string | null; + sharedDosBilling?: boolean; + dosPlan?: 'free' | 'plus' | 'pro' | null; }) >(undefined); export const ContextWrapper: FC<{ diff --git a/libraries/nestjs-libraries/jest.dos-billing.config.cjs b/libraries/nestjs-libraries/jest.dos-billing.config.cjs new file mode 100644 index 0000000000..13a3a49ca2 --- /dev/null +++ b/libraries/nestjs-libraries/jest.dos-billing.config.cjs @@ -0,0 +1,9 @@ +/** Isolated unit tests for DOS shared billing mapping. */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src/dos-billing'], + moduleNameMapper: { + '^@gitroom/nestjs-libraries/(.*)$': '/src/$1', + }, +}; diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index a129641bf8..6dcbef8788 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -18,6 +18,7 @@ import { timer } from '@gitroom/helpers/utils/timer'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto'; +import { isCroveBillingGated } from '@gitroom/nestjs-libraries/dos-billing/crove-billing-gate'; import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto'; import { difference, uniq } from 'lodash'; @@ -259,7 +260,7 @@ export class IntegrationService { await this._integrationRepository.getIntegrationsList(org) ).filter((f) => !f.disabled); if ( - !!process.env.STRIPE_PUBLISHABLE_KEY && + isCroveBillingGated() && integrations.length >= totalChannels ) { throw new Error('You have reached the maximum number of channels'); 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 1114051b9b..e408616d2d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { isCroveBillingGated } from '@gitroom/nestjs-libraries/dos-billing/crove-billing-gate'; @Injectable() export class OrganizationRepository { @@ -247,7 +248,7 @@ export class OrganizationRepository { }); if ( - process.env.STRIPE_PUBLISHABLE_KEY && + isCroveBillingGated() && checkForSubscription?.subscription?.subscriptionTier === SubscriptionTier.STANDARD ) { 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 55eb632b72..36b2e3686a 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts @@ -1,4 +1,5 @@ import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto'; +import { isCroveBillingGated } from '@gitroom/nestjs-libraries/dos-billing/crove-billing-gate'; import { HttpException, Injectable } from '@nestjs/common'; import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; @@ -102,7 +103,7 @@ export class OrganizationService { const tier = // @ts-ignore org?.subscription?.subscriptionTier || - (!process.env.STRIPE_PUBLISHABLE_KEY ? 'ULTIMATE' : 'FREE'); + (!isCroveBillingGated() ? 'ULTIMATE' : 'FREE'); if (!pricing[tier].team_members) { throw new HttpException( 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 afaa9b09a4..257c508cbb 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -283,4 +283,40 @@ export class SubscriptionRepository { }, }); } + + syncFromDosPlan( + organizationId: string, + billing: 'STANDARD' | 'PRO', + totalChannels: number, + identifier: string, + cancelAt: Date | null + ) { + return this._subscription.model.subscription.upsert({ + where: { organizationId }, + update: { + subscriptionTier: billing, + totalChannels, + identifier, + period: 'MONTHLY', + cancelAt, + deletedAt: null, + isLifetime: false, + }, + create: { + organizationId, + subscriptionTier: billing, + totalChannels, + identifier, + period: 'MONTHLY', + cancelAt, + isLifetime: false, + }, + }); + } + + clearDosSyncedSubscription(organizationId: string) { + return this._subscription.model.subscription.deleteMany({ + where: { organizationId }, + }); + } } 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 f607e6d134..3b2503821e 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -261,4 +261,26 @@ export class SubscriptionService { orgId ); } + + syncFromDosPlan( + organizationId: string, + billing: 'STANDARD' | 'PRO', + totalChannels: number, + identifier: string, + cancelAt: Date | null + ) { + return this._subscriptionRepository.syncFromDosPlan( + organizationId, + billing, + totalChannels, + identifier, + cancelAt + ); + } + + clearDosSyncedSubscription(organizationId: string) { + return this._subscriptionRepository.clearDosSyncedSubscription( + organizationId + ); + } } diff --git a/libraries/nestjs-libraries/src/dos-billing/crove-billing-gate.ts b/libraries/nestjs-libraries/src/dos-billing/crove-billing-gate.ts new file mode 100644 index 0000000000..f4d0b7e3f6 --- /dev/null +++ b/libraries/nestjs-libraries/src/dos-billing/crove-billing-gate.ts @@ -0,0 +1,13 @@ +export function isDosSharedBillingEnabled(): boolean { + return process.env.DOS_SHARED_BILLING === 'true'; +} + +export function isCroveBillingGated(): boolean { + return ( + !!process.env.STRIPE_PUBLISHABLE_KEY || isDosSharedBillingEnabled() + ); +} + +export function croveUiBillingEnabled(): boolean { + return isCroveBillingGated(); +} diff --git a/libraries/nestjs-libraries/src/dos-billing/dos-me-billing.client.ts b/libraries/nestjs-libraries/src/dos-billing/dos-me-billing.client.ts new file mode 100644 index 0000000000..537b5d5fe3 --- /dev/null +++ b/libraries/nestjs-libraries/src/dos-billing/dos-me-billing.client.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { DosPlan } from './dos-plan.map'; + +export type DosEntitlement = { + user_id: string; + plan: DosPlan; + active_subscription_source: 'none' | 'stripe' | 'apple' | 'google'; + active_subscription_id: string | null; + current_period_start: string | null; + current_period_end: string | null; +}; + +@Injectable() +export class DosMeBillingClient { + private readonly logger = new Logger(DosMeBillingClient.name); + + private apiUrl() { + return (process.env.DOS_ME_API_URL || 'https://api.dos.me').replace( + /\/+$/, + '' + ); + } + + private apiKey() { + const key = process.env.DOS_ME_INTERNAL_API_KEY || ''; + if (key.length < 32) { + throw new Error('DOS_ME_INTERNAL_API_KEY is not configured'); + } + return key; + } + + async getEntitlement(userId: string): Promise { + return this.request(`/internal/users/${userId}/plan`, { + method: 'GET', + }); + } + + async checkout(input: { + userId: string; + plan: Exclude; + successUrl: string; + cancelUrl: string; + }) { + return this.request<{ + url?: string; + plan?: string; + already_subscribed?: boolean; + portal_url?: string; + updated?: boolean; + }>('/internal/billing/checkout', { + method: 'POST', + body: JSON.stringify({ + userId: input.userId, + plan: input.plan, + successUrl: input.successUrl, + cancelUrl: input.cancelUrl, + }), + }); + } + + async portal(userId: string, returnUrl: string) { + return this.request<{ url: string }>('/internal/billing/portal', { + method: 'POST', + body: JSON.stringify({ userId, returnUrl }), + }); + } + + async cancel(userId: string) { + return this.request<{ cancel_at_period_end: boolean; plan: string }>( + '/internal/billing/cancel', + { + method: 'POST', + body: JSON.stringify({ userId }), + } + ); + } + + private async request(path: string, init: RequestInit): Promise { + const url = `${this.apiUrl()}${path}`; + const response = await fetch(url, { + ...init, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-API-Key': this.apiKey(), + ...(init.headers || {}), + }, + }); + const text = await response.text(); + let body: any = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { error: text }; + } + if (!response.ok) { + this.logger.warn( + `DOS-Me ${init.method} ${path} failed: ${response.status} ${text}` + ); + const err = new Error( + body?.error?.message || body?.message || `DOS-Me ${response.status}` + ) as Error & { status?: number; body?: unknown }; + err.status = response.status; + err.body = body; + throw err; + } + return body as T; + } +} diff --git a/libraries/nestjs-libraries/src/dos-billing/dos-plan.map.spec.ts b/libraries/nestjs-libraries/src/dos-billing/dos-plan.map.spec.ts new file mode 100644 index 0000000000..5ade4805dd --- /dev/null +++ b/libraries/nestjs-libraries/src/dos-billing/dos-plan.map.spec.ts @@ -0,0 +1,49 @@ +import { mapDosPlanToCrove, crovePackToDosPlan, isDosUserId } from './dos-plan.map'; + +describe('mapDosPlanToCrove', () => { + it('maps pro to Crove PRO with 30 channels', () => { + expect(mapDosPlanToCrove('pro')).toMatchObject({ + dosPlan: 'pro', + tier: 'PRO', + channels: 30, + monthPrice: 19, + }); + }); + + it('maps plus to Crove STANDARD with 5 channels', () => { + expect(mapDosPlanToCrove('plus')).toMatchObject({ + dosPlan: 'plus', + tier: 'STANDARD', + channels: 5, + monthPrice: 9, + }); + }); + + it('maps missing/free to FREE with 0 channels', () => { + expect(mapDosPlanToCrove(undefined)).toMatchObject({ + dosPlan: 'free', + tier: 'FREE', + channels: 0, + }); + }); +}); + +describe('crovePackToDosPlan', () => { + it('maps STANDARD purchase buttons to DOS plus', () => { + expect(crovePackToDosPlan('STANDARD')).toBe('plus'); + }); + + it('maps PRO purchase buttons to DOS pro', () => { + expect(crovePackToDosPlan('PRO')).toBe('pro'); + }); +}); + +describe('isDosUserId', () => { + it('accepts the DOS OIDC sub', () => { + expect(isDosUserId('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + + it('rejects a Postiz local id', () => { + expect(isDosUserId('clxyz')).toBe(false); + }); +}); diff --git a/libraries/nestjs-libraries/src/dos-billing/dos-plan.map.ts b/libraries/nestjs-libraries/src/dos-billing/dos-plan.map.ts new file mode 100644 index 0000000000..2cff3c21c6 --- /dev/null +++ b/libraries/nestjs-libraries/src/dos-billing/dos-plan.map.ts @@ -0,0 +1,60 @@ +import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; + +export type DosPlan = 'free' | 'plus' | 'pro'; +export type CroveMappedTier = 'FREE' | 'STANDARD' | 'PRO'; + +export type CrovePlanMapping = { + dosPlan: DosPlan; + tier: CroveMappedTier; + channels: number; + monthPrice: number; +}; + +const DOS_MONTH_PRICE = { plus: 9, pro: 19 } as const; + +export function mapDosPlanToCrove(plan: string | null | undefined): CrovePlanMapping { + if (plan === 'pro') { + return { + dosPlan: 'pro', + tier: 'PRO', + channels: pricing.PRO.channel || 30, + monthPrice: DOS_MONTH_PRICE.pro, + }; + } + if (plan === 'plus') { + return { + dosPlan: 'plus', + tier: 'STANDARD', + channels: pricing.STANDARD.channel || 5, + monthPrice: DOS_MONTH_PRICE.plus, + }; + } + return { + dosPlan: 'free', + tier: 'FREE', + channels: pricing.FREE.channel || 0, + monthPrice: 0, + }; +} + +export function crovePackToDosPlan( + pack: string, +): Exclude | 'free' { + const upper = pack.toUpperCase(); + if (upper === 'PRO' || upper === 'ULTIMATE') { + return 'pro'; + } + if (upper === 'FREE') { + return 'free'; + } + return 'plus'; +} + +export function isDosUserId(value: string | undefined | null): value is string { + return ( + typeof value === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value + ) + ); +} diff --git a/libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts b/libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts new file mode 100644 index 0000000000..bf68642233 --- /dev/null +++ b/libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts @@ -0,0 +1,122 @@ +import { HttpException, Injectable, Logger } from '@nestjs/common'; +import { Provider, User } from '@prisma/client'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { isDosSharedBillingEnabled } from './crove-billing-gate'; +import { DosMeBillingClient } from './dos-me-billing.client'; +import { + CrovePlanMapping, + crovePackToDosPlan, + isDosUserId, + mapDosPlanToCrove, +} from './dos-plan.map'; + +@Injectable() +export class DosSharedBillingService { + private readonly logger = new Logger(DosSharedBillingService.name); + + constructor( + private readonly client: DosMeBillingClient, + private readonly subscriptions: SubscriptionService + ) {} + + enabled() { + return isDosSharedBillingEnabled(); + } + + dosUserId(user: User): string | null { + if (user.providerName !== Provider.GENERIC) { + return null; + } + return isDosUserId(user.providerId) ? user.providerId : null; + } + + async syncOrg( + user: User, + organizationId: string + ): Promise { + const dosUserId = this.dosUserId(user); + if (!dosUserId) { + this.logger.warn( + `DOS shared billing is on but user ${user.id} has no DOS UUID providerId` + ); + return mapDosPlanToCrove('free'); + } + + const entitlement = await this.client.getEntitlement(dosUserId); + const mapped = mapDosPlanToCrove(entitlement.plan); + const cancelAt = entitlement.current_period_end + ? new Date(entitlement.current_period_end) + : null; + + if (mapped.tier === 'FREE') { + await this.subscriptions.clearDosSyncedSubscription(organizationId); + } else { + await this.subscriptions.syncFromDosPlan( + organizationId, + mapped.tier, + mapped.channels, + entitlement.active_subscription_id || `dos-${dosUserId}`, + cancelAt + ); + } + return mapped; + } + + async checkout( + user: User, + pack: string, + frontendUrl: string + ) { + const dosUserId = this.requireDosUserId(user); + const plan = crovePackToDosPlan(pack); + try { + if (plan === 'free') { + return this.client.cancel(dosUserId); + } + const billingUrl = `${frontendUrl.replace(/\/+$/, '')}/billing`; + return await this.client.checkout({ + userId: dosUserId, + plan, + successUrl: `${billingUrl}?success=true&plan=${plan}`, + cancelUrl: `${billingUrl}?canceled=true`, + }); + } catch (err) { + this.rethrowDosMe(err); + } + } + + async portal(user: User, frontendUrl: string) { + try { + const dosUserId = this.requireDosUserId(user); + const billingUrl = `${frontendUrl.replace(/\/+$/, '')}/billing`; + return await this.client.portal(dosUserId, billingUrl); + } catch (err) { + this.rethrowDosMe(err); + } + } + + async cancel(user: User) { + try { + return await this.client.cancel(this.requireDosUserId(user)); + } catch (err) { + this.rethrowDosMe(err); + } + } + + private rethrowDosMe(err: unknown): never { + const status = (err as { status?: number })?.status; + const message = err instanceof Error ? err.message : 'DOS billing failed'; + throw new HttpException(message, status && status >= 400 ? status : 502); + } + + private requireDosUserId(user: User): string { + const dosUserId = this.dosUserId(user); + if (!dosUserId) { + throw new HttpException( + 'Crove account is not linked to a DOS ID', + 400 + ); + } + return dosUserId; + } +} From 940287f68d185e9ab957b4db7f8062c69b7291f2 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:33:34 +0700 Subject: [PATCH 2/2] fix(billing): narrow dos checkout result union with in-guards --- apps/backend/src/api/routes/billing.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index 94c0088e2b..595cd31a18 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -236,13 +236,13 @@ export class BillingController { billing, process.env.FRONTEND_URL || 'https://post.crove.com' ); - if (result.updated) { + if ('updated' in result && result.updated) { return {}; } - if (result.url) { + if ('url' in result && result.url) { return { url: result.url }; } - if (result.portal_url) { + if ('portal_url' in result && result.portal_url) { return { portal: result.portal_url }; } return result;