From baf13c282f73d77c4f5c5d6cc7bdcf6bcea49c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Sun, 21 Jun 2026 14:49:54 +0800 Subject: [PATCH 1/4] fix: resync subscriptions on in_arrear invoice fixed fee 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. --- apps/node-fastify/src/test/webhooks.test.ts | 82 ++++++++++++++++++++- packages/orb-sync-lib/src/invoice-utils.ts | 5 +- packages/orb-sync-lib/src/orb-sync.ts | 18 +++-- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/apps/node-fastify/src/test/webhooks.test.ts b/apps/node-fastify/src/test/webhooks.test.ts index 1a307d6..2c650bd 100644 --- a/apps/node-fastify/src/test/webhooks.test.ts +++ b/apps/node-fastify/src/test/webhooks.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from 'vitest'; import { FastifyInstance } from 'fastify'; import path from 'node:path'; import pino from 'pino'; @@ -18,6 +18,7 @@ describe('POST /webhooks', () => { let orbSync: OrbSync; beforeAll(async () => { + process.env.TZ = 'UTC'; // Ensure consistent timezone for tests const logger = pino({ level: 'silent' }); // Create a OrbSync instance for integration testing @@ -45,6 +46,10 @@ describe('POST /webhooks', () => { } }); + afterEach(() => { + vi.restoreAllMocks(); + }); + function loadWebhookPayload(eventName: string): string { const fixturePath = path.join(__dirname, 'orb', `${eventName}.json`); return fs.readFileSync(fixturePath, 'utf-8'); @@ -171,6 +176,81 @@ describe('POST /webhooks', () => { ); }); + it('should handle invoice.issued webhook and resync subscription if in-arrears fixed fee', async () => { + let payload = loadWebhookPayload('invoice'); + + // Parse the payload and update billing cycle dates to sensible values + const webhookData = JSON.parse(payload); + const invoiceId = webhookData.invoice.id; + const subscriptionId = webhookData.invoice.subscription?.id; + const customerId = webhookData.invoice.customer.id; + + // As preparation, we delete the existing invoice and subscription if they exist + await deleteTestData(orbSync.postgresClient, 'invoices', [invoiceId]); + await deleteTestData(orbSync.postgresClient, 'subscriptions', [subscriptionId]); + + webhookData.type = 'invoice.issued'; + + const now = new Date(); + const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago + const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days from now + + // Find and update the plan line item dates + const planLineItem = webhookData.invoice.line_items.find( + (item: Invoice.LineItem) => + item.price?.price_type === 'fixed_price' && item.price.billable_metric === null && item.name.endsWith('Plan') + ); + planLineItem.price.billing_mode = 'in_arrear'; + + planLineItem.start_date = startDate.toISOString(); + planLineItem.end_date = endDate.toISOString(); + + // Update the payload with the modified data + payload = JSON.stringify(webhookData); + + const subscriptionStartDate = new Date('2025-03-01T00:00:00.000Z'); + + // For an in-arrears fixed fee, the webhook handler resyncs the subscription from the Orb API + // to get the new billing cycle. We mock that fetch to return the subscription with the + // updated billing period dates. + const testSubscription = { + id: subscriptionId, + customer: { id: customerId }, + status: 'active', + created_at: subscriptionStartDate.toISOString(), + start_date: subscriptionStartDate.toISOString(), + current_billing_period_start_date: startDate.toISOString(), + current_billing_period_end_date: endDate.toISOString(), + billing_cycle_day: 8, + net_terms: 0, + metadata: {}, + } as Subscription; + + // Mock the Orb SDK call that fetches the subscription during the in-arrears resync + const orb = (orbSync as unknown as { orb: { subscriptions: { fetch: (id: string) => Promise } } }) + .orb; + const fetchSpy = vi.spyOn(orb.subscriptions, 'fetch').mockResolvedValue(testSubscription); + + const response = await sendWebhookRequest(payload); + expect(fetchSpy).toHaveBeenCalledWith(subscriptionId); + expect(response.statusCode).toBe(200); + + // Verify that the invoice was created in the database + const [invoice] = await fetchInvoicesFromDatabase(orbSync.postgresClient, [invoiceId]); + expect(invoice).toBeDefined(); + + // Verify that billing cycle was updated if subscription exists and has a plan line item + const billingCycles = await fetchBillingCyclesFromDatabase(orbSync.postgresClient, subscriptionId); + expect(billingCycles).toHaveLength(1); + const billingCycle = billingCycles[0]; + + // The billing cycle should reflect the resynced subscription's billing period (startDate/endDate), + // proving the in-arrears branch fetched the subscription from the Orb API rather than deriving the + // cycle from the invoice line item. + expect(new Date(billingCycle.current_billing_period_start_date).toISOString()).toBe(startDate.toISOString()); + expect(new Date(billingCycle.current_billing_period_end_date).toISOString()).toBe(endDate.toISOString()); + }); + it.each([ 'invoice.edited', 'invoice.manually_marked_as_void', diff --git a/packages/orb-sync-lib/src/invoice-utils.ts b/packages/orb-sync-lib/src/invoice-utils.ts index 911b050..ec64f1b 100644 --- a/packages/orb-sync-lib/src/invoice-utils.ts +++ b/packages/orb-sync-lib/src/invoice-utils.ts @@ -6,7 +6,9 @@ const PLAN_LINE_ITEM_NAME_ENDS_IN = 'Plan'; * Returns the billing cycle the given invoice's plan line item applies to. * If no plan line item is present, null is returned. */ -export function getBillingCycleFromInvoice(invoice: Invoice): { start: string; end: string } | null { +export function getBillingCycleFromInvoice( + invoice: Invoice +): { start: string; end: string; inArrears: boolean } | null { const planLineItem = findPlanLineItem(invoice.line_items); // No plan line item found. @@ -19,6 +21,7 @@ export function getBillingCycleFromInvoice(invoice: Invoice): { start: string; e return { start: planLineItem.start_date, end: planLineItem.end_date, + inArrears: planLineItem.price?.billing_mode === 'in_arrear', }; } diff --git a/packages/orb-sync-lib/src/orb-sync.ts b/packages/orb-sync-lib/src/orb-sync.ts index ac9936c..64aff79 100644 --- a/packages/orb-sync-lib/src/orb-sync.ts +++ b/packages/orb-sync-lib/src/orb-sync.ts @@ -227,11 +227,19 @@ export class OrbSync { const billingCycle = getBillingCycleFromInvoice(invoice); if (billingCycle && invoice.subscription) { - await updateBillingCycle(this.postgresClient, { - subscriptionId: invoice.subscription.id, - billingCycleStart: billingCycle.start, - billingCycleEnd: billingCycle.end, - }); + if (billingCycle.inArrears) { + // When a plan is configured with an in-arrears fixed fee, we cannot directly determine the billing cycle start and end dates + // when the invoice is issued, as the plan line item will contain the past and not the new billing cycle + // In order to not run into stale data, we resync the subscription + const subscription = await this.orb.subscriptions.fetch(invoice.subscription.id); + await syncSubscriptions(this.postgresClient, [subscription], webhook.created_at); + } else { + await updateBillingCycle(this.postgresClient, { + subscriptionId: invoice.subscription.id, + billingCycleStart: billingCycle.start, + billingCycleEnd: billingCycle.end, + }); + } } break; From 07e03f7f9c9821cdc3eb9c352234eef10f0382cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Sun, 21 Jun 2026 16:20:53 +0800 Subject: [PATCH 2/4] update --- apps/node-fastify/src/test/webhooks.test.ts | 39 +++++++++++-------- .../orb-sync-lib/src/database/postgres.ts | 16 ++++++++ packages/orb-sync-lib/src/orb-sync.ts | 26 +++++++------ .../orb-sync-lib/src/sync/subscriptions.ts | 16 ++++++++ 4 files changed, 69 insertions(+), 28 deletions(-) diff --git a/apps/node-fastify/src/test/webhooks.test.ts b/apps/node-fastify/src/test/webhooks.test.ts index 2c650bd..ca1ee39 100644 --- a/apps/node-fastify/src/test/webhooks.test.ts +++ b/apps/node-fastify/src/test/webhooks.test.ts @@ -176,7 +176,7 @@ describe('POST /webhooks', () => { ); }); - it('should handle invoice.issued webhook and resync subscription if in-arrears fixed fee', async () => { + it('should handle invoice.issued webhook and resync subscription if billing cycle outdated', async () => { let payload = loadWebhookPayload('invoice'); // Parse the payload and update billing cycle dates to sensible values @@ -192,44 +192,49 @@ describe('POST /webhooks', () => { webhookData.type = 'invoice.issued'; const now = new Date(); - const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago - const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days from now + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago + const thirtyOneDaysAgo = new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000); // 31 days ago + const thirtyDaysInFuture = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days in future - // Find and update the plan line item dates + // Find and update the plan and change to in_arrear const planLineItem = webhookData.invoice.line_items.find( (item: Invoice.LineItem) => item.price?.price_type === 'fixed_price' && item.price.billable_metric === null && item.name.endsWith('Plan') ); planLineItem.price.billing_mode = 'in_arrear'; - planLineItem.start_date = startDate.toISOString(); - planLineItem.end_date = endDate.toISOString(); - // Update the payload with the modified data payload = JSON.stringify(webhookData); - const subscriptionStartDate = new Date('2025-03-01T00:00:00.000Z'); - - // For an in-arrears fixed fee, the webhook handler resyncs the subscription from the Orb API + // For an outdated billing cycle, the webhook handler resyncs the subscription from the Orb API // to get the new billing cycle. We mock that fetch to return the subscription with the // updated billing period dates. const testSubscription = { id: subscriptionId, customer: { id: customerId }, status: 'active', - created_at: subscriptionStartDate.toISOString(), - start_date: subscriptionStartDate.toISOString(), - current_billing_period_start_date: startDate.toISOString(), - current_billing_period_end_date: endDate.toISOString(), + current_billing_period_start_date: thirtyOneDaysAgo.toISOString(), + // Billing cycle end date in past + current_billing_period_end_date: oneDayAgo.toISOString(), billing_cycle_day: 8, net_terms: 0, metadata: {}, + created_at: new Date().toISOString(), + start_date: new Date().toISOString(), } as Subscription; + syncSubscriptions(orbSync.postgresClient, [testSubscription]); + // Mock the Orb SDK call that fetches the subscription during the in-arrears resync const orb = (orbSync as unknown as { orb: { subscriptions: { fetch: (id: string) => Promise } } }) .orb; - const fetchSpy = vi.spyOn(orb.subscriptions, 'fetch').mockResolvedValue(testSubscription); + + const latestSubscription = { + ...testSubscription, + current_billing_period_start_date: oneDayAgo.toISOString(), + current_billing_period_end_date: thirtyDaysInFuture.toISOString(), + }; + const fetchSpy = vi.spyOn(orb.subscriptions, 'fetch').mockResolvedValue(latestSubscription); const response = await sendWebhookRequest(payload); expect(fetchSpy).toHaveBeenCalledWith(subscriptionId); @@ -247,8 +252,8 @@ describe('POST /webhooks', () => { // The billing cycle should reflect the resynced subscription's billing period (startDate/endDate), // proving the in-arrears branch fetched the subscription from the Orb API rather than deriving the // cycle from the invoice line item. - expect(new Date(billingCycle.current_billing_period_start_date).toISOString()).toBe(startDate.toISOString()); - expect(new Date(billingCycle.current_billing_period_end_date).toISOString()).toBe(endDate.toISOString()); + expect(new Date(billingCycle.current_billing_period_start_date).toISOString()).toBe(oneDayAgo.toISOString()); + expect(new Date(billingCycle.current_billing_period_end_date).toISOString()).toBe(thirtyDaysInFuture.toISOString()); }); it.each([ diff --git a/packages/orb-sync-lib/src/database/postgres.ts b/packages/orb-sync-lib/src/database/postgres.ts index 7c33c0a..afa2767 100644 --- a/packages/orb-sync-lib/src/database/postgres.ts +++ b/packages/orb-sync-lib/src/database/postgres.ts @@ -178,6 +178,22 @@ export class PostgresClient { return result.rows; } + async getBillingCycleEndDate(subscriptionId: string): Promise { + const query = ` + select current_billing_period_end_date + from "${this.config.schema}"."subscriptions" + where id = $1 and status = 'active' + `; + + const result = await this.pool.query(query, [subscriptionId]); + + if (!result.rows.length) { + return null; + } + + return result.rows[0].current_billing_period_end_date; + } + async query(text: string, params?: string[]): Promise { return this.pool.query(text, params); } diff --git a/packages/orb-sync-lib/src/orb-sync.ts b/packages/orb-sync-lib/src/orb-sync.ts index 64aff79..2f105fc 100644 --- a/packages/orb-sync-lib/src/orb-sync.ts +++ b/packages/orb-sync-lib/src/orb-sync.ts @@ -18,6 +18,7 @@ import type { import { PostgresClient } from './database/postgres'; import { fetchAndSyncCustomer, fetchAndSyncCustomers, syncCustomers } from './sync/customers'; import { + checkIfCurrentBillingCycleIsOutdated, fetchAndSyncSubscription, fetchAndSyncSubscriptions, syncSubscriptions, @@ -226,19 +227,22 @@ export class OrbSync { await syncInvoices(this.postgresClient, [invoice], webhook.created_at); const billingCycle = getBillingCycleFromInvoice(invoice); - if (billingCycle && invoice.subscription) { - if (billingCycle.inArrears) { - // When a plan is configured with an in-arrears fixed fee, we cannot directly determine the billing cycle start and end dates - // when the invoice is issued, as the plan line item will contain the past and not the new billing cycle - // In order to not run into stale data, we resync the subscription + if (billingCycle && invoice.subscription && !billingCycle.inArrears) { + await updateBillingCycle(this.postgresClient, { + subscriptionId: invoice.subscription.id, + billingCycleStart: billingCycle.start, + billingCycleEnd: billingCycle.end, + }); + } else if ((billingCycle?.inArrears || !billingCycle) && invoice.subscription) { + // 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 + const isOutdated = await checkIfCurrentBillingCycleIsOutdated(this.postgresClient, invoice.subscription.id); + + if (isOutdated) { + this.config.logger?.info( + `Billing cycle of subscription ${invoice.subscription.id} is outdated, fetching latest subscription data from Orb API to update it` + ); const subscription = await this.orb.subscriptions.fetch(invoice.subscription.id); await syncSubscriptions(this.postgresClient, [subscription], webhook.created_at); - } else { - await updateBillingCycle(this.postgresClient, { - subscriptionId: invoice.subscription.id, - billingCycleStart: billingCycle.start, - billingCycleEnd: billingCycle.end, - }); } } diff --git a/packages/orb-sync-lib/src/sync/subscriptions.ts b/packages/orb-sync-lib/src/sync/subscriptions.ts index 182a165..2703762 100644 --- a/packages/orb-sync-lib/src/sync/subscriptions.ts +++ b/packages/orb-sync-lib/src/sync/subscriptions.ts @@ -83,3 +83,19 @@ export async function updateBillingCycle( return postgresClient.updateSubscriptionBillingCycle({ subscriptionId, billingCycleStart, billingCycleEnd }); } + +export async function checkIfCurrentBillingCycleIsOutdated( + postgresClient: PostgresClient, + subscriptionId: string +): Promise { + const billingCycleEndDate = await postgresClient.getBillingCycleEndDate(subscriptionId); + + if (!billingCycleEndDate) { + // If none is found, no need to update it, perhaps already deleted sub + return false; + } + + const isEndDateInPast = new Date(billingCycleEndDate) < new Date(); + + return isEndDateInPast; +} From e14c312b5f131bd5877b3598020d6efd1e8eefbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Sun, 21 Jun 2026 16:24:28 +0800 Subject: [PATCH 3/4] Update orb-sync.ts --- packages/orb-sync-lib/src/orb-sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orb-sync-lib/src/orb-sync.ts b/packages/orb-sync-lib/src/orb-sync.ts index 2f105fc..2f90fce 100644 --- a/packages/orb-sync-lib/src/orb-sync.ts +++ b/packages/orb-sync-lib/src/orb-sync.ts @@ -242,7 +242,7 @@ export class OrbSync { `Billing cycle of subscription ${invoice.subscription.id} is outdated, fetching latest subscription data from Orb API to update it` ); const subscription = await this.orb.subscriptions.fetch(invoice.subscription.id); - await syncSubscriptions(this.postgresClient, [subscription], webhook.created_at); + await syncSubscriptions(this.postgresClient, [subscription]); } } From 818105dfec9642e0a8267f8806a5b0c7281f5446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Mon, 22 Jun 2026 10:11:37 +0800 Subject: [PATCH 4/4] Update subscriptions.ts --- packages/orb-sync-lib/src/sync/subscriptions.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/orb-sync-lib/src/sync/subscriptions.ts b/packages/orb-sync-lib/src/sync/subscriptions.ts index 2703762..ea0642e 100644 --- a/packages/orb-sync-lib/src/sync/subscriptions.ts +++ b/packages/orb-sync-lib/src/sync/subscriptions.ts @@ -95,7 +95,5 @@ export async function checkIfCurrentBillingCycleIsOutdated( return false; } - const isEndDateInPast = new Date(billingCycleEndDate) < new Date(); - - return isEndDateInPast; + return new Date(billingCycleEndDate).getTime() < new Date().getTime(); }