Skip to content

Commit 274a935

Browse files
authored
fix: resync subscriptions on in_arrear invoice fixed fee (#162)
Orb has no event for billing cycle resets and we previously assumed that fixed fees are always in-advance and update the billing cycle accordingly when we receive an `invoice.issued` event that contains the new in-advance fee for the next billing cycle. This is no longer true as we have moved some customers to in-arrear fixed fees where we cannot derive the new billing cycle based on the invoice line items. I have resynced the affected cases manually. To ensure no stale billing cycles, we now recognise in-arrear fixed fees in invoices and resync the subscription.
1 parent 4b1ee98 commit 274a935

5 files changed

Lines changed: 133 additions & 3 deletions

File tree

apps/node-fastify/src/test/webhooks.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1+
import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from 'vitest';
22
import { FastifyInstance } from 'fastify';
33
import path from 'node:path';
44
import pino from 'pino';
@@ -18,6 +18,7 @@ describe('POST /webhooks', () => {
1818
let orbSync: OrbSync;
1919

2020
beforeAll(async () => {
21+
process.env.TZ = 'UTC'; // Ensure consistent timezone for tests
2122
const logger = pino({ level: 'silent' });
2223

2324
// Create a OrbSync instance for integration testing
@@ -45,6 +46,10 @@ describe('POST /webhooks', () => {
4546
}
4647
});
4748

49+
afterEach(() => {
50+
vi.restoreAllMocks();
51+
});
52+
4853
function loadWebhookPayload(eventName: string): string {
4954
const fixturePath = path.join(__dirname, 'orb', `${eventName}.json`);
5055
return fs.readFileSync(fixturePath, 'utf-8');
@@ -171,6 +176,86 @@ describe('POST /webhooks', () => {
171176
);
172177
});
173178

179+
it('should handle invoice.issued webhook and resync subscription if billing cycle outdated', async () => {
180+
let payload = loadWebhookPayload('invoice');
181+
182+
// Parse the payload and update billing cycle dates to sensible values
183+
const webhookData = JSON.parse(payload);
184+
const invoiceId = webhookData.invoice.id;
185+
const subscriptionId = webhookData.invoice.subscription?.id;
186+
const customerId = webhookData.invoice.customer.id;
187+
188+
// As preparation, we delete the existing invoice and subscription if they exist
189+
await deleteTestData(orbSync.postgresClient, 'invoices', [invoiceId]);
190+
await deleteTestData(orbSync.postgresClient, 'subscriptions', [subscriptionId]);
191+
192+
webhookData.type = 'invoice.issued';
193+
194+
const now = new Date();
195+
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago
196+
const thirtyOneDaysAgo = new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000); // 31 days ago
197+
const thirtyDaysInFuture = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days in future
198+
199+
// Find and update the plan and change to in_arrear
200+
const planLineItem = webhookData.invoice.line_items.find(
201+
(item: Invoice.LineItem) =>
202+
item.price?.price_type === 'fixed_price' && item.price.billable_metric === null && item.name.endsWith('Plan')
203+
);
204+
planLineItem.price.billing_mode = 'in_arrear';
205+
206+
// Update the payload with the modified data
207+
payload = JSON.stringify(webhookData);
208+
209+
// For an outdated billing cycle, the webhook handler resyncs the subscription from the Orb API
210+
// to get the new billing cycle. We mock that fetch to return the subscription with the
211+
// updated billing period dates.
212+
const testSubscription = {
213+
id: subscriptionId,
214+
customer: { id: customerId },
215+
status: 'active',
216+
current_billing_period_start_date: thirtyOneDaysAgo.toISOString(),
217+
// Billing cycle end date in past
218+
current_billing_period_end_date: oneDayAgo.toISOString(),
219+
billing_cycle_day: 8,
220+
net_terms: 0,
221+
metadata: {},
222+
created_at: new Date().toISOString(),
223+
start_date: new Date().toISOString(),
224+
} as Subscription;
225+
226+
syncSubscriptions(orbSync.postgresClient, [testSubscription]);
227+
228+
// Mock the Orb SDK call that fetches the subscription during the in-arrears resync
229+
const orb = (orbSync as unknown as { orb: { subscriptions: { fetch: (id: string) => Promise<Subscription> } } })
230+
.orb;
231+
232+
const latestSubscription = {
233+
...testSubscription,
234+
current_billing_period_start_date: oneDayAgo.toISOString(),
235+
current_billing_period_end_date: thirtyDaysInFuture.toISOString(),
236+
};
237+
const fetchSpy = vi.spyOn(orb.subscriptions, 'fetch').mockResolvedValue(latestSubscription);
238+
239+
const response = await sendWebhookRequest(payload);
240+
expect(fetchSpy).toHaveBeenCalledWith(subscriptionId);
241+
expect(response.statusCode).toBe(200);
242+
243+
// Verify that the invoice was created in the database
244+
const [invoice] = await fetchInvoicesFromDatabase(orbSync.postgresClient, [invoiceId]);
245+
expect(invoice).toBeDefined();
246+
247+
// Verify that billing cycle was updated if subscription exists and has a plan line item
248+
const billingCycles = await fetchBillingCyclesFromDatabase(orbSync.postgresClient, subscriptionId);
249+
expect(billingCycles).toHaveLength(1);
250+
const billingCycle = billingCycles[0];
251+
252+
// The billing cycle should reflect the resynced subscription's billing period (startDate/endDate),
253+
// proving the in-arrears branch fetched the subscription from the Orb API rather than deriving the
254+
// cycle from the invoice line item.
255+
expect(new Date(billingCycle.current_billing_period_start_date).toISOString()).toBe(oneDayAgo.toISOString());
256+
expect(new Date(billingCycle.current_billing_period_end_date).toISOString()).toBe(thirtyDaysInFuture.toISOString());
257+
});
258+
174259
it.each([
175260
'invoice.edited',
176261
'invoice.manually_marked_as_void',

packages/orb-sync-lib/src/database/postgres.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,22 @@ export class PostgresClient {
178178
return result.rows;
179179
}
180180

181+
async getBillingCycleEndDate(subscriptionId: string): Promise<string | null> {
182+
const query = `
183+
select current_billing_period_end_date
184+
from "${this.config.schema}"."subscriptions"
185+
where id = $1 and status = 'active'
186+
`;
187+
188+
const result = await this.pool.query(query, [subscriptionId]);
189+
190+
if (!result.rows.length) {
191+
return null;
192+
}
193+
194+
return result.rows[0].current_billing_period_end_date;
195+
}
196+
181197
async query(text: string, params?: string[]): Promise<QueryResult> {
182198
return this.pool.query(text, params);
183199
}

packages/orb-sync-lib/src/invoice-utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ const PLAN_LINE_ITEM_NAME_ENDS_IN = 'Plan';
66
* Returns the billing cycle the given invoice's plan line item applies to.
77
* If no plan line item is present, null is returned.
88
*/
9-
export function getBillingCycleFromInvoice(invoice: Invoice): { start: string; end: string } | null {
9+
export function getBillingCycleFromInvoice(
10+
invoice: Invoice
11+
): { start: string; end: string; inArrears: boolean } | null {
1012
const planLineItem = findPlanLineItem(invoice.line_items);
1113

1214
// No plan line item found.
@@ -19,6 +21,7 @@ export function getBillingCycleFromInvoice(invoice: Invoice): { start: string; e
1921
return {
2022
start: planLineItem.start_date,
2123
end: planLineItem.end_date,
24+
inArrears: planLineItem.price?.billing_mode === 'in_arrear',
2225
};
2326
}
2427

packages/orb-sync-lib/src/orb-sync.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
import { PostgresClient } from './database/postgres';
1919
import { fetchAndSyncCustomer, fetchAndSyncCustomers, syncCustomers } from './sync/customers';
2020
import {
21+
checkIfCurrentBillingCycleIsOutdated,
2122
fetchAndSyncSubscription,
2223
fetchAndSyncSubscriptions,
2324
syncSubscriptions,
@@ -226,12 +227,23 @@ export class OrbSync {
226227
await syncInvoices(this.postgresClient, [invoice], webhook.created_at);
227228

228229
const billingCycle = getBillingCycleFromInvoice(invoice);
229-
if (billingCycle && invoice.subscription) {
230+
if (billingCycle && invoice.subscription && !billingCycle.inArrears) {
230231
await updateBillingCycle(this.postgresClient, {
231232
subscriptionId: invoice.subscription.id,
232233
billingCycleStart: billingCycle.start,
233234
billingCycleEnd: billingCycle.end,
234235
});
236+
} else if ((billingCycle?.inArrears || !billingCycle) && invoice.subscription) {
237+
// In case no plan item is present, we still want to do a check to see if there is an outdated billing cycle and potentially trigger an update
238+
const isOutdated = await checkIfCurrentBillingCycleIsOutdated(this.postgresClient, invoice.subscription.id);
239+
240+
if (isOutdated) {
241+
this.config.logger?.info(
242+
`Billing cycle of subscription ${invoice.subscription.id} is outdated, fetching latest subscription data from Orb API to update it`
243+
);
244+
const subscription = await this.orb.subscriptions.fetch(invoice.subscription.id);
245+
await syncSubscriptions(this.postgresClient, [subscription]);
246+
}
235247
}
236248

237249
break;

packages/orb-sync-lib/src/sync/subscriptions.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,17 @@ export async function updateBillingCycle(
8383

8484
return postgresClient.updateSubscriptionBillingCycle({ subscriptionId, billingCycleStart, billingCycleEnd });
8585
}
86+
87+
export async function checkIfCurrentBillingCycleIsOutdated(
88+
postgresClient: PostgresClient,
89+
subscriptionId: string
90+
): Promise<boolean> {
91+
const billingCycleEndDate = await postgresClient.getBillingCycleEndDate(subscriptionId);
92+
93+
if (!billingCycleEndDate) {
94+
// If none is found, no need to update it, perhaps already deleted sub
95+
return false;
96+
}
97+
98+
return new Date(billingCycleEndDate).getTime() < new Date().getTime();
99+
}

0 commit comments

Comments
 (0)