Skip to content

Commit 040c874

Browse files
authored
feat: updates subscription billing cycle on invoice.issued event (#22)
Orb currently has no webhook whenever a billing cycle is reset, so subscription billing cycle information may be outdated. As a workaround we check whether an issued invoice contains a plan line item. A plan line item bein present means that there was a billing cycle reset and you can find the billing cycle's start and end date in the line item.
1 parent cc8ff40 commit 040c874

7 files changed

Lines changed: 122 additions & 3 deletions

File tree

apps/node-fastify/.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ ORB_API_KEY=test_
1616
# Optional
1717
PORT=8080
1818

19+
# Optional, whether to verify the Orb webhook signature
20+
VERIFY_WEBHOOK_SIGNATURE=true

apps/node-fastify/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export async function createApp(opts: FastifyServerOptions = {}): Promise<Fastif
4242
orbWebhookSecret: config.ORB_WEBHOOK_SECRET,
4343
databaseSchema: config.DATABASE_SCHEMA,
4444
orbApiKey: config.ORB_API_KEY,
45+
verifyWebhookSignature: config.VERIFY_WEBHOOK_SIGNATURE,
4546
});
4647

4748
app.decorate('orbSync', orbSync);

apps/node-fastify/src/utils/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ type configType = {
1919

2020
/** Access the Orb API */
2121
ORB_API_KEY?: string;
22+
23+
/** Whether to verify the Orb webhook signature */
24+
VERIFY_WEBHOOK_SIGNATURE: boolean;
2225
};
2326

