diff --git a/services/platform-api/src/integrations/stripe.client.ts b/services/platform-api/src/integrations/stripe.client.ts index 3a2043b..462cb71 100644 --- a/services/platform-api/src/integrations/stripe.client.ts +++ b/services/platform-api/src/integrations/stripe.client.ts @@ -52,16 +52,59 @@ export class StripeClient { return c.id } + // Create (or reuse) a recurring Stripe Price for a catalog row. We pass + // product_data so Stripe creates the backing Product in the same call; the + // resulting price id is persisted on the Price doc (stripePriceIds[currency]) + // so we only hit Stripe once per (plan, cycle, currency). + async createPrice(input: { + productName: string + currency: string // ISO, lower-cased by Stripe + unitAmountMinor: number + interval: 'month' | 'year' + intervalCount: number + metadata?: Record + }): Promise { + const price = await this.stripe.prices.create({ + currency: input.currency.toLowerCase(), + unit_amount: input.unitAmountMinor, + recurring: { interval: input.interval, interval_count: input.intervalCount }, + product_data: { name: input.productName }, + metadata: input.metadata, + }) + return price.id + } + async createSubscription(input: { customerId: string priceId: string quantity: number - }): Promise<{ id: string; currentPeriodEnd?: number }> { + }): Promise<{ id: string; currentPeriodEnd?: number; status: string }> { + // send_invoice (not charge_automatically) so a test subscription produces + // a real open invoice without a saved payment method — that's what the + // billing pages and the webhook flow consume in dev/test. const s = await this.stripe.subscriptions.create({ customer: input.customerId, items: [{ price: input.priceId, quantity: input.quantity }], + collection_method: 'send_invoice', + days_until_due: 14, }) - return { id: s.id, currentPeriodEnd: (s as { current_period_end?: number }).current_period_end } + return { + id: s.id, + currentPeriodEnd: (s as { current_period_end?: number }).current_period_end, + status: s.status, + } + } + + // Suspend billing without losing the subscription — collection is voided + // while paused, then resumed on unpause. Mirrors tenant suspend/resume. + async pauseSubscription(subscriptionId: string): Promise { + await this.stripe.subscriptions.update(subscriptionId, { + pause_collection: { behavior: 'void' }, + }) + } + + async resumeSubscription(subscriptionId: string): Promise { + await this.stripe.subscriptions.update(subscriptionId, { pause_collection: null }) } async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise { diff --git a/services/platform-api/src/prices/prices.service.ts b/services/platform-api/src/prices/prices.service.ts index ee1eb11..e28e6ab 100644 --- a/services/platform-api/src/prices/prices.service.ts +++ b/services/platform-api/src/prices/prices.service.ts @@ -30,6 +30,19 @@ export class PricesService { return price.amounts[currency] } + // Persist the Stripe Price id we created for (this row, currency) so the + // next tenant on the same plan/cycle/currency reuses it instead of creating + // a duplicate Stripe Price. Keyed write — never clobbers other currencies. + async setStripePriceId( + priceId: PriceDocument['_id'], + currency: PriceCurrency, + stripePriceId: string, + ): Promise { + await this.priceModel + .updateOne({ _id: priceId }, { $set: { [`stripePriceIds.${currency}`]: stripePriceId } }) + .exec() + } + async create(dto: CreatePriceDto): Promise { try { return await this.priceModel.create({ ...dto, active: dto.active ?? true }) diff --git a/services/platform-api/src/tenants/tenants.service.ts b/services/platform-api/src/tenants/tenants.service.ts index ad3ac80..9f04e99 100644 --- a/services/platform-api/src/tenants/tenants.service.ts +++ b/services/platform-api/src/tenants/tenants.service.ts @@ -8,7 +8,9 @@ import { import { InjectModel } from '@nestjs/mongoose' import { Model, Types } from 'mongoose' import { AuditService, type AuditActor } from '../audit/audit.service.js' +import { StripeClient } from '../integrations/stripe.client.js' import { PricesService } from '../prices/prices.service.js' +import type { PriceCurrency, PriceCycle, PriceDocument } from '../schemas/price.schema.js' import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import { User, UserDocument } from '../schemas/user.schema.js' @@ -28,6 +30,7 @@ export class TenantsService { private readonly provisioning: ProvisioningService, private readonly audit: AuditService, private readonly prices: PricesService, + private readonly stripe: StripeClient, ) {} async listUsersForTenant(slug: string): Promise { @@ -65,7 +68,7 @@ export class TenantsService { const currency = dto.currency ?? 'DKK' const price = await this.prices.findActive(plan, cycle) const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0 - await this.subModel.create({ + const sub = await this.subModel.create({ tenantId: tenant._id, plan, cycle, @@ -75,6 +78,11 @@ export class TenantsService { seats: tenant.seats ?? 0, status: 'active', }) + + // Mirror the subscription into Stripe (best-effort, only when enabled). + // A Stripe failure must not fail tenant creation — the local sub still + // drives derived MRR; /tenants/:slug/reconcile can retry later. + await this.syncStripeOnCreate(tenant, sub, price, currency, perSeatAmount) } catch (err) { this.logger.warn( `Subscription auto-create failed for tenant ${tenant.slug}: ${ @@ -165,6 +173,8 @@ export class TenantsService { if (dto.seats !== undefined) { await this.subModel.updateOne({ tenantId: updated._id }, { $set: { seats: dto.seats } }).exec() + // Mirror the seat count onto the live Stripe subscription quantity. + await this.syncStripeQuantity(updated._id, dto.seats) } void this.audit.record( @@ -239,6 +249,9 @@ export class TenantsService { .findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true }) .exec() if (!result) throw new NotFoundException(`Tenant "${slug}" not found`) + // Cancel the live Stripe subscription (best-effort) so a deleted tenant + // stops accruing charges. + await this.syncStripeCancel(result._id) void this.audit.record( { action: 'tenant.deleted', @@ -260,6 +273,8 @@ export class TenantsService { .findOneAndUpdate({ slug }, { status }, { new: true }) .exec() if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) + // Pause/resume the live Stripe subscription to match (best-effort). + await this.syncStripeStatus(tenant._id, status) void this.audit.record( { action: status === 'suspended' ? 'tenant.suspended' : 'tenant.resumed', @@ -272,4 +287,135 @@ export class TenantsService { ) return tenant } + + // ── Stripe lifecycle sync ────────────────────────────────────────────────── + // All helpers below are best-effort and gated on `stripe.enabled`. When Stripe + // is disabled (dev default) they are cheap no-ops, so the local Subscription + // doc remains the single source of truth for derived MRR. + + // monthly/quarterly/yearly → Stripe recurring interval + count. + private stripeInterval(cycle: PriceCycle): { interval: 'month' | 'year'; intervalCount: number } { + if (cycle === 'yearly') return { interval: 'year', intervalCount: 1 } + if (cycle === 'quarterly') return { interval: 'month', intervalCount: 3 } + return { interval: 'month', intervalCount: 1 } + } + + // Resolve (creating + persisting once) the Stripe Price id for a catalog row + // in a given currency. Returns null when the row has no amount in that + // currency (e.g. Enterprise / unpriced) — caller then skips the subscription. + private async resolveStripePriceId( + price: PriceDocument, + currency: PriceCurrency, + perSeatAmount: number, + ): Promise { + if (perSeatAmount <= 0) return null + const existing = price.stripePriceIds?.[currency] + if (existing) return existing + const { interval, intervalCount } = this.stripeInterval(price.cycle) + const stripePriceId = await this.stripe.createPrice({ + productName: `Dezky ${price.plan} (${price.cycle})`, + currency, + unitAmountMinor: perSeatAmount, + interval, + intervalCount, + metadata: { plan: price.plan, cycle: price.cycle, currency }, + }) + await this.prices.setStripePriceId(price._id, currency, stripePriceId) + return stripePriceId + } + + // On tenant create: open a Stripe customer and, when the plan is priced and + // has at least one seat, a matching subscription. Persists the Stripe ids + // back onto the local Subscription doc so the webhook can correlate events. + private async syncStripeOnCreate( + tenant: TenantDocument, + sub: SubscriptionDocument, + price: PriceDocument | null, + currency: PriceCurrency, + perSeatAmount: number, + ): Promise { + if (!this.stripe.enabled) return + try { + const customerId = await this.stripe.createCustomer({ + name: tenant.name, + metadata: { tenantId: String(tenant._id), slug: tenant.slug }, + }) + sub.stripeCustomerId = customerId + + const seats = sub.seats ?? 0 + const stripePriceId = price + ? await this.resolveStripePriceId(price, currency, perSeatAmount) + : null + if (stripePriceId && seats >= 1) { + const created = await this.stripe.createSubscription({ + customerId, + priceId: stripePriceId, + quantity: seats, + }) + sub.stripeSubscriptionId = created.id + if (created.currentPeriodEnd) sub.currentPeriodEnd = new Date(created.currentPeriodEnd * 1000) + } + await sub.save() + this.logger.log( + `Stripe billing provisioned for ${tenant.slug}: customer=${customerId}${ + sub.stripeSubscriptionId ? ` sub=${sub.stripeSubscriptionId}` : ' (no subscription — unpriced or 0 seats)' + }`, + ) + } catch (err) { + this.logger.warn( + `Stripe provisioning failed for tenant ${tenant.slug}: ${ + err instanceof Error ? err.message : String(err) + } — local subscription kept; reconcile to retry.`, + ) + } + } + + private async syncStripeQuantity(tenantId: Types.ObjectId, seats: number): Promise { + if (!this.stripe.enabled || seats < 1) return + const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec() + if (!sub?.stripeSubscriptionId) return + try { + await this.stripe.updateSubscriptionQuantity(sub.stripeSubscriptionId, seats) + } catch (err) { + this.logger.warn( + `Stripe quantity update failed for tenant ${tenantId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ) + } + } + + private async syncStripeStatus( + tenantId: Types.ObjectId, + status: 'active' | 'suspended', + ): Promise { + if (!this.stripe.enabled) return + const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec() + if (!sub?.stripeSubscriptionId) return + try { + if (status === 'suspended') await this.stripe.pauseSubscription(sub.stripeSubscriptionId) + else await this.stripe.resumeSubscription(sub.stripeSubscriptionId) + } catch (err) { + this.logger.warn( + `Stripe pause/resume failed for tenant ${tenantId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ) + } + } + + private async syncStripeCancel(tenantId: Types.ObjectId): Promise { + if (!this.stripe.enabled) return + const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec() + if (!sub?.stripeSubscriptionId) return + try { + await this.stripe.cancelSubscription(sub.stripeSubscriptionId) + } catch (err) { + this.logger.warn( + `Stripe cancel failed for tenant ${tenantId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ) + } + } }