feat(billing): Stripe-backed billing engine (dark-launched)

Add a lazy/guarded Stripe client (boots without keys), Invoice/Payout schemas, per-currency Price.stripePriceIds, and a BillingService deriving partner/platform summaries, invoices and a partner-cut payout ledger. Partner and operator billing controllers plus a signature-verified Stripe webhook (Fastify raw body). Frontend: partner and operator billing pages and the operator tenant billing/audit tabs on real data. Gated behind new_billing_engine and BILLING_STRIPE_ENABLED; live money paths stay off until keys are set.
This commit is contained in:
Ronni Baslund
2026-05-30 08:03:23 +02:00
parent 6370e392cc
commit 0e1d2fb0d1
23 changed files with 1064 additions and 143 deletions
+2 -1
View File
@@ -24,7 +24,8 @@
"jose": "^5.9.0",
"mongoose": "^8.7.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
"rxjs": "^7.8.0",
"stripe": "^17.5.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
+79
View File
@@ -44,6 +44,9 @@ importers:
rxjs:
specifier: ^7.8.0
version: 7.8.2
stripe:
specifier: ^17.5.0
version: 17.7.0
devDependencies:
'@nestjs/cli':
specifier: ^10.4.0
@@ -652,6 +655,10 @@ packages:
resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -1272,6 +1279,10 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obliterator@2.0.5:
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
@@ -1366,6 +1377,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@@ -1469,6 +1484,22 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
sift@17.1.3:
resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==}
@@ -1523,6 +1554,10 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
stripe@17.7.0:
resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==}
engines: {node: '>=12.*'}
strnum@2.3.0:
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
@@ -2596,6 +2631,11 @@ snapshots:
get-intrinsic: 1.3.0
set-function-length: 1.2.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
callsites@3.1.0: {}
caniuse-lite@1.0.30001793: {}
@@ -3202,6 +3242,8 @@ snapshots:
normalize-path@3.0.0: {}
object-inspect@1.13.4: {}
obliterator@2.0.5: {}
on-exit-leak-free@2.1.2: {}
@@ -3293,6 +3335,10 @@ snapshots:
punycode@2.3.1: {}
qs@6.15.2:
dependencies:
side-channel: 1.1.0
quick-format-unescaped@4.0.4: {}
readable-stream@3.6.2:
@@ -3382,6 +3428,34 @@ snapshots:
shebang-regex@3.0.0: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
sift@17.1.3: {}
signal-exit@3.0.7: {}
@@ -3433,6 +3507,11 @@ snapshots:
strip-bom@3.0.0: {}
stripe@17.7.0:
dependencies:
'@types/node': 20.19.41
qs: 6.15.2
strnum@2.3.0: {}
strtok3@10.3.5:
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from './audit/audit.module.js'
import { AuthModule } from './auth/auth.module.js'
import { BillingModule } from './billing/billing.module.js'
import { FlagsModule } from './flags/flags.module.js'
import { HealthModule } from './health/health.module.js'
import { IngestModule } from './ingest/ingest.module.js'
@@ -30,6 +31,7 @@ import { UsersModule } from './users/users.module.js'
SubscriptionsModule,
PricesModule,
FlagsModule,
BillingModule,
IngestModule,
SeedModule,
],
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Invoice, InvoiceSchema } from '../schemas/invoice.schema.js'
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { Payout, PayoutSchema } from '../schemas/payout.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { UsersModule } from '../users/users.module.js'
import { BillingService } from './billing.service.js'
import { OperatorBillingController } from './operator-billing.controller.js'
import { PartnerBillingController } from './partner-billing.controller.js'
import { StripeWebhookController } from './stripe-webhook.controller.js'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Invoice.name, schema: InvoiceSchema },
{ name: Payout.name, schema: PayoutSchema },
{ name: Subscription.name, schema: SubscriptionSchema },
{ name: Partner.name, schema: PartnerSchema },
{ name: Tenant.name, schema: TenantSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
UsersModule,
],
controllers: [PartnerBillingController, OperatorBillingController, StripeWebhookController],
providers: [BillingService],
})
export class BillingModule {}
@@ -0,0 +1,211 @@
import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type Stripe from 'stripe'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { StripeClient } from '../integrations/stripe.client.js'
import { Invoice, InvoiceDocument } from '../schemas/invoice.schema.js'
import { Payout, PayoutDocument } from '../schemas/payout.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { UsersService } from '../users/users.service.js'
type Currency = 'DKK' | 'EUR' | 'USD'
function toCurrency(c?: string): Currency {
const up = (c ?? 'DKK').toUpperCase()
return up === 'EUR' || up === 'USD' ? up : 'DKK'
}
// Billing reads run on DERIVED data (Subscription + Price + marginPct + the
// Invoice/Payout collections), so the billing pages show real numbers in dev
// even with no live Stripe. The webhook populates Invoices/Subscription status
// once Stripe is enabled.
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name)
constructor(
@InjectModel(Invoice.name) private readonly invoiceModel: Model<InvoiceDocument>,
@InjectModel(Payout.name) private readonly payoutModel: Model<PayoutDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly users: UsersService,
private readonly stripe: StripeClient,
private readonly audit: AuditService,
) {}
// ── Partner billing reads ────────────────────────────────────────────────
async partnerSummary(partnerId: Types.ObjectId): Promise<{
marginPct: number
mrr: Array<{ currency: Currency; monthlyMinor: number; partnerCutMinor: number; netMinor: number }>
customers: number
openInvoices: number
openAmountMinor: number
stripeLive: boolean
}> {
const [mrr, partner] = await Promise.all([
this.users.partnerMrr(partnerId),
this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
])
const marginPct = partner?.marginPct ?? 0
const mrrRows = mrr.totals.map((t) => {
const partnerCutMinor = Math.round((t.monthlyMinor * marginPct) / 100)
return {
currency: t.currency,
monthlyMinor: t.monthlyMinor,
partnerCutMinor,
netMinor: t.monthlyMinor - partnerCutMinor,
}
})
const openInv = await this.invoiceModel
.find({ partnerId, status: { $in: ['open', 'past_due'] } })
.exec()
const openAmountMinor = openInv.reduce((s, i) => s + (i.amountDue - i.amountPaid), 0)
return {
marginPct,
mrr: mrrRows,
customers: mrr.breakdown.length,
openInvoices: openInv.length,
openAmountMinor,
stripeLive: this.stripe.enabled,
}
}
async partnerInvoices(partnerId: Types.ObjectId): Promise<InvoiceDocument[]> {
return this.invoiceModel.find({ partnerId }).sort({ createdAt: -1 }).limit(100).exec()
}
async partnerPayouts(partnerId: Types.ObjectId): Promise<PayoutDocument[]> {
return this.payoutModel.find({ partnerId }).sort({ periodMonth: -1 }).exec()
}
// ── Operator (platform-wide) billing reads ───────────────────────────────
async platformSummary(): Promise<{
invoicedMinor: number
paidMinor: number
outstandingMinor: number
openInvoices: number
pastDueInvoices: number
stripeLive: boolean
}> {
const invoices = await this.invoiceModel.find().exec()
let invoicedMinor = 0
let paidMinor = 0
let outstandingMinor = 0
let openInvoices = 0
let pastDueInvoices = 0
for (const i of invoices) {
invoicedMinor += i.amountDue
paidMinor += i.amountPaid
if (i.status === 'open' || i.status === 'past_due') {
outstandingMinor += i.amountDue - i.amountPaid
openInvoices++
if (i.status === 'past_due') pastDueInvoices++
}
}
return { invoicedMinor, paidMinor, outstandingMinor, openInvoices, pastDueInvoices, stripeLive: this.stripe.enabled }
}
async platformInvoices(): Promise<InvoiceDocument[]> {
return this.invoiceModel.find().sort({ createdAt: -1 }).limit(200).exec()
}
async tenantInvoices(tenantId: Types.ObjectId): Promise<InvoiceDocument[]> {
return this.invoiceModel.find({ tenantId }).sort({ createdAt: -1 }).exec()
}
async tenantInvoicesBySlug(slug: string): Promise<InvoiceDocument[]> {
const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec()
if (!tenant) return []
return this.tenantInvoices(tenant._id)
}
// ── Stripe webhook ────────────────────────────────────────────────────────
// Idempotent upsert by stripeInvoiceId / subscription id. Only meaningful
// when Stripe is enabled; no-op acknowledgement otherwise.
async handleWebhookEvent(event: Stripe.Event, actor?: AuditActor): Promise<void> {
switch (event.type) {
case 'invoice.paid':
case 'invoice.payment_failed':
case 'invoice.finalized': {
await this.upsertInvoiceFromStripe(event.data.object as Stripe.Invoice, event.type)
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await this.applySubscriptionEvent(event.data.object as Stripe.Subscription, event.type)
break
}
default:
// Ignore unhandled event types.
return
}
void this.audit.record(
{
action: `billing.${event.type.replace(/\./g, '_')}`,
resourceType: 'subscription',
resourceId: event.id,
source: 'platform-api',
externalId: event.id,
},
actor,
)
}
private async upsertInvoiceFromStripe(inv: Stripe.Invoice, eventType: string): Promise<void> {
const customerId = typeof inv.customer === 'string' ? inv.customer : inv.customer?.id
if (!customerId) return
const sub = await this.subModel.findOne({ stripeCustomerId: customerId }).exec()
if (!sub) {
this.logger.warn(`Stripe invoice ${inv.id} for unknown customer ${customerId} — skipping`)
return
}
const tenant = await this.tenantModel.findById(sub.tenantId, { partnerId: 1 }).exec()
const status: InvoiceDocument['status'] =
eventType === 'invoice.payment_failed'
? 'past_due'
: inv.status === 'paid'
? 'paid'
: ((inv.status as InvoiceDocument['status']) ?? 'open')
await this.invoiceModel
.findOneAndUpdate(
{ stripeInvoiceId: inv.id },
{
$set: {
tenantId: sub.tenantId,
partnerId: tenant?.partnerId,
subscriptionId: sub._id,
number: inv.number ?? undefined,
currency: toCurrency(inv.currency),
amountDue: inv.amount_due ?? 0,
amountPaid: inv.amount_paid ?? 0,
status,
periodStart: inv.period_start ? new Date(inv.period_start * 1000) : undefined,
periodEnd: inv.period_end ? new Date(inv.period_end * 1000) : undefined,
hostedInvoiceUrl: inv.hosted_invoice_url ?? undefined,
pdfUrl: inv.invoice_pdf ?? undefined,
},
},
{ upsert: true, new: true },
)
.exec()
}
private async applySubscriptionEvent(sub: Stripe.Subscription, eventType: string): Promise<void> {
const set: Record<string, unknown> = {}
if (eventType === 'customer.subscription.deleted') {
set.status = 'canceled'
set.canceledAt = new Date()
} else {
set.status = sub.status
}
const cpe = (sub as { current_period_end?: number }).current_period_end
if (cpe) set.currentPeriodEnd = new Date(cpe * 1000)
await this.subModel.updateOne({ stripeSubscriptionId: sub.id }, { $set: set }).exec()
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import { BillingService } from './billing.service.js'
// Platform-wide billing reads. Operator-only.
@Controller('billing')
@UseGuards(JwtAuthGuard, OperatorGuard)
export class OperatorBillingController {
constructor(private readonly billing: BillingService) {}
@Get('summary')
summary() {
return this.billing.platformSummary()
}
@Get('invoices')
invoices() {
return this.billing.platformInvoices()
}
@Get('tenants/:slug/invoices')
tenantInvoices(@Param('slug') slug: string) {
return this.billing.tenantInvoicesBySlug(slug)
}
}
@@ -0,0 +1,37 @@
import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import { BillingService } from './billing.service.js'
// Partner-facing billing reads. Scoped to the caller's partnerId.
@Controller('me/partner/billing')
@UseGuards(JwtAuthGuard)
export class PartnerBillingController {
constructor(
private readonly billing: BillingService,
private readonly actor: ActorService,
) {}
@Get('summary')
async summary(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
return this.billing.partnerSummary(actor.partnerId)
}
@Get('invoices')
async invoices(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
return this.billing.partnerInvoices(actor.partnerId)
}
@Get('payouts')
async payouts(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
return this.billing.partnerPayouts(actor.partnerId)
}
}
@@ -0,0 +1,46 @@
import {
BadRequestException,
Controller,
Headers,
HttpCode,
Post,
Req,
type RawBodyRequest,
} from '@nestjs/common'
import { StripeClient } from '../integrations/stripe.client.js'
import { BillingService } from './billing.service.js'
// Stripe webhook receiver. NOT guarded (Stripe calls it) — authenticity comes
// from signature verification against the raw body. No-ops when Stripe is
// disabled so the route is safe to expose in dev.
@Controller('stripe')
export class StripeWebhookController {
constructor(
private readonly stripe: StripeClient,
private readonly billing: BillingService,
) {}
@Post('webhook')
@HttpCode(200)
async webhook(
// Only req.rawBody is needed; the signature comes via @Headers. Typed
// minimally to avoid a direct 'fastify' type import (transitive dep).
@Req() req: RawBodyRequest<Record<string, unknown>>,
@Headers('stripe-signature') signature: string,
) {
if (!this.stripe.enabled || !this.stripe.hasWebhookSecret) {
return { received: true, ignored: 'stripe disabled' }
}
if (!req.rawBody || !signature) {
throw new BadRequestException('Missing raw body or stripe-signature header')
}
let event
try {
event = this.stripe.constructWebhookEvent(req.rawBody, signature)
} catch {
throw new BadRequestException('Invalid Stripe signature')
}
await this.billing.handleWebhookEvent(event)
return { received: true }
}
}
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'
import { AuthentikClient } from './authentik.client.js'
import { OcisClient } from './ocis.client.js'
import { StalwartClient } from './stalwart.client.js'
import { StripeClient } from './stripe.client.js'
@Module({
providers: [AuthentikClient, StalwartClient, OcisClient],
exports: [AuthentikClient, StalwartClient, OcisClient],
providers: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
exports: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
})
export class IntegrationsModule {}
@@ -0,0 +1,87 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import Stripe from 'stripe'
// Thin wrapper around the Stripe SDK. Dark-launch friendly: the SDK is only
// constructed lazily on first use, so the app boots fine in dev with no Stripe
// keys. `enabled` gates the live money-moving paths (subscription create on
// tenant create, webhook processing). When disabled, billing reads run on
// DERIVED data (Subscription + Price + marginPct) instead of live Stripe.
@Injectable()
export class StripeClient {
private readonly logger = new Logger(StripeClient.name)
private readonly secretKey?: string
private readonly webhookSecret?: string
private _stripe?: Stripe
readonly enabled: boolean
constructor(config: ConfigService) {
this.secretKey = config.get<string>('STRIPE_SECRET_KEY')
this.webhookSecret = config.get<string>('STRIPE_WEBHOOK_SECRET')
this.enabled = config.get<string>('BILLING_STRIPE_ENABLED') === 'true' && !!this.secretKey
if (!this.enabled) {
this.logger.log(
'Stripe billing disabled (BILLING_STRIPE_ENABLED != true or no key) — billing uses derived data.',
)
}
}
get hasWebhookSecret(): boolean {
return !!this.webhookSecret
}
// Lazy SDK accessor — throws only if actually used while unconfigured.
private get stripe(): Stripe {
if (!this.secretKey) {
throw new Error('Stripe is not configured (STRIPE_SECRET_KEY missing)')
}
if (!this._stripe) this._stripe = new Stripe(this.secretKey)
return this._stripe
}
async createCustomer(input: {
name: string
email?: string
metadata?: Record<string, string>
}): Promise<string> {
const c = await this.stripe.customers.create({
name: input.name,
email: input.email,
metadata: input.metadata,
})
return c.id
}
async createSubscription(input: {
customerId: string
priceId: string
quantity: number
}): Promise<{ id: string; currentPeriodEnd?: number }> {
const s = await this.stripe.subscriptions.create({
customer: input.customerId,
items: [{ price: input.priceId, quantity: input.quantity }],
})
return { id: s.id, currentPeriodEnd: (s as { current_period_end?: number }).current_period_end }
}
async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise<void> {
const sub = await this.stripe.subscriptions.retrieve(subscriptionId)
const itemId = sub.items.data[0]?.id
if (!itemId) throw new Error(`Stripe subscription ${subscriptionId} has no line items`)
await this.stripe.subscriptions.update(subscriptionId, { items: [{ id: itemId, quantity }] })
}
async cancelSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.cancel(subscriptionId)
}
async listInvoices(customerId: string, limit = 50): Promise<Stripe.Invoice[]> {
const res = await this.stripe.invoices.list({ customer: customerId, limit })
return res.data
}
constructWebhookEvent(rawBody: Buffer | string, signature: string): Stripe.Event {
if (!this.webhookSecret) throw new Error('Stripe webhook secret not configured')
return this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret)
}
}
+3
View File
@@ -11,6 +11,9 @@ async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
// rawBody exposes req.rawBody (Buffer) — needed for Stripe webhook
// signature verification, which must hash the exact bytes Stripe sent.
{ rawBody: true },
)
app.enableCors({
@@ -0,0 +1,56 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type InvoiceDocument = HydratedDocument<Invoice>
// One customer invoice, synced from Stripe webhooks (invoice.paid /
// payment_failed). `partnerId` is denormalized so the partner billing list is
// one query. Amounts are in minor units. Empty in dev until Stripe is live.
@Schema({ collection: 'invoices', timestamps: true })
export class Invoice {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
partnerId?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: 'Subscription', sparse: true })
subscriptionId?: Types.ObjectId
// Dedup key for webhook idempotency.
@Prop({ unique: true, sparse: true, index: true })
stripeInvoiceId?: string
@Prop({ trim: true })
number?: string
@Prop({ enum: ['DKK', 'EUR', 'USD'], default: 'DKK' })
currency!: 'DKK' | 'EUR' | 'USD'
@Prop({ type: Number, default: 0 })
amountDue!: number
@Prop({ type: Number, default: 0 })
amountPaid!: number
@Prop({
enum: ['draft', 'open', 'paid', 'void', 'uncollectible', 'past_due'],
default: 'open',
index: true,
})
status!: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible' | 'past_due'
@Prop()
periodStart?: Date
@Prop()
periodEnd?: Date
@Prop()
hostedInvoiceUrl?: string
@Prop()
pdfUrl?: string
}
export const InvoiceSchema = SchemaFactory.createForClass(Invoice)
@@ -0,0 +1,38 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type PayoutDocument = HydratedDocument<Payout>
// Partner-cut payout ledger. v1 is a COMPUTED ledger (not Stripe Connect):
// Dezky bills customers, the partner earns marginPct; a job aggregates paid
// invoices per partner per month into these records. Disbursement is out-of-band
// in v1 — these rows are the ledger of what's owed/paid.
@Schema({ collection: 'payouts', timestamps: true })
export class Payout {
@Prop({ type: Types.ObjectId, ref: 'Partner', required: true, index: true })
partnerId!: Types.ObjectId
// YYYY-MM the payout covers.
@Prop({ required: true, index: true })
periodMonth!: string
@Prop({ enum: ['DKK', 'EUR', 'USD'], default: 'DKK' })
currency!: 'DKK' | 'EUR' | 'USD'
@Prop({ type: Number, default: 0 })
grossMrrMinor!: number
@Prop({ type: Number, default: 0 })
marginPct!: number
@Prop({ type: Number, default: 0 })
payoutMinor!: number
@Prop({ enum: ['pending', 'paid'], default: 'pending', index: true })
status!: 'pending' | 'paid'
@Prop()
paidAt?: Date
}
export const PayoutSchema = SchemaFactory.createForClass(Payout)
@@ -38,6 +38,22 @@ export class Price {
USD?: number
}
// Per-currency Stripe Price IDs, mapping this catalog row to Stripe. Seeded
// when Stripe billing is enabled; absent in dev (derived billing).
@Prop({
type: {
DKK: { type: String },
EUR: { type: String },
USD: { type: String },
},
default: () => ({}),
})
stripePriceIds?: {
DKK?: string
EUR?: string
USD?: string
}
// Soft-active flag. When the operator changes a row's amounts we mutate
// the row in place; deactivation is for cases like end-of-life'd plans
// where we want to preserve subs' priceId references without making the