Skip to content

Commit 2090360

Browse files
committed
fix: payments
1 parent 9f81fdd commit 2090360

34 files changed

Lines changed: 2131 additions & 1142 deletions

apps/api/src/controllers/webhook.controller.ts

Lines changed: 247 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,198 @@ async function clearOrganizationCache(organizationId: string) {
121121
}
122122
}
123123

124+
type PolarEvent = ReturnType<typeof validatePolarEvent>;
125+
type PolarSubscriptionData = Extract<
126+
PolarEvent,
127+
{ type: 'subscription.updated' }
128+
>['data'];
129+
130+
const subscriptionMetadataSchema = z.object({
131+
organizationId: z.string(),
132+
// `userId` is only used for the `subscriptionCreatedByUserId` audit field, so
133+
// it is optional — a missing one must not block syncing the subscription.
134+
userId: z.string().optional(),
135+
});
136+
137+
// Org columns whose before→after transition is logged on every sync.
138+
const TRACKED_SUBSCRIPTION_FIELDS = [
139+
'subscriptionId',
140+
'subscriptionStatus',
141+
'subscriptionProductId',
142+
'subscriptionPriceId',
143+
'subscriptionStartsAt',
144+
'subscriptionEndsAt',
145+
'subscriptionCanceledAt',
146+
'subscriptionInterval',
147+
'subscriptionPeriodEventsLimit',
148+
] as const;
149+
150+
const normalizeLogValue = (value: unknown) =>
151+
value instanceof Date ? value.toISOString() : (value ?? null);
152+
153+
// Builds a `{ field: { from, to } }` map of changed columns. `undefined`
154+
// after-values are skipped — Prisma reads them as "leave the column untouched".
155+
function diffOrganizationFields(
156+
before: Record<string, unknown>,
157+
after: Record<string, unknown>,
158+
fields: readonly string[]
159+
) {
160+
const changes: Record<string, { from: unknown; to: unknown }> = {};
161+
for (const field of fields) {
162+
if (after[field] === undefined) {
163+
continue;
164+
}
165+
const from = normalizeLogValue(before[field]);
166+
const to = normalizeLogValue(after[field]);
167+
if (from !== to) {
168+
changes[field] = { from, to };
169+
}
170+
}
171+
return changes;
172+
}
173+
174+
/**
175+
* Syncs the full Polar subscription state onto the organization. Used for every
176+
* `subscription.*` event (created, active, updated, canceled, revoked,
177+
* past_due, uncanceled) since they all carry the same Subscription object and
178+
* `status` drives the rest. This covers new subscriptions, cancellations,
179+
* reactivations, plan changes and payment-state changes in one place.
180+
*/
181+
async function syncSubscriptionToOrg(
182+
data: PolarSubscriptionData,
183+
eventType: string,
184+
log: FastifyRequest['log']
185+
) {
186+
const metadata = subscriptionMetadataSchema.parse(data.metadata);
187+
const isCanceled = data.status === 'canceled';
188+
189+
const organization = await db.organization.findUniqueOrThrow({
190+
where: {
191+
id: metadata.organizationId,
192+
},
193+
});
194+
195+
// An organization maps to a single subscription in our DB, but can have
196+
// several in Polar (e.g. after re-subscribing). A canceled/revoked event for
197+
// a subscription that is no longer the org's current one must not clobber the
198+
// newer active subscription.
199+
if (isCanceled && organization.subscriptionId !== data.id) {
200+
log.info(
201+
{
202+
organizationId: metadata.organizationId,
203+
eventType,
204+
eventSubscriptionId: data.id,
205+
orgSubscriptionId: organization.subscriptionId,
206+
},
207+
'polar webhook: ignoring canceled event for non-current subscription'
208+
);
209+
return;
210+
}
211+
212+
// Polar can deliver subscription events out of order: at renewal the new
213+
// period can arrive first, then a stale event for the *previous* period lands
214+
// a few seconds later. A billing period never moves backwards, so for the
215+
// same subscription we ignore any event whose period starts before the one we
216+
// already stored — otherwise the stale event resets the org to the expired
217+
// period and the usage counter (recomputed over that window) gets stuck.
218+
if (
219+
organization.subscriptionId === data.id &&
220+
organization.subscriptionStartsAt &&
221+
data.currentPeriodStart < organization.subscriptionStartsAt
222+
) {
223+
log.info(
224+
{
225+
organizationId: metadata.organizationId,
226+
eventType,
227+
eventSubscriptionId: data.id,
228+
eventPeriodStart: data.currentPeriodStart,
229+
storedPeriodStart: organization.subscriptionStartsAt,
230+
},
231+
'polar webhook: ignoring stale subscription event (older billing period)'
232+
);
233+
return;
234+
}
235+
236+
const product = await getProduct(data.productId);
237+
const rawEventsLimit = product.metadata?.eventsLimit;
238+
const parsedEventsLimit =
239+
typeof rawEventsLimit === 'number'
240+
? rawEventsLimit
241+
: typeof rawEventsLimit === 'string'
242+
? Number(rawEventsLimit)
243+
: Number.NaN;
244+
const hasValidEventsLimit = Number.isFinite(parsedEventsLimit);
245+
const subscriptionPeriodEventsLimit = hasValidEventsLimit
246+
? parsedEventsLimit
247+
: organization.subscriptionPeriodEventsLimit;
248+
249+
if (!hasValidEventsLimit) {
250+
log.warn(
251+
{ product },
252+
'No valid eventsLimit on product, preserving existing organization limit'
253+
);
254+
}
255+
256+
const updateData = {
257+
subscriptionId: data.id,
258+
subscriptionCustomerId: data.customer.id,
259+
subscriptionPriceId: data.prices[0]?.id ?? null,
260+
subscriptionProductId: data.productId,
261+
subscriptionStatus: data.status,
262+
subscriptionStartsAt: data.currentPeriodStart,
263+
subscriptionCanceledAt: data.canceledAt,
264+
subscriptionEndsAt: isCanceled
265+
? data.cancelAtPeriodEnd
266+
? data.currentPeriodEnd
267+
: data.canceledAt
268+
: data.currentPeriodEnd,
269+
subscriptionCreatedByUserId:
270+
metadata.userId ?? organization.subscriptionCreatedByUserId,
271+
subscriptionInterval: data.recurringInterval,
272+
subscriptionPeriodEventsLimit,
273+
subscriptionPeriodEventsCountExceededAt:
274+
typeof subscriptionPeriodEventsLimit === 'number' &&
275+
organization.subscriptionPeriodEventsCountExceededAt &&
276+
typeof organization.subscriptionPeriodEventsLimit === 'number' &&
277+
organization.subscriptionPeriodEventsLimit < subscriptionPeriodEventsLimit
278+
? null
279+
: undefined,
280+
};
281+
282+
const changes = diffOrganizationFields(
283+
organization as unknown as Record<string, unknown>,
284+
updateData as unknown as Record<string, unknown>,
285+
TRACKED_SUBSCRIPTION_FIELDS
286+
);
287+
288+
await db.organization.update({
289+
where: {
290+
id: metadata.organizationId,
291+
},
292+
data: updateData,
293+
});
294+
295+
await clearOrganizationCache(metadata.organizationId);
296+
297+
await publishEvent('organization', 'subscription_updated', {
298+
organizationId: metadata.organizationId,
299+
});
300+
301+
log.info(
302+
{
303+
organizationId: metadata.organizationId,
304+
eventType,
305+
subscriptionId: data.id,
306+
previousStatus: organization.subscriptionStatus,
307+
status: data.status,
308+
changes,
309+
},
310+
Object.keys(changes).length > 0
311+
? `polar webhook: synced subscription for ${metadata.organizationId} (${Object.keys(changes).join(', ')} changed)`
312+
: `polar webhook: synced subscription for ${metadata.organizationId} (no field changes)`
313+
);
314+
}
315+
124316
export async function polarWebhook(
125317
request: FastifyRequest<{
126318
Querystring: unknown;
@@ -147,11 +339,25 @@ export async function polarWebhook(
147339

148340
const event = validation.data;
149341

342+
const eventOrganizationId =
343+
'metadata' in event.data &&
344+
event.data.metadata &&
345+
typeof event.data.metadata === 'object' &&
346+
'organizationId' in event.data.metadata
347+
? String(event.data.metadata.organizationId)
348+
: undefined;
349+
150350
const eventCtx = {
151351
eventType: event.type,
152352
eventId: 'id' in event.data ? event.data.id : undefined,
353+
organizationId: eventOrganizationId,
153354
};
154355

356+
request.log.info(
357+
eventCtx,
358+
`polar webhook: processing ${event.type}${eventOrganizationId ? ` for ${eventOrganizationId}` : ''}`
359+
);
360+
155361
if (
156362
'data' in event &&
157363
'product' in event.data &&
@@ -163,118 +369,66 @@ export async function polarWebhook(
163369

164370
const handler = await tryCatch(async () => {
165371
switch (event.type) {
166-
case 'order.created': {
167-
const metadata = z
168-
.object({
169-
organizationId: z.string(),
170-
})
171-
.parse(event.data.metadata);
172-
173-
if (event.data.billingReason === 'subscription_cycle') {
174-
await db.organization.update({
175-
where: {
176-
id: metadata.organizationId,
177-
},
178-
data: {
179-
subscriptionPeriodEventsCount: 0,
180-
subscriptionPeriodEventsCountExceededAt: null,
181-
},
182-
});
183-
184-
await clearOrganizationCache(metadata.organizationId);
372+
// A new paid billing cycle resets the org's usage counter. Polar sends
373+
// `order.updated` (not `order.created`) and the order moves through
374+
// pending -> paid, so we only act on the `paid` + `subscription_cycle`
375+
// transition. Re-deliveries of the same paid order just reset to 0 again,
376+
// which is harmless (and safely under-counts at worst).
377+
case 'order.updated': {
378+
if (
379+
event.data.billingReason !== 'subscription_cycle' ||
380+
event.data.status !== 'paid'
381+
) {
382+
request.log.info(
383+
{ ...eventCtx, billingReason: event.data.billingReason },
384+
'polar webhook: order.updated ignored (not a paid billing cycle)'
385+
);
386+
return;
185387
}
186-
return;
187-
}
188-
case 'subscription.updated': {
388+
189389
const metadata = z
190390
.object({
191391
organizationId: z.string(),
192-
userId: z.string(),
193392
})
194393
.parse(event.data.metadata);
195394

196-
const product = await getProduct(event.data.productId);
197-
const organization = await db.organization.findUniqueOrThrow({
198-
where: {
199-
id: metadata.organizationId,
200-
},
395+
const previous = await db.organization.findUnique({
396+
where: { id: metadata.organizationId },
397+
select: { subscriptionPeriodEventsCount: true },
201398
});
202-
const rawEventsLimit = product.metadata?.eventsLimit;
203-
const parsedEventsLimit =
204-
typeof rawEventsLimit === 'number'
205-
? rawEventsLimit
206-
: typeof rawEventsLimit === 'string'
207-
? Number(rawEventsLimit)
208-
: Number.NaN;
209-
const hasValidEventsLimit = Number.isFinite(parsedEventsLimit);
210-
const subscriptionPeriodEventsLimit = hasValidEventsLimit
211-
? parsedEventsLimit
212-
: organization.subscriptionPeriodEventsLimit;
213-
214-
if (!hasValidEventsLimit) {
215-
request.log.warn(
216-
{ product },
217-
'No valid eventsLimit on product, preserving existing organization limit'
218-
);
219-
}
220-
// If we get a cancel event and we cant find it we should ignore it
221-
// Since we only have one subscription per organization but you can have several in polar
222-
// we dont want to override the existing subscription with a canceled one
223-
// TODO: might be other events that we should handle like this?!
224-
if (event.data.status === 'canceled') {
225-
const orgSubscription = await db.organization.findFirst({
226-
where: {
227-
subscriptionCustomerId: event.data.customer.id,
228-
subscriptionId: event.data.id,
229-
subscriptionStatus: {
230-
in: ['active', 'past_due', 'unpaid'],
231-
},
232-
},
233-
});
234-
235-
if (!orgSubscription) {
236-
return;
237-
}
238-
}
239399

240400
await db.organization.update({
241401
where: {
242402
id: metadata.organizationId,
243403
},
244404
data: {
245-
subscriptionId: event.data.id,
246-
subscriptionCustomerId: event.data.customer.id,
247-
subscriptionPriceId: event.data.prices[0]?.id ?? null,
248-
subscriptionProductId: event.data.productId,
249-
subscriptionStatus: event.data.status,
250-
subscriptionStartsAt: event.data.currentPeriodStart,
251-
subscriptionCanceledAt: event.data.canceledAt,
252-
subscriptionEndsAt:
253-
event.data.status === 'canceled'
254-
? event.data.cancelAtPeriodEnd
255-
? event.data.currentPeriodEnd
256-
: event.data.canceledAt
257-
: event.data.currentPeriodEnd,
258-
subscriptionCreatedByUserId: metadata.userId,
259-
subscriptionInterval: event.data.recurringInterval,
260-
subscriptionPeriodEventsLimit,
261-
subscriptionPeriodEventsCountExceededAt:
262-
typeof subscriptionPeriodEventsLimit === 'number' &&
263-
organization.subscriptionPeriodEventsCountExceededAt &&
264-
typeof organization.subscriptionPeriodEventsLimit === 'number' &&
265-
organization.subscriptionPeriodEventsLimit <
266-
subscriptionPeriodEventsLimit
267-
? null
268-
: undefined,
405+
subscriptionPeriodEventsCount: 0,
406+
subscriptionPeriodEventsCountExceededAt: null,
269407
},
270408
});
271409

272410
await clearOrganizationCache(metadata.organizationId);
273411

274-
await publishEvent('organization', 'subscription_updated', {
275-
organizationId: metadata.organizationId,
276-
});
277-
412+
request.log.info(
413+
{
414+
...eventCtx,
415+
previousEventsCount: previous?.subscriptionPeriodEventsCount,
416+
},
417+
`polar webhook: new billing cycle for ${metadata.organizationId}, reset usage counter ${previous?.subscriptionPeriodEventsCount ?? 0} -> 0`
418+
);
419+
return;
420+
}
421+
// All subscription lifecycle events carry the same Subscription object;
422+
// sync them through a single path (new subs, cancellations, revokes,
423+
// reactivations, plan changes, payment-state changes).
424+
case 'subscription.created':
425+
case 'subscription.active':
426+
case 'subscription.updated':
427+
case 'subscription.uncanceled':
428+
case 'subscription.canceled':
429+
case 'subscription.revoked':
430+
case 'subscription.past_due': {
431+
await syncSubscriptionToOrg(event.data, event.type, request.log);
278432
return;
279433
}
280434
default: {

0 commit comments

Comments
 (0)