diff --git a/backend/billing/domain/stripe/StripeBillingService.ts b/backend/billing/domain/stripe/StripeBillingService.ts index 8a8bda4b..c08240a9 100644 --- a/backend/billing/domain/stripe/StripeBillingService.ts +++ b/backend/billing/domain/stripe/StripeBillingService.ts @@ -64,6 +64,8 @@ export interface StripeCustomerInput { export interface StripeSubscriptionItemInput { readonly priceId: string; readonly quantity?: number; + /** Existing Stripe subscription item to update instead of adding a new item. */ + readonly subscriptionItemId?: string; } /** @@ -270,7 +272,11 @@ export class StripeBillingService { ): Promise { const params: StripeParams = { expand: ['latest_invoice.payment_intent'] }; if (input.items) params.items = input.items.map((item) => this.itemParams(item)); - if (input.prorationBehavior) params.proration_behavior = input.prorationBehavior; + if (input.prorationBehavior) { + params.proration_behavior = input.prorationBehavior; + } else if (input.items) { + params.proration_behavior = 'create_prorations'; + } if (input.coupon !== undefined) params.coupon = input.coupon; if (input.cancelAtPeriodEnd !== undefined) { params.cancel_at_period_end = input.cancelAtPeriodEnd; @@ -503,6 +509,7 @@ export class StripeBillingService { private itemParams(item: StripeSubscriptionItemInput): Record { const params: Record = { price: item.priceId }; + if (item.subscriptionItemId) params.id = item.subscriptionItemId; if (item.quantity !== undefined) params.quantity = item.quantity; return params; } diff --git a/backend/billing/tests/stripeBilling.test.ts b/backend/billing/tests/stripeBilling.test.ts index c568a9f6..a99c7b71 100644 --- a/backend/billing/tests/stripeBilling.test.ts +++ b/backend/billing/tests/stripeBilling.test.ts @@ -444,6 +444,42 @@ describe('StripeBillingService', () => { expect(calls[0].init.headers['Idempotency-Key']).toBe('subtrackr:subscription:update:sub_1'); }); + it.each([1, 5])('prorates a quantity change to %i seats on the existing item', async (quantity) => { + const invoice = { id: 'in_proration', total: 1200 }; + const { service, calls } = createService([json({ id: 'sub_1', latest_invoice: invoice })]); + + const updated = await service.updateSubscription('sub_1', { + items: [{ priceId: 'price_seat', subscriptionItemId: 'si_seat', quantity }], + }); + + expect(calls[0].params.get('items[0][id]')).toBe('si_seat'); + expect(calls[0].params.get('items[0][price]')).toBe('price_seat'); + expect(calls[0].params.get('items[0][quantity]')).toBe(String(quantity)); + expect(calls[0].params.get('proration_behavior')).toBe('create_prorations'); + expect(calls[0].params.getAll('expand[]')).toContain('latest_invoice.payment_intent'); + expect(updated.latest_invoice).toEqual(invoice); + }); + + it('preserves an explicit proration behavior for item changes', async () => { + const { service, calls } = createService([json({ id: 'sub_1' })]); + + await service.updateSubscription('sub_1', { + items: [{ priceId: 'price_seat', subscriptionItemId: 'si_seat', quantity: 0 }], + prorationBehavior: 'none', + }); + + expect(calls[0].params.get('proration_behavior')).toBe('none'); + expect(calls[0].params.get('items[0][quantity]')).toBe('0'); + }); + + it('does not set proration for metadata-only updates', async () => { + const { service, calls } = createService([json({ id: 'sub_1' })]); + + await service.updateSubscription('sub_1', { metadata: { source: 'admin' } }); + + expect(calls[0].params.has('proration_behavior')).toBe(false); + }); + it('ends a trial early with trial_end=now', async () => { const { service, calls } = createService([json({ id: 'sub_1' })]);