diff --git a/.env.example b/.env.example index 5a036a4d42..0b5c3887b6 100644 --- a/.env.example +++ b/.env.example @@ -369,6 +369,25 @@ MEWE_HOST="https://mewe.com" MEWE_APP_ID="sample_mewe_app_id" MEWE_API_KEY="sample_mewe_api_key" +# 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 +NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME="Authentik" +NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL="https://raw.githubusercontent.com/walkxcode/dashboard-icons/master/png/authentik.png" +POSTIZ_GENERIC_OAUTH="false" +POSTIZ_OAUTH_URL="https://auth.example.com" +POSTIZ_OAUTH_AUTH_URL="https://auth.example.com/application/o/authorize" +POSTIZ_OAUTH_TOKEN_URL="https://auth.example.com/application/o/token" +POSTIZ_OAUTH_USERINFO_URL="https://authentik.example.com/application/o/userinfo" +POSTIZ_OAUTH_CLIENT_ID="" +POSTIZ_OAUTH_CLIENT_SECRET="" +# POSTIZ_OAUTH_SCOPE="openid profile email" # default values # --- Farcaster (Neynar API) --- NEYNAR_CLIENT_ID="sample_neynar_client_id" NEYNAR_SECRET_KEY="sample_neynar_secret_key" diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 6a6f25f9c2..0e26cc8129 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -51,6 +51,8 @@ import { AppleProvider } from '@gitroom/backend/services/auth/providers/apple.pr 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'; import { StripeController } from '@gitroom/backend/api/routes/stripe.controller'; const authenticatedController = [ @@ -112,6 +114,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 49fe1a3c92..595cd31a18 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -18,6 +18,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'; import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; import { BillingSyncDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.sync.dto'; @@ -28,6 +29,7 @@ export class BillingController { private _subscriptionService: SubscriptionService, private _notificationService: NotificationService, private _usersService: UsersService, + private _dosBilling: DosSharedBillingService, private _paymentService: PaymentService ) {} @@ -98,6 +100,10 @@ export class BillingController { return { blocked: true }; } + if (this._dosBilling.enabled()) { + return this.subscribeThroughDos(user, body.billing); + } + const uniqueId = req?.cookies?.track; return (await this.provider(org)).embedded( uniqueId, @@ -119,6 +125,10 @@ export class BillingController { return { blocked: true }; } + if (this._dosBilling.enabled()) { + return this.subscribeThroughDos(user, body.billing); + } + const uniqueId = req?.cookies?.track; return (await this.provider(org)).subscribe( uniqueId, @@ -151,7 +161,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 { url } = await (await this.provider(org)).portalLink(org.id); return { portal: url, @@ -159,7 +179,16 @@ 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); + } return this._paymentService.getSubscription(org.id); } @@ -176,17 +205,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 (await this.provider(org)).setToCancel(org.id); } @Post('/prorate') async prorate( @GetOrgFromRequest() org: Organization, + @GetUserFromRequest() user: User, @Body() body: BillingSubscribeDto ) { + if (this._dosBilling.enabled()) { + return { price: body.billing === 'PRO' ? 19 : 9 }; + } return (await this.provider(org)).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 ('updated' in result && result.updated) { + return {}; + } + if ('url' in result && result.url) { + return { url: result.url }; + } + if ('portal_url' in result && 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 f5260b9aae..7d8a6a5172 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'; @@ -194,7 +195,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 08e839260a..3ad250b294 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -32,6 +32,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, @@ -46,7 +48,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') @@ -108,28 +111,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 9495eca762..b0e9ed280e 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -67,7 +67,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 c4d070af2f..998461d46d 100644 --- a/apps/frontend/src/app/(extension)/layout.tsx +++ b/apps/frontend/src/app/(extension)/layout.tsx @@ -37,7 +37,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 fc1737d723..888bdcc3cd 100644 --- a/apps/frontend/src/app/(provider)/layout.tsx +++ b/apps/frontend/src/app/(provider)/layout.tsx @@ -39,7 +39,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 ff75f66dba..cd51e17381 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -221,6 +221,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(); @@ -265,14 +266,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 () => { @@ -318,25 +321,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; + } } } @@ -481,6 +486,7 @@ export const MainBillingComponent: FC<{
{t('plans', 'Plans')}
+ {!sharedDosBilling && (
{t('monthly', 'MONTHLY')}
@@ -488,27 +494,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 884ce9578e..ff23bc540a 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -21,6 +21,7 @@ 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'; @@ -369,7 +370,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 32e1ccf682..30c528a650 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 { @@ -322,7 +323,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 d45e51b2ea..eac2e9df1c 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'; @@ -111,7 +112,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 1071b7fc50..38e35e24af 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -296,4 +296,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 aaf2ed471d..db6155d864 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -349,4 +349,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; + } +}