Skip to content

Commit a803964

Browse files
committed
fix: price difference calc
1 parent e2ca0fd commit a803964

2 files changed

Lines changed: 77 additions & 70 deletions

File tree

backend/src/billing/billing.service.spec.ts

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ const mockStripe = {
1111
checkout: { sessions: { create: jest.fn() } },
1212
billingPortal: { sessions: { create: jest.fn() } },
1313
subscriptions: { retrieve: jest.fn(), update: jest.fn() },
14-
invoices: { createPreview: jest.fn() },
14+
prices: { retrieve: jest.fn() },
15+
invoiceItems: { create: jest.fn() },
16+
invoices: { create: jest.fn(), pay: jest.fn() },
1517
webhooks: { constructEvent: jest.fn() },
1618
};
1719
jest.mock('stripe', () => {
@@ -360,50 +362,21 @@ describe('BillingService', () => {
360362

361363
it('returns preview for valid upgrade', async () => {
362364
mockRepository.findOne.mockResolvedValue(mockSubscription);
363-
mockStripe.subscriptions.retrieve.mockResolvedValue({
364-
items: { data: [{ id: 'si_item1' }] },
365-
});
366-
mockStripe.invoices.createPreview.mockResolvedValue({
367-
amount_due: 12900,
368-
currency: 'usd',
369-
lines: {
370-
data: [
371-
{
372-
amount: -1935,
373-
parent: {
374-
subscription_item_details: { proration: true },
375-
},
376-
},
377-
{
378-
amount: 5270,
379-
parent: {
380-
subscription_item_details: { proration: true },
381-
},
382-
},
383-
{
384-
amount: 7900,
385-
parent: {
386-
subscription_item_details: { proration: false },
387-
},
388-
},
389-
],
390-
},
391-
});
365+
mockStripe.prices.retrieve
366+
.mockResolvedValueOnce({ unit_amount: 2900, currency: 'usd' })
367+
.mockResolvedValueOnce({ unit_amount: 7900, currency: 'usd' });
392368

393369
const result = await service.previewUpgrade(userId, 'team');
394370
expect(result).toEqual({
395-
immediateAmountCents: 3335,
371+
immediateAmountCents: 5000,
396372
currency: 'usd',
397373
targetPlan: 'team',
398374
currentPeriodEnd: mockSubscription.currentPeriodEnd!.toISOString(),
399375
});
400-
expect(mockStripe.invoices.createPreview).toHaveBeenCalledWith({
401-
subscription: 'sub_test123',
402-
subscription_details: {
403-
items: [{ id: 'si_item1', price: 'price_team_456' }],
404-
proration_behavior: 'create_prorations',
405-
},
406-
});
376+
expect(mockStripe.prices.retrieve).toHaveBeenCalledWith(
377+
'price_starter_123',
378+
);
379+
expect(mockStripe.prices.retrieve).toHaveBeenCalledWith('price_team_456');
407380
});
408381
});
409382

@@ -422,12 +395,18 @@ describe('BillingService', () => {
422395
).rejects.toThrow(BadRequestException);
423396
});
424397

425-
it('upgrades subscription with proration', async () => {
398+
it('upgrades subscription with flat difference', async () => {
426399
mockRepository.findOne.mockResolvedValue({ ...mockSubscription });
400+
mockStripe.prices.retrieve
401+
.mockResolvedValueOnce({ unit_amount: 2900, currency: 'usd' })
402+
.mockResolvedValueOnce({ unit_amount: 7900, currency: 'usd' });
427403
mockStripe.subscriptions.retrieve.mockResolvedValue({
428404
items: { data: [{ id: 'si_item1' }] },
429405
});
430406
mockStripe.subscriptions.update.mockResolvedValue({});
407+
mockStripe.invoiceItems.create.mockResolvedValue({});
408+
mockStripe.invoices.create.mockResolvedValue({ id: 'inv_123' });
409+
mockStripe.invoices.pay.mockResolvedValue({});
431410
mockRepository.save.mockResolvedValue({
432411
...mockSubscription,
433412
plan: 'team',
@@ -439,9 +418,20 @@ describe('BillingService', () => {
439418
'sub_test123',
440419
{
441420
items: [{ id: 'si_item1', price: 'price_team_456' }],
442-
proration_behavior: 'create_prorations',
421+
proration_behavior: 'none',
443422
},
444423
);
424+
expect(mockStripe.invoiceItems.create).toHaveBeenCalledWith({
425+
customer: 'cus_test123',
426+
amount: 5000,
427+
currency: 'usd',
428+
description: 'Plan upgrade: starter → team',
429+
});
430+
expect(mockStripe.invoices.create).toHaveBeenCalledWith({
431+
customer: 'cus_test123',
432+
auto_advance: true,
433+
});
434+
expect(mockStripe.invoices.pay).toHaveBeenCalledWith('inv_123');
445435
expect(mockRepository.save).toHaveBeenCalledWith(
446436
expect.objectContaining({ plan: 'team' }),
447437
);

backend/src/billing/billing.service.ts

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -142,38 +142,13 @@ export class BillingService {
142142
currentPeriodEnd: string;
143143
}> {
144144
const sub = await this.validateUpgrade(userId, newPlan);
145-
const newPriceId = this.priceMap[newPlan];
146-
147-
const stripeSub = await this.stripe.subscriptions.retrieve(
148-
sub.stripeSubscriptionId!,
149-
);
150-
const itemId = stripeSub.items.data[0].id;
151145

152-
const upcomingInvoice = await this.stripe.invoices.createPreview({
153-
subscription: sub.stripeSubscriptionId!,
154-
subscription_details: {
155-
items: [{ id: itemId, price: newPriceId }],
156-
proration_behavior: 'create_prorations',
157-
},
158-
});
159-
160-
// Sum only the proration line items — amount_due includes the next
161-
// full billing cycle which isn't charged immediately.
162-
const isProration = (line: Stripe.InvoiceLineItem): boolean => {
163-
const parent = line.parent;
164-
if (!parent) return false;
165-
return (
166-
parent.invoice_item_details?.proration === true ||
167-
parent.subscription_item_details?.proration === true
168-
);
169-
};
170-
const prorationAmount = upcomingInvoice.lines.data
171-
.filter(isProration)
172-
.reduce((sum, line) => sum + line.amount, 0);
146+
const { currentPriceCents, newPriceCents, currency } =
147+
await this.getPriceDifference(sub.plan, newPlan);
173148

174149
return {
175-
immediateAmountCents: prorationAmount,
176-
currency: upcomingInvoice.currency,
150+
immediateAmountCents: newPriceCents - currentPriceCents,
151+
currency,
177152
targetPlan: newPlan,
178153
currentPeriodEnd: sub.currentPeriodEnd!.toISOString(),
179154
};
@@ -191,16 +166,35 @@ export class BillingService {
191166
const sub = await this.validateUpgrade(userId, newPlan);
192167
const newPriceId = this.priceMap[newPlan];
193168

169+
const { currentPriceCents, newPriceCents, currency } =
170+
await this.getPriceDifference(sub.plan, newPlan);
171+
const differenceCents = newPriceCents - currentPriceCents;
172+
194173
const stripeSub = await this.stripe.subscriptions.retrieve(
195174
sub.stripeSubscriptionId!,
196175
);
197176
const itemId = stripeSub.items.data[0].id;
198177

178+
// Switch the plan without Stripe's day-based proration.
199179
await this.stripe.subscriptions.update(sub.stripeSubscriptionId!, {
200180
items: [{ id: itemId, price: newPriceId }],
201-
proration_behavior: 'create_prorations',
181+
proration_behavior: 'none',
182+
});
183+
184+
// Charge the flat price difference immediately.
185+
await this.stripe.invoiceItems.create({
186+
customer: sub.stripeCustomerId,
187+
amount: differenceCents,
188+
currency,
189+
description: `Plan upgrade: ${sub.plan}${newPlan}`,
202190
});
203191

192+
const invoice = await this.stripe.invoices.create({
193+
customer: sub.stripeCustomerId,
194+
auto_advance: true,
195+
});
196+
await this.stripe.invoices.pay(invoice.id);
197+
204198
sub.plan = newPlan;
205199
await this.subscriptionRepository.save(sub);
206200

@@ -214,6 +208,29 @@ export class BillingService {
214208
};
215209
}
216210

211+
private async getPriceDifference(
212+
currentPlan: string,
213+
newPlan: string,
214+
): Promise<{
215+
currentPriceCents: number;
216+
newPriceCents: number;
217+
currency: string;
218+
}> {
219+
const currentPriceId = this.priceMap[currentPlan];
220+
const newPriceId = this.priceMap[newPlan];
221+
222+
const [currentPrice, newPrice] = await Promise.all([
223+
this.stripe.prices.retrieve(currentPriceId),
224+
this.stripe.prices.retrieve(newPriceId),
225+
]);
226+
227+
return {
228+
currentPriceCents: currentPrice.unit_amount ?? 0,
229+
newPriceCents: newPrice.unit_amount ?? 0,
230+
currency: newPrice.currency,
231+
};
232+
}
233+
217234
private async validateUpgrade(
218235
userId: string,
219236
newPlan: string,

0 commit comments

Comments
 (0)