diff --git a/apps/node-fastify/src/test/webhooks.test.ts b/apps/node-fastify/src/test/webhooks.test.ts index 1a307d6..ca1ee39 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,86 @@ describe('POST /webhooks', () => { ); }); + 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 + 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 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 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'; + + // Update the payload with the modified data + payload = JSON.stringify(webhookData); + + // 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', + 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 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); + 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(oneDayAgo.toISOString()); + expect(new Date(billingCycle.current_billing_period_end_date).toISOString()).toBe(thirtyDaysInFuture.toISOString()); + }); + it.each([ 'invoice.edited', 'invoice.manually_marked_as_void', 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/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..2f90fce 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,12 +227,23 @@ export class OrbSync { await syncInvoices(this.postgresClient, [invoice], webhook.created_at); const billingCycle = getBillingCycleFromInvoice(invoice); - if (billingCycle && invoice.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]); + } } break; diff --git a/packages/orb-sync-lib/src/sync/subscriptions.ts b/packages/orb-sync-lib/src/sync/subscriptions.ts index 182a165..ea0642e 100644 --- a/packages/orb-sync-lib/src/sync/subscriptions.ts +++ b/packages/orb-sync-lib/src/sync/subscriptions.ts @@ -83,3 +83,17 @@ 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; + } + + return new Date(billingCycleEndDate).getTime() < new Date().getTime(); +}