2427
function getConfigFromEnv(key: string, defaultValue?: string): string {
@@ -40,6 +43,7 @@ export function getConfig(): configType {
4043
DATABASE_URL: getConfigFromEnv('DATABASE_URL'),
4144
ORB_WEBHOOK_SECRET: getConfigFromEnv('ORB_WEBHOOK_SECRET'),
4245
PORT: Number(getConfigFromEnv('PORT', '8080')),
46+
VERIFY_WEBHOOK_SIGNATURE: getConfigFromEnv('VERIFY_WEBHOOK_SIGNATURE', 'true') === 'true',
4347
};
4448

4549
assert(!Number.isNaN(config.PORT), 'PORT must be a number');

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,39 @@ export class PostgresClient {
7676
;`;
7777
};
7878

79+
/**
80+
* Updates a subscription's billing cycle dates, provided that the current end date is in the past (i.e. the subscription
81+
* data in the database being stale).
82+
*/
83+
async updateSubscriptionBillingCycle({
84+
subscriptionId,
85+
billingCycleStart,
86+
billingCycleEnd,
87+
}: {
88+
subscriptionId: string;
89+
billingCycleStart: string;
90+
billingCycleEnd: string;
91+
}) {
92+
const updateSql = `
93+
update "${this.config.schema}"."subscriptions"
94+
set (current_billing_period_start_date, current_billing_period_end_date) =
95+
(:current_billing_period_start_date, :current_billing_period_end_date)
96+
where id = :id and current_billing_period_end_date < :now`;
97+
98+
const prepared = sql(updateSql, {
99+
useNullForMissing: true,
100+
})({
101+
id: subscriptionId,
102+
current_billing_period_start_date: billingCycleStart,
103+
current_billing_period_end_date: billingCycleEnd,
104+
now: new Date().toISOString(),
105+
});
106+
107+
const result = await this.pool.query(prepared.text, prepared.values);
108+
109+
return result.rows;
110+
}
111+
79112
private cleanseArrayField(
80113
obj: {
81114
[Key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Invoice } from 'orb-billing/resources';
2+
3+
const PLAN_LINE_ITEM_NAME_ENDS_IN = 'Plan';
4+
5+
/**
6+
* Returns the billing cycle the given invoice's plan line item applies to.
7+
* If no plan line item is present, null is returned.
8+
*/
9+
export function getBillingCycleFromInvoice(invoice: Invoice): { start: string; end: string } | null {
10+
const planLineItem = findPlanLineItem(invoice.line_items);
11+
12+
// No plan line item found.
13+
// Is the case for e.g. invoices that include usage line items for the past billing cycle only or
14+
// invoices that include a fixed price line item other than the plan line item only
15+
if (!planLineItem) {
16+
return null;
17+
}
18+
19+
return {
20+
start: planLineItem.start_date,
21+
end: planLineItem.end_date,
22+
};
23+
}
24+
25+
function findPlanLineItem(lineItems: Invoice.LineItem[]): Invoice.LineItem | undefined {
26+
return lineItems.find(
27+
(item) =>
28+
item.price?.price_type === 'fixed_price' &&
29+
item.price.billable_metric === null &&
30+
item.name.endsWith(PLAN_LINE_ITEM_NAME_ENDS_IN)
31+
);
32+
}

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ import type {
1414
} from './types';
1515
import { PostgresClient } from './database/postgres';
1616
import { fetchAndSyncCustomer, fetchAndSyncCustomers, syncCustomers } from './sync/customers';
17-
import { fetchAndSyncSubscription, fetchAndSyncSubscriptions, syncSubscriptions } from './sync/subscriptions';
17+
import {
18+
fetchAndSyncSubscription,
19+
fetchAndSyncSubscriptions,
20+
syncSubscriptions,
21+
updateBillingCycle,
22+
} from './sync/subscriptions';
1823
import { fetchAndSyncInvoice, fetchAndSyncInvoices, syncInvoices } from './sync/invoices';
1924
import { fetchAndSyncCreditNote, fetchAndSyncCreditNotes, syncCreditNotes } from './sync/credit_notes';
2025
import { fetchAndSyncPlan, fetchAndSyncPlans } from './sync/plans';
26+
import { getBillingCycleFromInvoice } from './invoice-utils';
2127

2228
export type OrbSyncConfig = {
2329
databaseUrl: string;
@@ -54,7 +60,7 @@ export class OrbSync {
5460
| CustomersFetchParams
5561
| CreditNotesFetchParams
5662
| SubscriptionsFetchParams
57-
| PlansFetchParams,
63+
| PlansFetchParams
5864
): Promise<number> {
5965
switch (entity) {
6066
case 'invoices': {
@@ -112,8 +118,24 @@ export class OrbSync {
112118
// We don't want to override invoice data with a minified version.
113119
break;
114120
}
121+
122+
case 'invoice.issued': {
123+
const invoice = (parsedData as InvoiceWebhook).invoice;
124+
await syncInvoices(this.postgresClient, [invoice]);
125+
126+
const billingCycle = getBillingCycleFromInvoice(invoice);
127+
if (billingCycle && invoice.subscription) {
128+
await updateBillingCycle(this.postgresClient, {
129+
subscriptionId: invoice.subscription.id,
130+
billingCycleStart: billingCycle.start,
131+
billingCycleEnd: billingCycle.end,
132+
});
133+
}
134+
135+
break;
136+
}
137+
115138
case 'invoice.edited':
116-
case 'invoice.issued':
117139
case 'invoice.manually_marked_as_void':
118140
case 'invoice.payment_failed':
119141
case 'invoice.issue_failed':

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,28 @@ export async function fetchAndSyncSubscription(postgresClient: PostgresClient, o
5858

5959
await syncSubscriptions(postgresClient, [subscription]);
6060
}
61+
62+
export async function updateBillingCycle(
63+
postgresClient: PostgresClient,
64+
{
65+
subscriptionId,
66+
billingCycleStart,
67+
billingCycleEnd,
68+
}: {
69+
subscriptionId: string;
70+
billingCycleStart: string;
71+
billingCycleEnd: string;
72+
}
73+
) {
74+
const isBillingCycleStartInThePast = new Date(billingCycleStart) < new Date();
75+
const isBillingCycleEndInTheFuture = new Date(billingCycleEnd) > new Date();
76+
77+
if (!isBillingCycleStartInThePast || !isBillingCycleEndInTheFuture) {
78+
console.info(
79+
`Billing cycle of subscription ${subscriptionId} is not being updated. start (${billingCycleStart}) / end (${billingCycleEnd}) not suitable`
80+
);
81+
return;
82+
}
83+
84+
return postgresClient.updateSubscriptionBillingCycle({ subscriptionId, billingCycleStart, billingCycleEnd });
85+
}

0 commit comments

Comments
 (0)