Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion apps/node-fastify/src/test/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<Subscription> } } })
.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',
Expand Down
16 changes: 16 additions & 0 deletions packages/orb-sync-lib/src/database/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,22 @@ export class PostgresClient {
return result.rows;
}

async getBillingCycleEndDate(subscriptionId: string): Promise<string | null> {
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<QueryResult> {
return this.pool.query(text, params);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/orb-sync-lib/src/invoice-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
};
}

Expand Down
14 changes: 13 additions & 1 deletion packages/orb-sync-lib/src/orb-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
import { PostgresClient } from './database/postgres';
import { fetchAndSyncCustomer, fetchAndSyncCustomers, syncCustomers } from './sync/customers';
import {
checkIfCurrentBillingCycleIsOutdated,
fetchAndSyncSubscription,
fetchAndSyncSubscriptions,
syncSubscriptions,
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions packages/orb-sync-lib/src/sync/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,17 @@ export async function updateBillingCycle(

return postgresClient.updateSubscriptionBillingCycle({ subscriptionId, billingCycleStart, billingCycleEnd });
}

export async function checkIfCurrentBillingCycleIsOutdated(
postgresClient: PostgresClient,
subscriptionId: string
): Promise<boolean> {
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();
}