@billfn/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/errors.d.ts +3 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +56 -3
- package/dist/schema.js.map +1 -1
- package/dist/service.d.ts +19 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +2051 -267
- package/dist/service.js.map +1 -1
- package/dist/types.d.ts +158 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/service.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { DuplicateKeyError } from '@superfunctions/db';
|
|
2
3
|
import { ok } from '@superfunctions/envelope';
|
|
3
4
|
import { createNamespacedEmitter, createMetricsEmitter } from '@superfunctions/metrics';
|
|
@@ -15,11 +16,41 @@ const TABLES = {
|
|
|
15
16
|
refunds: 'refunds',
|
|
16
17
|
subscriptionChangeRequests: 'subscriptionChangeRequests',
|
|
17
18
|
reconciliationJobs: 'reconciliationJobs',
|
|
18
|
-
reconciliationCursors: 'reconciliationCursors'
|
|
19
|
+
reconciliationCursors: 'reconciliationCursors',
|
|
20
|
+
unlinkedWebhookEvents: 'unlinkedWebhookEvents',
|
|
21
|
+
replacedProviderReferences: 'replacedProviderReferences'
|
|
19
22
|
};
|
|
20
23
|
const PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY = 'billfnProviderEventOccurredAt';
|
|
24
|
+
/** When the committed provider mutation that set the provider-state time started; it opens that mutation's window. */
|
|
25
|
+
const PROVIDER_MUTATION_STARTED_AT_METADATA_KEY = 'billfnProviderMutationStartedAt';
|
|
26
|
+
/** References a committed mutation's write dropped but could not record as replaced (`replacedReferencesPending`). */
|
|
27
|
+
const PENDING_REPLACED_REFERENCES_METADATA_KEY = 'billfnPendingReplacedReferences';
|
|
21
28
|
const WEBHOOK_RECEIPT_CLAIM_LEASE_MS = 15 * 60 * 1000;
|
|
22
29
|
const RECONCILIATION_JOB_CLAIM_LEASE_MS = 15 * 60 * 1000;
|
|
30
|
+
/** Unlinked webhook events are kept this long for a later link to replay them. */
|
|
31
|
+
const UNLINKED_WEBHOOK_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
32
|
+
const UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS = 15 * 60 * 1000;
|
|
33
|
+
/** Bounds the events one replay run applies; the rest are left for the retried operation's next run. */
|
|
34
|
+
const UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS = 500;
|
|
35
|
+
/** Provider metadata key naming the billing account that owns a provider record. */
|
|
36
|
+
const BILLING_ACCOUNT_METADATA_KEY = 'billfn_billing_account_id';
|
|
37
|
+
/**
|
|
38
|
+
* Subscription metadata key set when an operator imported the subscription:
|
|
39
|
+
* when it was first imported. Its binding to the billing account is the
|
|
40
|
+
* operator's, so a provider adapter does not expect the BillFn ownership
|
|
41
|
+
* metadata a subscription created outside BillFn lacks.
|
|
42
|
+
*/
|
|
43
|
+
export const BILLFN_IMPORTED_AT_METADATA_KEY = 'billfnImportedAt';
|
|
44
|
+
/**
|
|
45
|
+
* Subscription metadata key set on the row an import creates until its
|
|
46
|
+
* binding is accepted (`settleImportBinding`). A pending row is not usable:
|
|
47
|
+
* webhooks and replays leave events for it unlinked, other writes refuse it
|
|
48
|
+
* (retryably) and subject, account and sync lookups skip it. A refusal deletes
|
|
49
|
+
* it, on the import's first run or on any rerun, so it never becomes durable.
|
|
50
|
+
*/
|
|
51
|
+
export const BILLFN_IMPORT_PENDING_METADATA_KEY = 'billfnImportPending';
|
|
52
|
+
/** `details.reason` of the retryable `BILLFN_CONFLICT` an import raises when its replay did not finish. */
|
|
53
|
+
export const BILLFN_IMPORT_REPLAY_UNFINISHED_REASON = 'import_replay_unfinished';
|
|
23
54
|
/** Deliveries of one queue message refused by lease conflicts before it is dead-lettered. */
|
|
24
55
|
const RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS = 5;
|
|
25
56
|
/**
|
|
@@ -64,6 +95,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
64
95
|
metrics,
|
|
65
96
|
queue: config.queue
|
|
66
97
|
};
|
|
98
|
+
deps.reconcileProviderState = (subscription, event) => reconcileWindowEvent(subscription, event);
|
|
67
99
|
const subscriptionProvider = {
|
|
68
100
|
getActiveSubscription: async (subject) => {
|
|
69
101
|
const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
|
|
@@ -145,7 +177,8 @@ function createBillFnServiceWithInternals(config) {
|
|
|
145
177
|
};
|
|
146
178
|
}
|
|
147
179
|
};
|
|
148
|
-
|
|
180
|
+
/** Reads the subscription from the provider (see `fetchLiveSubscription`). */
|
|
181
|
+
async function readProviderSubscription(subscriptionId, candidate) {
|
|
149
182
|
const subscription = await getSubscriptionById(deps, subscriptionId);
|
|
150
183
|
if (!subscription) {
|
|
151
184
|
throw createBillFnError({
|
|
@@ -160,26 +193,162 @@ function createBillFnServiceWithInternals(config) {
|
|
|
160
193
|
message: 'Billing account not found'
|
|
161
194
|
});
|
|
162
195
|
}
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const provider = requireProvider(deps, subscription.provider, 'fetchSubscription');
|
|
166
|
-
const verification = await provider.fetchSubscription({
|
|
167
|
-
subscription,
|
|
168
|
-
plan,
|
|
169
|
-
price
|
|
170
|
-
});
|
|
171
|
-
if (!verification) {
|
|
196
|
+
const live = await fetchLiveSubscription(deps, subscription, candidate);
|
|
197
|
+
if (!live) {
|
|
172
198
|
throw createBillFnError({
|
|
173
199
|
code: 'BILLFN_NOT_FOUND',
|
|
174
200
|
message: 'Provider did not return subscription state'
|
|
175
201
|
});
|
|
176
202
|
}
|
|
177
|
-
return
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
203
|
+
return { billingAccount, subscription, ...live };
|
|
204
|
+
}
|
|
205
|
+
async function syncSubscriptionById(subscriptionId) {
|
|
206
|
+
return applyLiveProviderState(deps, await readProviderSubscription(subscriptionId));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Reconciles an event inside a committed mutation's window with a provider
|
|
210
|
+
* read for the committed plan and price, or for the event's price when that
|
|
211
|
+
* fails and the live record confirms it (`fetchLiveSubscription`). When no
|
|
212
|
+
* read succeeds, the committed state stands: with a queue a subscription-sync
|
|
213
|
+
* job carrying the event's price is enqueued, otherwise a window-read error is
|
|
214
|
+
* thrown so the delivery, job or replay trigger is retried. The window stays
|
|
215
|
+
* open until the read's entitlements and its lifecycle entry, keyed by the
|
|
216
|
+
* event, are written, so a retry after a failure there reconciles again.
|
|
217
|
+
*/
|
|
218
|
+
async function reconcileWindowEvent(subscription, event) {
|
|
219
|
+
let read;
|
|
220
|
+
try {
|
|
221
|
+
read = await readProviderSubscription(subscription.id, event.selection);
|
|
222
|
+
}
|
|
223
|
+
catch (error_) {
|
|
224
|
+
deps.metrics.track('subscription.mutation.window_event_unreconciled', {
|
|
225
|
+
provider: subscription.provider,
|
|
226
|
+
subscriptionId: subscription.id,
|
|
227
|
+
reason: 'read_failed',
|
|
228
|
+
error: error_ instanceof Error ? error_.message : String(error_)
|
|
229
|
+
});
|
|
230
|
+
if (!deps.queue) {
|
|
231
|
+
throw windowReadFailed(error_);
|
|
232
|
+
}
|
|
233
|
+
await scheduleSubscriptionSync(subscription, event.selection);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await applyVerificationState(deps, {
|
|
237
|
+
...read,
|
|
238
|
+
keepMutationWindow: true,
|
|
239
|
+
providerEventId: event.providerEventId ? `${event.providerEventId}#window-read` : undefined
|
|
240
|
+
});
|
|
241
|
+
await closeMutationWindow(deps, subscription.id, readMutationStartedAt(subscription));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Applies the provider state a committed provider mutation (cancel, change,
|
|
245
|
+
* resume, refund) returned. `committedAt` is taken as soon as the provider
|
|
246
|
+
* call returns, before any other write.
|
|
247
|
+
* Retained events that cannot be replayed now do not fail the mutation; with a
|
|
248
|
+
* queue and a provider that supports sync, a subscription-sync job is enqueued
|
|
249
|
+
* to replay them. When a provider event was applied while the mutation was in
|
|
250
|
+
* flight, the subscription is reconciled with an authoritative provider read.
|
|
251
|
+
*/
|
|
252
|
+
async function applyMutationProviderState(input) {
|
|
253
|
+
const projection = await applyLiveProviderState(deps, input, { onReplayDeferred: scheduleSubscriptionSync });
|
|
254
|
+
if (!projection.providerStateRaced) {
|
|
255
|
+
return projection;
|
|
256
|
+
}
|
|
257
|
+
deps.metrics.track('subscription.mutation.raced_event', {
|
|
258
|
+
provider: projection.subscription.provider,
|
|
259
|
+
subscriptionId: projection.subscription.id
|
|
260
|
+
});
|
|
261
|
+
return (await reconcileCommittedMutation(projection.subscription, projection.providerStateRaced.priceId)) ?? projection;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Reads the subscription from the provider after a mutation an event raced:
|
|
265
|
+
* a queued sync job when a queue is configured, otherwise an inline sync. It
|
|
266
|
+
* never repeats the mutation and never fails it. Returns the inline result.
|
|
267
|
+
* The price the raced event wrote is the read's candidate (`fetchLiveSubscription`).
|
|
268
|
+
*/
|
|
269
|
+
async function reconcileCommittedMutation(subscription, racedPriceId) {
|
|
270
|
+
const candidate = findCatalogPriceReference(deps.catalog, subscription.provider, [racedPriceId]) ?? undefined;
|
|
271
|
+
try {
|
|
272
|
+
if (deps.queue) {
|
|
273
|
+
await scheduleSubscriptionSync(subscription, candidate);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if (typeof deps.providers[subscription.provider]?.fetchSubscription !== 'function') {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
return await applyLiveProviderState(deps, await readProviderSubscription(subscription.id, candidate));
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
deps.metrics.track('subscription.mutation.reconcile_failed', {
|
|
283
|
+
provider: subscription.provider,
|
|
284
|
+
subscriptionId: subscription.id,
|
|
285
|
+
error: error instanceof Error ? error.message : String(error)
|
|
286
|
+
});
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Loads the caller's subscription, plan and price for a subscription mutation. */
|
|
291
|
+
async function loadMutationSubscription(input) {
|
|
292
|
+
const subject = requireSubject(input.subject);
|
|
293
|
+
const { billingAccount } = await getOrCreateBillingAccount(deps, subject);
|
|
294
|
+
const subscription = await getSubscriptionForAccount(deps, billingAccount.id, input.subscriptionId);
|
|
295
|
+
if (!subscription) {
|
|
296
|
+
throw createBillFnError({
|
|
297
|
+
code: 'BILLFN_NOT_FOUND',
|
|
298
|
+
message: 'Subscription not found'
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
302
|
+
const price = requirePriceById(plan, subscription.priceId);
|
|
303
|
+
return { billingAccount, subscription, plan, price };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Completes a committed cancel, change or resume: applies the state the
|
|
307
|
+
* provider returned (or keeps the stored subscription and entitlements when it
|
|
308
|
+
* returned none) and records the requested billing event.
|
|
309
|
+
*/
|
|
310
|
+
async function finishSubscriptionMutation(input) {
|
|
311
|
+
const { billingAccount, subscription, operation } = input;
|
|
312
|
+
const projection = operation.billingState
|
|
313
|
+
? await applyMutationProviderState({
|
|
314
|
+
billingAccount,
|
|
315
|
+
plan: input.plan,
|
|
316
|
+
price: input.price,
|
|
317
|
+
subscription,
|
|
318
|
+
state: operation.billingState,
|
|
319
|
+
liveStateObservedAt: input.liveStateObservedAt,
|
|
320
|
+
committedAt: input.timestamp
|
|
321
|
+
})
|
|
322
|
+
: {
|
|
323
|
+
subscription,
|
|
324
|
+
entitlements: (await findEntitlementSnapshot(deps, billingAccount.id)) ??
|
|
325
|
+
(await upsertEntitlements(deps, billingAccount.id, input.entitlementPlan, subscription, input.timestamp))
|
|
326
|
+
};
|
|
327
|
+
await recordBillingEvent(deps, {
|
|
328
|
+
id: deps.generateId('evt'),
|
|
329
|
+
billingAccountId: billingAccount.id,
|
|
330
|
+
type: input.eventType,
|
|
331
|
+
payload: { subscriptionId: subscription.id, ...input.eventPayload, operationStatus: operation.operationStatus },
|
|
332
|
+
createdAt: input.timestamp
|
|
333
|
+
});
|
|
334
|
+
return {
|
|
335
|
+
subscription: projection.subscription,
|
|
336
|
+
entitlements: projection.entitlements,
|
|
337
|
+
operationStatus: operation.operationStatus,
|
|
338
|
+
clientAction: operation.clientAction
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** `candidate` is the catalog price an unreconciled window event names; the job reads it if the stored one fails. */
|
|
342
|
+
async function scheduleSubscriptionSync(subscription, candidate) {
|
|
343
|
+
if (!deps.queue || typeof deps.providers[subscription.provider]?.fetchSubscription !== 'function') {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
await enqueueReconciliationJobInternal({
|
|
347
|
+
kind: 'subscription-sync',
|
|
348
|
+
provider: subscription.provider,
|
|
349
|
+
billingAccountId: subscription.billingAccountId,
|
|
350
|
+
subscriptionId: subscription.id,
|
|
351
|
+
payload: candidate ? { candidatePriceId: candidate.price.priceId } : undefined
|
|
183
352
|
});
|
|
184
353
|
}
|
|
185
354
|
async function createReconciliationJobRecord(input) {
|
|
@@ -418,6 +587,8 @@ function createBillFnServiceWithInternals(config) {
|
|
|
418
587
|
const plan = requirePlan(deps.catalog, checkoutSession.planKey);
|
|
419
588
|
const price = requirePriceById(plan, checkoutSession.priceId);
|
|
420
589
|
const provider = requireProvider(deps, checkoutSession.provider, 'verifyCheckout');
|
|
590
|
+
// Events that occurred before this live read are already reflected in its state.
|
|
591
|
+
const verificationObservedAt = toIsoString(deps.now());
|
|
421
592
|
const verification = await provider.verifyCheckout({
|
|
422
593
|
checkoutSession,
|
|
423
594
|
billingAccount,
|
|
@@ -425,12 +596,13 @@ function createBillFnServiceWithInternals(config) {
|
|
|
425
596
|
price,
|
|
426
597
|
payload: input.payload
|
|
427
598
|
});
|
|
428
|
-
const result = await
|
|
599
|
+
const result = await applyLiveProviderState(deps, {
|
|
429
600
|
billingAccount,
|
|
430
601
|
plan,
|
|
431
602
|
price,
|
|
432
603
|
checkoutSession,
|
|
433
|
-
state: verification
|
|
604
|
+
state: verification,
|
|
605
|
+
liveStateObservedAt: verificationObservedAt
|
|
434
606
|
});
|
|
435
607
|
if (!result.checkoutSession) {
|
|
436
608
|
throw createBillFnError({
|
|
@@ -445,18 +617,9 @@ function createBillFnServiceWithInternals(config) {
|
|
|
445
617
|
});
|
|
446
618
|
},
|
|
447
619
|
async cancelSubscription(input) {
|
|
448
|
-
const
|
|
449
|
-
const { billingAccount } = await getOrCreateBillingAccount(deps, subject);
|
|
450
|
-
const subscription = await getSubscriptionForAccount(deps, billingAccount.id, input.subscriptionId);
|
|
451
|
-
if (!subscription) {
|
|
452
|
-
throw createBillFnError({
|
|
453
|
-
code: 'BILLFN_NOT_FOUND',
|
|
454
|
-
message: 'Subscription not found'
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
458
|
-
const price = requirePriceById(plan, subscription.priceId);
|
|
620
|
+
const { billingAccount, subscription, plan, price } = await loadMutationSubscription(input);
|
|
459
621
|
const provider = requireProvider(deps, subscription.provider, 'cancelSubscription');
|
|
622
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
460
623
|
const operation = (await provider.cancelSubscription({
|
|
461
624
|
subscription,
|
|
462
625
|
reason: input.reason
|
|
@@ -472,48 +635,22 @@ function createBillFnServiceWithInternals(config) {
|
|
|
472
635
|
currentPeriodEnd: subscription.currentPeriodEnd
|
|
473
636
|
}
|
|
474
637
|
};
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
id: deps.generateId('evt'),
|
|
489
|
-
billingAccountId: billingAccount.id,
|
|
490
|
-
type: 'billfn.subscription.cancel.requested',
|
|
491
|
-
payload: {
|
|
492
|
-
subscriptionId: subscription.id,
|
|
493
|
-
operationStatus: operation.operationStatus,
|
|
494
|
-
reason: input.reason
|
|
495
|
-
},
|
|
496
|
-
createdAt: toIsoString(deps.now())
|
|
497
|
-
});
|
|
498
|
-
return ok({
|
|
499
|
-
subscription: projection.subscription,
|
|
500
|
-
entitlements: projection.entitlements,
|
|
501
|
-
operationStatus: operation.operationStatus,
|
|
502
|
-
clientAction: operation.clientAction
|
|
503
|
-
});
|
|
638
|
+
const committedAt = toIsoString(deps.now());
|
|
639
|
+
return ok(await finishSubscriptionMutation({
|
|
640
|
+
billingAccount,
|
|
641
|
+
subscription,
|
|
642
|
+
plan,
|
|
643
|
+
price,
|
|
644
|
+
entitlementPlan: plan,
|
|
645
|
+
operation,
|
|
646
|
+
liveStateObservedAt,
|
|
647
|
+
timestamp: committedAt,
|
|
648
|
+
eventType: 'billfn.subscription.cancel.requested',
|
|
649
|
+
eventPayload: { reason: input.reason }
|
|
650
|
+
}));
|
|
504
651
|
},
|
|
505
652
|
async changeSubscription(input) {
|
|
506
|
-
const
|
|
507
|
-
const { billingAccount } = await getOrCreateBillingAccount(deps, subject);
|
|
508
|
-
const subscription = await getSubscriptionForAccount(deps, billingAccount.id, input.subscriptionId);
|
|
509
|
-
if (!subscription) {
|
|
510
|
-
throw createBillFnError({
|
|
511
|
-
code: 'BILLFN_NOT_FOUND',
|
|
512
|
-
message: 'Subscription not found'
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
const currentPlan = requirePlan(deps.catalog, subscription.planKey);
|
|
516
|
-
const currentPrice = requirePriceById(currentPlan, subscription.priceId);
|
|
653
|
+
const { billingAccount, subscription, plan: currentPlan, price: currentPrice } = await loadMutationSubscription(input);
|
|
517
654
|
const target = requirePriceReference(deps.catalog, input.targetPriceId);
|
|
518
655
|
if (target.price.provider !== subscription.provider) {
|
|
519
656
|
throw createBillFnError({
|
|
@@ -529,6 +666,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
529
666
|
const provider = requireProvider(deps, subscription.provider, 'changeSubscription');
|
|
530
667
|
const effectiveAt = input.effectiveAt ?? 'immediate';
|
|
531
668
|
const prorationBehavior = input.prorationBehavior ?? 'provider_default';
|
|
669
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
532
670
|
const operation = await provider.changeSubscription({
|
|
533
671
|
subscription,
|
|
534
672
|
currentPlan,
|
|
@@ -556,82 +694,40 @@ function createBillFnServiceWithInternals(config) {
|
|
|
556
694
|
updatedAt: timestamp
|
|
557
695
|
});
|
|
558
696
|
const appliesTargetImmediately = operation.operationStatus === 'applied' && effectiveAt === 'immediate';
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
};
|
|
571
|
-
await recordBillingEvent(deps, {
|
|
572
|
-
id: deps.generateId('evt'),
|
|
573
|
-
billingAccountId: billingAccount.id,
|
|
574
|
-
type: 'billfn.subscription.change.requested',
|
|
575
|
-
payload: {
|
|
576
|
-
subscriptionId: subscription.id,
|
|
577
|
-
currentPriceId: currentPrice.priceId,
|
|
578
|
-
targetPriceId: target.price.priceId,
|
|
579
|
-
operationStatus: operation.operationStatus
|
|
580
|
-
},
|
|
581
|
-
createdAt: timestamp
|
|
582
|
-
});
|
|
583
|
-
return ok({
|
|
584
|
-
subscription: projection.subscription,
|
|
585
|
-
entitlements: projection.entitlements,
|
|
586
|
-
operationStatus: operation.operationStatus,
|
|
587
|
-
clientAction: operation.clientAction,
|
|
588
|
-
changeRequest
|
|
697
|
+
const result = await finishSubscriptionMutation({
|
|
698
|
+
billingAccount,
|
|
699
|
+
subscription,
|
|
700
|
+
plan: appliesTargetImmediately ? target.plan : currentPlan,
|
|
701
|
+
price: appliesTargetImmediately ? target.price : currentPrice,
|
|
702
|
+
entitlementPlan: currentPlan,
|
|
703
|
+
operation,
|
|
704
|
+
liveStateObservedAt,
|
|
705
|
+
timestamp,
|
|
706
|
+
eventType: 'billfn.subscription.change.requested',
|
|
707
|
+
eventPayload: { currentPriceId: currentPrice.priceId, targetPriceId: target.price.priceId }
|
|
589
708
|
});
|
|
709
|
+
return ok({ ...result, changeRequest });
|
|
590
710
|
},
|
|
591
711
|
async resumeSubscription(input) {
|
|
592
|
-
const
|
|
593
|
-
const { billingAccount } = await getOrCreateBillingAccount(deps, subject);
|
|
594
|
-
const subscription = await getSubscriptionForAccount(deps, billingAccount.id, input.subscriptionId);
|
|
595
|
-
if (!subscription) {
|
|
596
|
-
throw createBillFnError({
|
|
597
|
-
code: 'BILLFN_NOT_FOUND',
|
|
598
|
-
message: 'Subscription not found'
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
602
|
-
const price = requirePriceById(plan, subscription.priceId);
|
|
712
|
+
const { billingAccount, subscription, plan, price } = await loadMutationSubscription(input);
|
|
603
713
|
const provider = requireProvider(deps, subscription.provider, 'resumeSubscription');
|
|
714
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
604
715
|
const operation = await provider.resumeSubscription({
|
|
605
716
|
subscription
|
|
606
717
|
});
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
id: deps.generateId('evt'),
|
|
621
|
-
billingAccountId: billingAccount.id,
|
|
622
|
-
type: 'billfn.subscription.resume.requested',
|
|
623
|
-
payload: {
|
|
624
|
-
subscriptionId: subscription.id,
|
|
625
|
-
operationStatus: operation.operationStatus
|
|
626
|
-
},
|
|
627
|
-
createdAt: toIsoString(deps.now())
|
|
628
|
-
});
|
|
629
|
-
return ok({
|
|
630
|
-
subscription: projection.subscription,
|
|
631
|
-
entitlements: projection.entitlements,
|
|
632
|
-
operationStatus: operation.operationStatus,
|
|
633
|
-
clientAction: operation.clientAction
|
|
634
|
-
});
|
|
718
|
+
const committedAt = toIsoString(deps.now());
|
|
719
|
+
return ok(await finishSubscriptionMutation({
|
|
720
|
+
billingAccount,
|
|
721
|
+
subscription,
|
|
722
|
+
plan,
|
|
723
|
+
price,
|
|
724
|
+
entitlementPlan: plan,
|
|
725
|
+
operation,
|
|
726
|
+
liveStateObservedAt,
|
|
727
|
+
timestamp: committedAt,
|
|
728
|
+
eventType: 'billfn.subscription.resume.requested',
|
|
729
|
+
eventPayload: {}
|
|
730
|
+
}));
|
|
635
731
|
},
|
|
636
732
|
async refundCharge(input) {
|
|
637
733
|
const subject = requireSubject(input.subject);
|
|
@@ -655,6 +751,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
655
751
|
const provider = requireProvider(deps, providerName, 'refundCharge');
|
|
656
752
|
const mode = input.mode ?? 'full';
|
|
657
753
|
const providerChargeId = input.providerChargeId ?? subscription.providerChargeId;
|
|
754
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
658
755
|
const operation = await provider.refundCharge({
|
|
659
756
|
subscription,
|
|
660
757
|
billingAccount,
|
|
@@ -685,12 +782,14 @@ function createBillFnServiceWithInternals(config) {
|
|
|
685
782
|
if (subscription && operation.billingState) {
|
|
686
783
|
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
687
784
|
const price = requirePriceById(plan, subscription.priceId);
|
|
688
|
-
const projection = await
|
|
785
|
+
const projection = await applyMutationProviderState({
|
|
689
786
|
billingAccount,
|
|
690
787
|
plan,
|
|
691
788
|
price,
|
|
692
789
|
subscription,
|
|
693
|
-
state: operation.billingState
|
|
790
|
+
state: operation.billingState,
|
|
791
|
+
liveStateObservedAt,
|
|
792
|
+
committedAt: timestamp
|
|
694
793
|
});
|
|
695
794
|
projectedSubscription = projection.subscription;
|
|
696
795
|
projectedEntitlements = projection.entitlements;
|
|
@@ -757,6 +856,8 @@ function createBillFnServiceWithInternals(config) {
|
|
|
757
856
|
});
|
|
758
857
|
}
|
|
759
858
|
const provider = requireProvider(deps, input.provider, 'restorePurchases');
|
|
859
|
+
// Events that occurred before this live read are already reflected in its state.
|
|
860
|
+
const restoreObservedAt = toIsoString(deps.now());
|
|
760
861
|
const states = await provider.restorePurchases({
|
|
761
862
|
billingAccount,
|
|
762
863
|
plan,
|
|
@@ -777,11 +878,12 @@ function createBillFnServiceWithInternals(config) {
|
|
|
777
878
|
checkoutReferenceId: input.purchaseReference
|
|
778
879
|
});
|
|
779
880
|
if (!projection) {
|
|
780
|
-
const applied = await
|
|
881
|
+
const applied = await applyLiveProviderState(deps, {
|
|
781
882
|
billingAccount,
|
|
782
883
|
plan,
|
|
783
884
|
price,
|
|
784
|
-
state
|
|
885
|
+
state,
|
|
886
|
+
liveStateObservedAt: restoreObservedAt
|
|
785
887
|
});
|
|
786
888
|
const response = {
|
|
787
889
|
subscription: applied.subscription,
|
|
@@ -792,13 +894,14 @@ function createBillFnServiceWithInternals(config) {
|
|
|
792
894
|
if (projection.billingAccount.id !== billingAccount.id) {
|
|
793
895
|
continue;
|
|
794
896
|
}
|
|
795
|
-
const applied = await
|
|
897
|
+
const applied = await applyLiveProviderState(deps, {
|
|
796
898
|
billingAccount: projection.billingAccount,
|
|
797
899
|
plan: projection.plan,
|
|
798
900
|
price: projection.price,
|
|
799
901
|
checkoutSession: projection.checkoutSession ?? undefined,
|
|
800
902
|
subscription: projection.subscription ?? undefined,
|
|
801
|
-
state
|
|
903
|
+
state,
|
|
904
|
+
liveStateObservedAt: restoreObservedAt
|
|
802
905
|
});
|
|
803
906
|
const response = {
|
|
804
907
|
subscription: applied.subscription,
|
|
@@ -811,6 +914,142 @@ function createBillFnServiceWithInternals(config) {
|
|
|
811
914
|
message: 'No restored purchase could be linked to the resolved billing account'
|
|
812
915
|
});
|
|
813
916
|
},
|
|
917
|
+
async importSubscription(input) {
|
|
918
|
+
const { provider: providerName, reference, operator } = readImportRequest(input);
|
|
919
|
+
const plan = requirePlan(deps.catalog, input.planKey);
|
|
920
|
+
if (!plan.prices.some((price) => price.provider === providerName && price.kind === 'subscription')) {
|
|
921
|
+
throw createBillFnError({
|
|
922
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
923
|
+
message: `Plan ${plan.planKey} has no ${providerName} subscription price`
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
const provider = requireProvider(deps, providerName, 'importSubscription');
|
|
927
|
+
// A reference replaced by a subscription write, or bound to another billing account, fails before the provider is read.
|
|
928
|
+
// A pending row (an import not yet accepted, perhaps interrupted) is checked like an unbound reference and
|
|
929
|
+
// removed when the reference is retired, so a rerun of a refused import converges to no binding.
|
|
930
|
+
const bound = await findSubscriptionByProviderRef(deps, providerName, reference);
|
|
931
|
+
if (!bound || isImportPending(bound)) {
|
|
932
|
+
await refuseRetiredImportReference(deps, providerName, reference, bound);
|
|
933
|
+
}
|
|
934
|
+
if (bound && bound.billingAccountId !== input.billingAccountId) {
|
|
935
|
+
throw importBoundElsewhere(providerName);
|
|
936
|
+
}
|
|
937
|
+
// Resolved after the checks above, so an import they refuse never creates the subject's billing account;
|
|
938
|
+
// one refused after the provider read keeps it.
|
|
939
|
+
const billingAccount = await resolveImportBillingAccount(deps, input);
|
|
940
|
+
// Events that occurred before this live read are already reflected in its state.
|
|
941
|
+
const importObservedAt = toIsoString(deps.now());
|
|
942
|
+
const state = await readImportState(provider, providerName, { providerReference: reference, billingAccount, plan });
|
|
943
|
+
if (!state) {
|
|
944
|
+
throw createBillFnError({
|
|
945
|
+
code: 'BILLFN_NOT_FOUND',
|
|
946
|
+
message: 'Provider did not return the subscription to import'
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
if (state.providerSubscriptionId !== reference) {
|
|
950
|
+
throw createBillFnError({
|
|
951
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
952
|
+
message: 'The provider record does not match the imported reference'
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
const selection = requireImportPrice(plan, providerName, state);
|
|
956
|
+
// One audit event per import run and state it records, never changed once written. The run is the operator's
|
|
957
|
+
// request (a rerun of it that records a state it already recorded adds none, even after another state) or,
|
|
958
|
+
// without a requestId, this call. The state is every field the event shows and whether the row carries the
|
|
959
|
+
// import marker (not its value, which a rerun that adds it sets to its own read time).
|
|
960
|
+
const run = operator.requestId ? ['request', operator.requestId] : ['call', deps.generateId('run')];
|
|
961
|
+
const importAudit = (subscription) => ({
|
|
962
|
+
id: `evt_import_${createHash('sha256')
|
|
963
|
+
.update(JSON.stringify([
|
|
964
|
+
providerName,
|
|
965
|
+
reference,
|
|
966
|
+
billingAccount.id,
|
|
967
|
+
operator.id,
|
|
968
|
+
operator.type ?? null,
|
|
969
|
+
...run,
|
|
970
|
+
plan.planKey,
|
|
971
|
+
input.reason ?? null,
|
|
972
|
+
subscription.id,
|
|
973
|
+
subscription.planKey,
|
|
974
|
+
subscription.priceId,
|
|
975
|
+
subscription.status,
|
|
976
|
+
subscription.currentPeriodStart ?? null,
|
|
977
|
+
subscription.currentPeriodEnd ?? null,
|
|
978
|
+
subscription.autoRenew,
|
|
979
|
+
subscription.metadata?.[BILLFN_IMPORTED_AT_METADATA_KEY] !== undefined
|
|
980
|
+
]))
|
|
981
|
+
.digest('base64url')}`,
|
|
982
|
+
billingAccountId: billingAccount.id,
|
|
983
|
+
type: 'billfn.subscription.imported',
|
|
984
|
+
payload: {
|
|
985
|
+
subscriptionId: subscription.id,
|
|
986
|
+
provider: providerName,
|
|
987
|
+
providerReference: reference,
|
|
988
|
+
planKey: subscription.planKey,
|
|
989
|
+
priceId: subscription.priceId,
|
|
990
|
+
status: subscription.status,
|
|
991
|
+
currentPeriodStart: subscription.currentPeriodStart ?? null,
|
|
992
|
+
currentPeriodEnd: subscription.currentPeriodEnd ?? null,
|
|
993
|
+
autoRenew: subscription.autoRenew,
|
|
994
|
+
operator,
|
|
995
|
+
...(input.reason ? { reason: input.reason } : {})
|
|
996
|
+
},
|
|
997
|
+
createdAt: toIsoString(deps.now())
|
|
998
|
+
});
|
|
999
|
+
let applied;
|
|
1000
|
+
try {
|
|
1001
|
+
// Racing imports end with one row: the loser of the unique reference insert applies an ordered update,
|
|
1002
|
+
// or fails as bound elsewhere when the winner bound another billing account.
|
|
1003
|
+
applied = await applyVerificationState(deps, {
|
|
1004
|
+
billingAccount,
|
|
1005
|
+
plan: selection.plan,
|
|
1006
|
+
price: selection.price,
|
|
1007
|
+
state,
|
|
1008
|
+
liveStateObservedAt: importObservedAt,
|
|
1009
|
+
// A rerun or a racing import keeps the first import's time.
|
|
1010
|
+
metadataDefaults: { [BILLFN_IMPORTED_AT_METADATA_KEY]: importObservedAt },
|
|
1011
|
+
// A write may retire the reference while the provider is read (the check above ran before the read).
|
|
1012
|
+
importReference: reference,
|
|
1013
|
+
importAudit
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
if (hasErrorCode(error, 'BILLFN_CONFLICT') && (await isBoundElsewhere(deps, providerName, state, billingAccount.id))) {
|
|
1018
|
+
throw importBoundElsewhere(providerName);
|
|
1019
|
+
}
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
// Each write that left the row usable recorded its state's audit first. A run that wrote nothing (a read an
|
|
1023
|
+
// event superseded, or another run's accepted row) records the state it applied here, before the replay
|
|
1024
|
+
// changes anything, so a replay that does not finish still leaves this operator's event.
|
|
1025
|
+
await recordBillingEvent(deps, importAudit(applied.subscription));
|
|
1026
|
+
// A replay that does not finish fails the import; calling it again finishes the replay.
|
|
1027
|
+
try {
|
|
1028
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, applied.subscription);
|
|
1029
|
+
}
|
|
1030
|
+
catch (error) {
|
|
1031
|
+
if (isReconciliationLeaseConflict(error)) {
|
|
1032
|
+
throw importReplayUnfinished();
|
|
1033
|
+
}
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
const refreshed = await refreshProjectionAfterReplay(deps, applied);
|
|
1037
|
+
// The event returned is the one for the state returned (after the replay), recorded here when the replay
|
|
1038
|
+
// changed it. The first write wins, so a rerun returns it as recorded.
|
|
1039
|
+
const recorded = importAudit(refreshed.subscription);
|
|
1040
|
+
await recordBillingEvent(deps, recorded);
|
|
1041
|
+
const billingEvent = (await deps.db.findOne({
|
|
1042
|
+
model: TABLES.billingEvents,
|
|
1043
|
+
where: [{ field: 'id', operator: 'eq', value: recorded.id }],
|
|
1044
|
+
namespace: deps.namespace
|
|
1045
|
+
})) ?? recorded;
|
|
1046
|
+
deps.metrics.track('subscription.imported', { provider: providerName });
|
|
1047
|
+
return ok({
|
|
1048
|
+
subscription: refreshed.subscription,
|
|
1049
|
+
entitlements: refreshed.entitlements,
|
|
1050
|
+
billingEvent
|
|
1051
|
+
});
|
|
1052
|
+
},
|
|
814
1053
|
async getEntitlements(subject) {
|
|
815
1054
|
const resolved = await resolveBillingAccountReference(deps, requireSubject(subject));
|
|
816
1055
|
const billingAccount = await getBillingAccountById(deps, resolved.billingAccountId);
|
|
@@ -1056,7 +1295,13 @@ export function createBillFnReconciliationWorker(config) {
|
|
|
1056
1295
|
// The queue rejects an empty dead-letter reason, which would mask the run's error.
|
|
1057
1296
|
const reason = (error instanceof Error ? error.message || error.name : String(error)) || 'reconciliation run failed';
|
|
1058
1297
|
try {
|
|
1059
|
-
if (
|
|
1298
|
+
if (isProductiveReplayBound(error)) {
|
|
1299
|
+
// A replay that finished events before its bound continues in a fresh message, so a long
|
|
1300
|
+
// backlog never uses up the lease-conflict budget; one that finished none counts against it.
|
|
1301
|
+
await queue.enqueue(RECONCILIATION_QUEUE, claimed.payload);
|
|
1302
|
+
await queue.ack(claimed.id, fence);
|
|
1303
|
+
}
|
|
1304
|
+
else if (isReconciliationLeaseConflict(error) &&
|
|
1060
1305
|
claimed.attempt < RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS) {
|
|
1061
1306
|
// Another runner holds or took over the job's processing lease; retry once it has finished or expired.
|
|
1062
1307
|
await queue.fail(claimed.id, {
|
|
@@ -1162,7 +1407,8 @@ async function processReconciliationJob(deps, job, assertLease) {
|
|
|
1162
1407
|
message: 'Subscription sync job requires subscriptionId'
|
|
1163
1408
|
});
|
|
1164
1409
|
}
|
|
1165
|
-
|
|
1410
|
+
const candidatePriceId = readString(job.payload ?? {}, 'candidatePriceId');
|
|
1411
|
+
await syncSubscriptionByIdForJob(deps, job.subscriptionId, assertLease, candidatePriceId);
|
|
1166
1412
|
return;
|
|
1167
1413
|
}
|
|
1168
1414
|
case 'account-scan': {
|
|
@@ -1333,34 +1579,67 @@ async function processReconciliationJob(deps, job, assertLease) {
|
|
|
1333
1579
|
}
|
|
1334
1580
|
}
|
|
1335
1581
|
}
|
|
1336
|
-
async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease) {
|
|
1582
|
+
async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease, candidatePriceId) {
|
|
1337
1583
|
const subscription = await getSubscriptionById(deps, subscriptionId);
|
|
1338
|
-
if (!subscription) {
|
|
1584
|
+
if (!subscription || isImportPending(subscription)) {
|
|
1339
1585
|
return;
|
|
1340
1586
|
}
|
|
1341
1587
|
const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
|
|
1342
1588
|
if (!billingAccount) {
|
|
1343
1589
|
return;
|
|
1344
1590
|
}
|
|
1345
|
-
const
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
plan,
|
|
1351
|
-
price
|
|
1352
|
-
});
|
|
1353
|
-
if (!verification) {
|
|
1591
|
+
const candidate = candidatePriceId
|
|
1592
|
+
? findCatalogPriceReference(deps.catalog, subscription.provider, [candidatePriceId])
|
|
1593
|
+
: null;
|
|
1594
|
+
const live = await fetchLiveSubscription(deps, subscription, candidate ?? undefined);
|
|
1595
|
+
if (!live) {
|
|
1354
1596
|
return;
|
|
1355
1597
|
}
|
|
1356
1598
|
await assertLease();
|
|
1357
|
-
await
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1599
|
+
await applyLiveProviderState(deps, { billingAccount, subscription, ...live });
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Reads the subscription from the provider for its stored plan and price. If
|
|
1603
|
+
* that read fails or returns no state and `candidate` names another catalog
|
|
1604
|
+
* price, it reads for that price (a provider such as Apple reads only the live
|
|
1605
|
+
* product); such a read counts only when the state it returns names a catalog
|
|
1606
|
+
* price (`providerProductId` or the raw record), whatever the stored read's
|
|
1607
|
+
* failure was, else it counts as failed. The catalog price the live record
|
|
1608
|
+
* names is the selection applied. Returns null when every read returned no
|
|
1609
|
+
* state, and otherwise throws the first failure.
|
|
1610
|
+
*/
|
|
1611
|
+
async function fetchLiveSubscription(deps, subscription, candidate) {
|
|
1612
|
+
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
1613
|
+
const stored = { plan, price: requirePriceById(plan, subscription.priceId) };
|
|
1614
|
+
const provider = requireProvider(deps, subscription.provider, 'fetchSubscription');
|
|
1615
|
+
const selections = candidate?.price.priceId && candidate.price.priceId !== stored.price.priceId ? [stored, candidate] : [stored];
|
|
1616
|
+
const failures = [];
|
|
1617
|
+
for (const selection of selections) {
|
|
1618
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
1619
|
+
try {
|
|
1620
|
+
const state = await provider.fetchSubscription({ subscription, plan: selection.plan, price: selection.price });
|
|
1621
|
+
const live = state
|
|
1622
|
+
? await resolveProviderEventPriceReference(deps, subscription.provider, { type: 'subscription.read', billingState: state }, subscription)
|
|
1623
|
+
: null;
|
|
1624
|
+
// A read for another price counts only when the provider confirms that price.
|
|
1625
|
+
if (state && (live || selection === stored)) {
|
|
1626
|
+
return { ...(live ?? selection), state, liveStateObservedAt };
|
|
1627
|
+
}
|
|
1628
|
+
if (state) {
|
|
1629
|
+
failures.push(createBillFnError({
|
|
1630
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
1631
|
+
message: 'The live provider record does not confirm the requested catalog price'
|
|
1632
|
+
}));
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
catch (error) {
|
|
1636
|
+
failures.push(error);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (failures.length > 0) {
|
|
1640
|
+
throw failures[0];
|
|
1641
|
+
}
|
|
1642
|
+
return null;
|
|
1364
1643
|
}
|
|
1365
1644
|
async function getOrCreateBillingAccount(deps, subject) {
|
|
1366
1645
|
const resolved = await resolveBillingAccountReference(deps, subject);
|
|
@@ -1498,23 +1777,304 @@ function requireProvider(deps, providerName, capability) {
|
|
|
1498
1777
|
}
|
|
1499
1778
|
return provider;
|
|
1500
1779
|
}
|
|
1501
|
-
function
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
}
|
|
1780
|
+
function readImportRequest(input) {
|
|
1781
|
+
const invalid = (message) => createBillFnError({ code: 'BILLFN_VALIDATION_ERROR', message });
|
|
1782
|
+
const optionalReference = (value, name) => {
|
|
1783
|
+
if (value === undefined) {
|
|
1784
|
+
return undefined;
|
|
1785
|
+
}
|
|
1786
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value !== value.trim()) {
|
|
1787
|
+
throw invalid(`${name} must be a non-empty string without surrounding whitespace`);
|
|
1788
|
+
}
|
|
1789
|
+
return value;
|
|
1790
|
+
};
|
|
1791
|
+
const providerSubscriptionId = optionalReference(input.providerSubscriptionId, 'providerSubscriptionId');
|
|
1792
|
+
const originalTransactionId = optionalReference(input.originalTransactionId, 'originalTransactionId');
|
|
1793
|
+
if (originalTransactionId !== undefined && input.provider !== 'apple') {
|
|
1794
|
+
throw invalid('originalTransactionId identifies Apple subscriptions only');
|
|
1507
1795
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1796
|
+
if (providerSubscriptionId !== undefined && originalTransactionId !== undefined && providerSubscriptionId !== originalTransactionId) {
|
|
1797
|
+
throw invalid('providerSubscriptionId and originalTransactionId name different subscriptions');
|
|
1798
|
+
}
|
|
1799
|
+
const reference = providerSubscriptionId ?? originalTransactionId;
|
|
1800
|
+
if (reference === undefined) {
|
|
1801
|
+
throw invalid('Importing a subscription requires providerSubscriptionId or originalTransactionId');
|
|
1802
|
+
}
|
|
1803
|
+
if (typeof input.billingAccountId !== 'string' || input.billingAccountId.length === 0) {
|
|
1804
|
+
throw invalid('billingAccountId must be a non-empty string');
|
|
1805
|
+
}
|
|
1806
|
+
if (typeof input.planKey !== 'string' || input.planKey.length === 0) {
|
|
1807
|
+
throw invalid('planKey must be a non-empty string');
|
|
1808
|
+
}
|
|
1809
|
+
const operator = input.operator;
|
|
1810
|
+
if (!operator || typeof operator.id !== 'string' || operator.id.length === 0) {
|
|
1811
|
+
throw invalid('Importing a subscription requires the operator identity');
|
|
1812
|
+
}
|
|
1813
|
+
if (input.reason !== undefined && typeof input.reason !== 'string') {
|
|
1814
|
+
throw invalid('reason must be a string');
|
|
1815
|
+
}
|
|
1816
|
+
return {
|
|
1817
|
+
provider: input.provider,
|
|
1818
|
+
reference,
|
|
1819
|
+
operator: {
|
|
1820
|
+
id: operator.id,
|
|
1821
|
+
...(typeof operator.type === 'string' ? { type: operator.type } : {}),
|
|
1822
|
+
...(typeof operator.requestId === 'string' ? { requestId: operator.requestId } : {})
|
|
1823
|
+
}
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Reads the provider record to import. A BillFn error from the adapter keeps its classification; any other failure
|
|
1828
|
+
* (the provider was not reached, or the adapter failed without classifying it) is a retryable provider error whose
|
|
1829
|
+
* message names only the provider, so no raw error text reaches the operator.
|
|
1830
|
+
*/
|
|
1831
|
+
async function readImportState(provider, providerName, input) {
|
|
1832
|
+
try {
|
|
1833
|
+
return await provider.importSubscription(input);
|
|
1834
|
+
}
|
|
1835
|
+
catch (error) {
|
|
1836
|
+
const code = error?.code;
|
|
1837
|
+
if (typeof code === 'string' && code.startsWith('BILLFN_')) {
|
|
1838
|
+
throw error;
|
|
1839
|
+
}
|
|
1510
1840
|
throw createBillFnError({
|
|
1511
|
-
code: '
|
|
1512
|
-
message:
|
|
1841
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
1842
|
+
message: `Provider ${providerName} could not be read for the imported subscription`
|
|
1513
1843
|
});
|
|
1514
1844
|
}
|
|
1515
|
-
return subject;
|
|
1516
1845
|
}
|
|
1517
|
-
|
|
1846
|
+
/** The billing account an import binds to: created for the subject when one is given, otherwise existing. */
|
|
1847
|
+
async function resolveImportBillingAccount(deps, input) {
|
|
1848
|
+
if (input.subject) {
|
|
1849
|
+
const subject = requireSubject(input.subject);
|
|
1850
|
+
const resolved = await resolveBillingAccountReference(deps, subject);
|
|
1851
|
+
if (resolved.billingAccountId !== input.billingAccountId) {
|
|
1852
|
+
throw createBillFnError({
|
|
1853
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
1854
|
+
message: 'The subject resolves to a different billing account'
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
return (await getOrCreateBillingAccount(deps, subject)).billingAccount;
|
|
1858
|
+
}
|
|
1859
|
+
const billingAccount = await getBillingAccountById(deps, input.billingAccountId);
|
|
1860
|
+
if (!billingAccount) {
|
|
1861
|
+
throw createBillFnError({
|
|
1862
|
+
code: 'BILLFN_NOT_FOUND',
|
|
1863
|
+
message: 'Billing account not found'
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
return billingAccount;
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* The plan's subscription price the provider record's product names. Every reference (the adapter's
|
|
1870
|
+
* `providerProductId`, then the record's `product_id`/`productId`) is a provider product id here, so it is matched
|
|
1871
|
+
* only against `providerProductId`, never against a catalog `priceId` another plan's product could equal.
|
|
1872
|
+
*/
|
|
1873
|
+
function requireImportPrice(plan, provider, state) {
|
|
1874
|
+
const raw = asRecord(state.raw);
|
|
1875
|
+
const records = [raw, asRecord(raw?.transaction)];
|
|
1876
|
+
const references = [
|
|
1877
|
+
state.providerProductId,
|
|
1878
|
+
...records.flatMap((record) => (record ? ['product_id', 'productId'].map((key) => readString(record, key)) : []))
|
|
1879
|
+
].filter((reference) => Boolean(reference));
|
|
1880
|
+
const prices = plan.prices.filter((price) => price.provider === provider && price.kind === 'subscription');
|
|
1881
|
+
for (const reference of references) {
|
|
1882
|
+
const price = prices.find((candidate) => candidate.providerProductId === reference);
|
|
1883
|
+
if (price) {
|
|
1884
|
+
return { plan, price };
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
throw createBillFnError({
|
|
1888
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
1889
|
+
message: `The provider subscription's product is not a ${provider} subscription price of plan ${plan.planKey}`,
|
|
1890
|
+
details: { planKey: plan.planKey, providerProductIds: references }
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
function importBoundElsewhere(provider) {
|
|
1894
|
+
return createBillFnError({
|
|
1895
|
+
code: 'BILLFN_CONFLICT',
|
|
1896
|
+
message: `The ${provider} subscription is already bound to a different billing account`
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
/** Whether a subscription of another billing account holds a reference the provider record names. */
|
|
1900
|
+
async function isBoundElsewhere(deps, provider, state, billingAccountId) {
|
|
1901
|
+
const holder = await findSubscriptionByProviderRef(deps, provider, state.providerSubscriptionId, state.providerChargeId);
|
|
1902
|
+
return Boolean(holder && holder.billingAccountId !== billingAccountId);
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The import is bound and recorded, but another run holds the reference's
|
|
1906
|
+
* unlinked events (including a run interrupted before it released them) or the
|
|
1907
|
+
* replay reached its bound. Unlike a bound-elsewhere conflict it is retryable:
|
|
1908
|
+
* calling import again, after the holder's lease expires, finishes the replay.
|
|
1909
|
+
*/
|
|
1910
|
+
function importReplayUnfinished() {
|
|
1911
|
+
return createBillFnError({
|
|
1912
|
+
code: 'BILLFN_CONFLICT',
|
|
1913
|
+
message: 'The import is recorded but replaying its unlinked webhook events did not finish; import again to finish it',
|
|
1914
|
+
details: { reason: BILLFN_IMPORT_REPLAY_UNFINISHED_REASON },
|
|
1915
|
+
retryable: true
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
function importReferenceReplaced() {
|
|
1919
|
+
return createBillFnError({
|
|
1920
|
+
code: 'BILLFN_CONFLICT',
|
|
1921
|
+
message: 'The provider reference was replaced by another subscription and cannot be imported'
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Refuses an import of a reference a subscription write retired: deletes
|
|
1926
|
+
* `holder` when it is a pending import row holding the reference (whichever
|
|
1927
|
+
* import run created it), then fails with a non-retryable `BILLFN_CONFLICT`.
|
|
1928
|
+
* An accepted row is never deleted: the delete is ordered against a fresh
|
|
1929
|
+
* read that is still pending, so a stale `holder` another run has accepted
|
|
1930
|
+
* since survives.
|
|
1931
|
+
*/
|
|
1932
|
+
async function refuseRetiredImportReference(deps, provider, reference, holder) {
|
|
1933
|
+
if (!(await isReplacedProviderReference(deps, provider, reference, holder?.id))) {
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
const stored = holder && isImportPending(holder) ? await getSubscriptionById(deps, holder.id) : null;
|
|
1937
|
+
if (stored && isImportPending(stored) && stored.providerSubscriptionId === reference) {
|
|
1938
|
+
await deps.db.deleteMany({
|
|
1939
|
+
model: TABLES.subscriptions,
|
|
1940
|
+
where: [
|
|
1941
|
+
{ field: 'id', operator: 'eq', value: stored.id },
|
|
1942
|
+
{ field: 'providerSubscriptionId', operator: 'eq', value: reference },
|
|
1943
|
+
{ field: 'updatedAt', operator: 'eq', value: stored.updatedAt }
|
|
1944
|
+
],
|
|
1945
|
+
namespace: deps.namespace
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
deps.metrics.track('subscription.import.replaced_reference_refused', { provider });
|
|
1949
|
+
throw importReferenceReplaced();
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* After an import's write to `subscription`, which holds `reference`: refuses
|
|
1953
|
+
* the binding when a write of another subscription retired the reference, or
|
|
1954
|
+
* accepts it by clearing the pending marker, and returns the accepted row:
|
|
1955
|
+
* `ownState` when that row carries this write's state, and `acceptedHere` when
|
|
1956
|
+
* this call cleared its pending marker. A lost acceptance write re-finds the
|
|
1957
|
+
* row, which then carries another run's state (accepted by it or here), so the
|
|
1958
|
+
* caller publishes that row's plan, never its own. Every row, the first and
|
|
1959
|
+
* each reload, is checked the same way before it is accepted or returned: it
|
|
1960
|
+
* holds the reference, belongs to `billingAccountId` (another account's row
|
|
1961
|
+
* fails as bound elsewhere, untouched) and its reference is not retired.
|
|
1962
|
+
* Nothing uses a pending row, so no entitlement or lifecycle event exists for
|
|
1963
|
+
* it until this accepts it. The import's audit event (`audit`) for the row it
|
|
1964
|
+
* accepts is recorded just before each acceptance write, so an accepted
|
|
1965
|
+
* binding always has it (an event for a write that then does not land stays);
|
|
1966
|
+
* a refusal deletes the row, and an interrupted run leaves it pending for the
|
|
1967
|
+
* rerun to settle.
|
|
1968
|
+
*
|
|
1969
|
+
* Why the check after the write is enough: a reference is held by at most one
|
|
1970
|
+
* row (unique index), so a writer that retired it took it off its row before
|
|
1971
|
+
* this write held it, and recorded it before that, or saved it pending in that
|
|
1972
|
+
* same write. Read pending, then recorded, now, one of the reads sees it. Every
|
|
1973
|
+
* import that holds the row therefore reaches the same answer.
|
|
1974
|
+
*/
|
|
1975
|
+
async function settleImportBinding(deps, subscription, reference, billingAccountId, audit) {
|
|
1976
|
+
let current = subscription;
|
|
1977
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
1978
|
+
// Only the first attempt holds this run's write; a reload holds whatever wrote the row after it.
|
|
1979
|
+
const ownState = attempt === 0;
|
|
1980
|
+
if (current?.providerSubscriptionId !== reference) {
|
|
1981
|
+
throw createBillFnError({
|
|
1982
|
+
code: 'BILLFN_CONFLICT',
|
|
1983
|
+
message: 'The imported subscription changed while its import was accepted',
|
|
1984
|
+
retryable: true
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
if (current.billingAccountId !== billingAccountId) {
|
|
1988
|
+
throw importBoundElsewhere(current.provider);
|
|
1989
|
+
}
|
|
1990
|
+
await refuseRetiredImportReference(deps, current.provider, reference, current);
|
|
1991
|
+
if (!isImportPending(current)) {
|
|
1992
|
+
return { subscription: current, ownState, acceptedHere: false };
|
|
1993
|
+
}
|
|
1994
|
+
const accepted = {
|
|
1995
|
+
...current,
|
|
1996
|
+
metadata: withoutImportPending(current.metadata),
|
|
1997
|
+
updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
|
|
1998
|
+
};
|
|
1999
|
+
if (await updateSubscriptionAfterImportAudit(deps, current, accepted, audit)) {
|
|
2000
|
+
return { subscription: accepted, ownState, acceptedHere: true };
|
|
2001
|
+
}
|
|
2002
|
+
current = await findSubscriptionByProviderRef(deps, subscription.provider, reference);
|
|
2003
|
+
await Promise.resolve();
|
|
2004
|
+
}
|
|
2005
|
+
throw createBillFnError({
|
|
2006
|
+
code: 'BILLFN_CONFLICT',
|
|
2007
|
+
message: 'Unable to accept the imported subscription after multiple retries',
|
|
2008
|
+
retryable: true
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
/** Whether the row is an import's binding not yet accepted (see `BILLFN_IMPORT_PENDING_METADATA_KEY`). */
|
|
2012
|
+
function isImportPending(subscription) {
|
|
2013
|
+
return subscription?.metadata?.[BILLFN_IMPORT_PENDING_METADATA_KEY] === true;
|
|
2014
|
+
}
|
|
2015
|
+
function withoutImportPending(metadata) {
|
|
2016
|
+
if (!metadata) {
|
|
2017
|
+
return metadata;
|
|
2018
|
+
}
|
|
2019
|
+
const { [BILLFN_IMPORT_PENDING_METADATA_KEY]: _pending, ...rest } = metadata;
|
|
2020
|
+
return rest;
|
|
2021
|
+
}
|
|
2022
|
+
/** A write other than an import's reached a pending import row: retried once the import settles it. */
|
|
2023
|
+
function importPendingConflict() {
|
|
2024
|
+
return createBillFnError({
|
|
2025
|
+
code: 'BILLFN_CONFLICT',
|
|
2026
|
+
message: 'The subscription is being imported',
|
|
2027
|
+
details: { reason: 'subscription_import_pending' },
|
|
2028
|
+
retryable: true
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Whether a subscription write dropped this reference (still pending on the
|
|
2033
|
+
* subscription, or recorded). A writer records the reference before it clears
|
|
2034
|
+
* pending, so pending is read first, as `namesReplacedReference` does: a writer
|
|
2035
|
+
* that clears pending between the reads has already recorded it. `holderId`,
|
|
2036
|
+
* the row that holds the reference now, is not counted: a reference it dropped
|
|
2037
|
+
* and holds again, or one a write that lost its race on it recorded, is held.
|
|
2038
|
+
*/
|
|
2039
|
+
async function isReplacedProviderReference(deps, provider, reference, holderId) {
|
|
2040
|
+
const pending = await deps.db.findMany({
|
|
2041
|
+
model: TABLES.subscriptions,
|
|
2042
|
+
where: [
|
|
2043
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
2044
|
+
{ field: 'replacedReferencesPending', operator: 'eq', value: true }
|
|
2045
|
+
],
|
|
2046
|
+
namespace: deps.namespace
|
|
2047
|
+
});
|
|
2048
|
+
if (pending.some((subscription) => subscription.id !== holderId && pendingReplacedReferencesOf(subscription).includes(reference))) {
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
2051
|
+
const replaced = await deps.db.findOne({
|
|
2052
|
+
model: TABLES.replacedProviderReferences,
|
|
2053
|
+
where: [
|
|
2054
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
2055
|
+
{ field: 'providerReference', operator: 'eq', value: reference }
|
|
2056
|
+
],
|
|
2057
|
+
namespace: deps.namespace
|
|
2058
|
+
});
|
|
2059
|
+
return Boolean(replaced && replaced.subscriptionId !== holderId);
|
|
2060
|
+
}
|
|
2061
|
+
function requireSubject(subject) {
|
|
2062
|
+
if (!subject) {
|
|
2063
|
+
throw createBillFnError({
|
|
2064
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
2065
|
+
message: 'A billing subject is required'
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
const hasIdentifier = Boolean(subject.actorId || subject.principalId || subject.organizationId || subject.tenantId);
|
|
2069
|
+
if (!hasIdentifier) {
|
|
2070
|
+
throw createBillFnError({
|
|
2071
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
2072
|
+
message: 'A billing subject must include an actor, principal, tenant, or organization identifier'
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
return subject;
|
|
2076
|
+
}
|
|
2077
|
+
async function createCheckoutSessionRecord(deps, session) {
|
|
1518
2078
|
await deps.db.create({
|
|
1519
2079
|
model: TABLES.checkoutSessions,
|
|
1520
2080
|
data: session,
|
|
@@ -1553,7 +2113,7 @@ async function getSubscriptionById(deps, id) {
|
|
|
1553
2113
|
}
|
|
1554
2114
|
async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId) {
|
|
1555
2115
|
if (subscriptionId) {
|
|
1556
|
-
|
|
2116
|
+
const subscription = await deps.db.findOne({
|
|
1557
2117
|
model: TABLES.subscriptions,
|
|
1558
2118
|
where: [
|
|
1559
2119
|
{ field: 'id', operator: 'eq', value: subscriptionId },
|
|
@@ -1561,6 +2121,7 @@ async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId)
|
|
|
1561
2121
|
],
|
|
1562
2122
|
namespace: deps.namespace
|
|
1563
2123
|
});
|
|
2124
|
+
return isImportPending(subscription) ? null : subscription;
|
|
1564
2125
|
}
|
|
1565
2126
|
return findActiveSubscription(deps, billingAccountId);
|
|
1566
2127
|
}
|
|
@@ -1574,7 +2135,8 @@ async function findActiveSubscription(deps, billingAccountId) {
|
|
|
1574
2135
|
orderBy: [{ field: 'updatedAt', direction: 'desc' }],
|
|
1575
2136
|
namespace: deps.namespace
|
|
1576
2137
|
});
|
|
1577
|
-
|
|
2138
|
+
// A pending import row is not the account's until its import accepts it.
|
|
2139
|
+
return candidates.find((candidate) => !isImportPending(candidate)) ?? null;
|
|
1578
2140
|
}
|
|
1579
2141
|
async function findSubscriptionByProviderRef(deps, provider, providerSubscriptionId, providerChargeId, checkoutReferenceId) {
|
|
1580
2142
|
const whereSets = [];
|
|
@@ -1869,26 +2431,91 @@ async function applyVerificationState(deps, input) {
|
|
|
1869
2431
|
(await findSubscriptionByProviderRef(deps, input.price.provider, input.state.providerSubscriptionId, input.state.providerChargeId, input.checkoutSession?.checkoutSessionId));
|
|
1870
2432
|
let subscription = null;
|
|
1871
2433
|
let timestamp = toIsoString(deps.now());
|
|
2434
|
+
let liveStateSuperseded = false;
|
|
2435
|
+
let providerStateRaced;
|
|
2436
|
+
// A read is superseded by an event newer than its start; a committed mutation only by one after its commit.
|
|
2437
|
+
const supersededBefore = input.committedAt ?? input.liveStateObservedAt;
|
|
1872
2438
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2439
|
+
if (!input.importReference && isImportPending(baseSubscription)) {
|
|
2440
|
+
// Only the import settles its pending row; anything else applies once it is accepted (or deleted).
|
|
2441
|
+
throw importPendingConflict();
|
|
2442
|
+
}
|
|
1873
2443
|
if (baseSubscription && baseSubscription.billingAccountId !== input.billingAccount.id) {
|
|
1874
2444
|
throw createBillFnError({
|
|
1875
2445
|
code: 'BILLFN_CONFLICT',
|
|
1876
2446
|
message: 'Provider billing reference belongs to a different billing account'
|
|
1877
2447
|
});
|
|
1878
2448
|
}
|
|
1879
|
-
if (baseSubscription &&
|
|
1880
|
-
|
|
1881
|
-
|
|
2449
|
+
if (input.importReference && baseSubscription && baseSubscription.providerSubscriptionId !== input.importReference) {
|
|
2450
|
+
// Found by another reference the provider record names: an import never moves its reference onto such a row.
|
|
2451
|
+
throw createBillFnError({
|
|
2452
|
+
code: 'BILLFN_CONFLICT',
|
|
2453
|
+
message: 'The provider record names a reference another subscription holds'
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
const order = baseSubscription && input.occurredAt ? orderProviderEvent(baseSubscription, input.occurredAt) : 'apply';
|
|
2457
|
+
if (baseSubscription && order === 'skip') {
|
|
2458
|
+
// The older event's state is skipped, but a reference it adds still belongs to this subscription.
|
|
2459
|
+
await linkSkippedEventReferences(deps, baseSubscription, input.state);
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
if (baseSubscription && order === 'reconcile') {
|
|
2463
|
+
// Never applied over the committed mutation; without a queue a failed read fails this call, so it is retried.
|
|
2464
|
+
await reconcileMutationWindowEvent(deps, baseSubscription, input);
|
|
1882
2465
|
return null;
|
|
1883
2466
|
}
|
|
2467
|
+
// An event applied after a live read started is newer than what the read returned: the read
|
|
2468
|
+
// only links its references and the subscription keeps the event's state.
|
|
2469
|
+
liveStateSuperseded = Boolean(baseSubscription &&
|
|
2470
|
+
!input.occurredAt &&
|
|
2471
|
+
supersededBefore &&
|
|
2472
|
+
isProviderEventOlderThanSubscription(baseSubscription, supersededBefore));
|
|
2473
|
+
// An event applied while a committed mutation was in flight is reconciled by the caller.
|
|
2474
|
+
providerStateRaced =
|
|
2475
|
+
baseSubscription &&
|
|
2476
|
+
input.committedAt &&
|
|
2477
|
+
input.liveStateObservedAt &&
|
|
2478
|
+
isProviderEventOlderThanSubscription(baseSubscription, input.liveStateObservedAt)
|
|
2479
|
+
? { priceId: baseSubscription.priceId }
|
|
2480
|
+
: undefined;
|
|
1884
2481
|
timestamp = nextSubscriptionUpdatedAt(deps.now(), baseSubscription?.updatedAt);
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
2482
|
+
if (baseSubscription && liveStateSuperseded) {
|
|
2483
|
+
const linked = linkLiveReadReferences(baseSubscription, input, timestamp);
|
|
2484
|
+
if (!linked) {
|
|
2485
|
+
subscription = baseSubscription;
|
|
2486
|
+
break;
|
|
2487
|
+
}
|
|
2488
|
+
// An import that adds its marker or references to an accepted row records its audit first.
|
|
2489
|
+
if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, linked, input.importAudit)) {
|
|
2490
|
+
subscription = linked;
|
|
2491
|
+
break;
|
|
2492
|
+
}
|
|
2493
|
+
const current = await reloadAfterLostWrite(deps, input, baseSubscription);
|
|
2494
|
+
if (!current && !input.importReference) {
|
|
2495
|
+
throw createBillFnError({
|
|
2496
|
+
code: 'BILLFN_CONFLICT',
|
|
2497
|
+
message: 'Subscription changed while applying provider state'
|
|
2498
|
+
});
|
|
1890
2499
|
}
|
|
1891
|
-
|
|
2500
|
+
baseSubscription = current;
|
|
2501
|
+
await Promise.resolve();
|
|
2502
|
+
continue;
|
|
2503
|
+
}
|
|
2504
|
+
// A state write closes the window of an earlier committed mutation; a committed mutation opens its own.
|
|
2505
|
+
const merged = mergeJson(mergeJson(input.metadataDefaults, baseSubscription?.metadata), input.state.raw);
|
|
2506
|
+
const mergedMetadata = input.keepMutationWindow ? merged : withoutMutationWindow(merged);
|
|
2507
|
+
const providerStateAt = providerStateTimeFor(input.occurredAt, supersededBefore, baseSubscription);
|
|
2508
|
+
const metadata = {
|
|
2509
|
+
...mergedMetadata,
|
|
2510
|
+
...(providerStateAt
|
|
2511
|
+
? {
|
|
2512
|
+
[PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]: providerStateAt,
|
|
2513
|
+
...(input.committedAt ? { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: input.liveStateObservedAt } : {})
|
|
2514
|
+
}
|
|
2515
|
+
: {}),
|
|
2516
|
+
// A row an import creates stays pending until `settleImportBinding` accepts it.
|
|
2517
|
+
...(input.importReference && !baseSubscription ? { [BILLFN_IMPORT_PENDING_METADATA_KEY]: true } : {})
|
|
2518
|
+
};
|
|
1892
2519
|
const subscriptionId = baseSubscription?.id ?? deps.generateId('sub');
|
|
1893
2520
|
const record = {
|
|
1894
2521
|
id: subscriptionId,
|
|
@@ -1911,38 +2538,30 @@ async function applyVerificationState(deps, input) {
|
|
|
1911
2538
|
trialEnd: baseSubscription?.trialEnd
|
|
1912
2539
|
};
|
|
1913
2540
|
if (baseSubscription) {
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2541
|
+
// A reference this write drops is recorded as replaced before it, so no failure or interruption
|
|
2542
|
+
// after the write lets an event on it bootstrap another subscription; one a committed mutation
|
|
2543
|
+
// cannot record is saved pending with the write. A pending one is recorded before this write
|
|
2544
|
+
// clears it, the order `namesReplacedReference` reads against.
|
|
2545
|
+
const pending = await recordDroppedReferences(deps, baseSubscription, record, Boolean(input.committedAt));
|
|
2546
|
+
Object.assign(record, withPendingReplacements(record.metadata, pending));
|
|
2547
|
+
// Every update is ordered against the row it was computed from, so a provider-state time and the
|
|
2548
|
+
// pending references it clears always come from the row it replaces. A re-import of an accepted row
|
|
2549
|
+
// records its audit first.
|
|
2550
|
+
if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, record, input.importAudit)) {
|
|
2551
|
+
subscription = record;
|
|
2552
|
+
break;
|
|
2553
|
+
}
|
|
2554
|
+
// A lost race leaves its records: a reference the winner keeps is held, so its events still link to it.
|
|
2555
|
+
const current = await reloadAfterLostWrite(deps, input, baseSubscription);
|
|
2556
|
+
if (!current && !input.importReference) {
|
|
2557
|
+
throw createBillFnError({
|
|
2558
|
+
code: 'BILLFN_CONFLICT',
|
|
2559
|
+
message: 'Subscription changed while applying provider event state'
|
|
1923
2560
|
});
|
|
1924
|
-
if (updated === 1) {
|
|
1925
|
-
subscription = record;
|
|
1926
|
-
break;
|
|
1927
|
-
}
|
|
1928
|
-
const current = await getSubscriptionById(deps, baseSubscription.id);
|
|
1929
|
-
if (!current) {
|
|
1930
|
-
throw createBillFnError({
|
|
1931
|
-
code: 'BILLFN_CONFLICT',
|
|
1932
|
-
message: 'Subscription changed while applying provider event state'
|
|
1933
|
-
});
|
|
1934
|
-
}
|
|
1935
|
-
baseSubscription = current;
|
|
1936
|
-
await Promise.resolve();
|
|
1937
|
-
continue;
|
|
1938
2561
|
}
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
data: record,
|
|
1943
|
-
namespace: deps.namespace
|
|
1944
|
-
});
|
|
1945
|
-
break;
|
|
2562
|
+
baseSubscription = current;
|
|
2563
|
+
await Promise.resolve();
|
|
2564
|
+
continue;
|
|
1946
2565
|
}
|
|
1947
2566
|
try {
|
|
1948
2567
|
subscription = await deps.db.create({
|
|
@@ -1970,6 +2589,16 @@ async function applyVerificationState(deps, input) {
|
|
|
1970
2589
|
message: 'Unable to apply provider event state after multiple retries'
|
|
1971
2590
|
});
|
|
1972
2591
|
}
|
|
2592
|
+
// Whether the row carries this call's state; otherwise it publishes the row's own plan, never `input.plan`.
|
|
2593
|
+
let ownState = !liveStateSuperseded;
|
|
2594
|
+
// Whether this call records the row's lifecycle event (an import that accepts another run's state records it).
|
|
2595
|
+
let recordLifecycle = ownState;
|
|
2596
|
+
if (input.importReference) {
|
|
2597
|
+
const settled = await settleImportBinding(deps, subscription, input.importReference, input.billingAccount.id, input.importAudit);
|
|
2598
|
+
subscription = settled.subscription;
|
|
2599
|
+
ownState = ownState && settled.ownState;
|
|
2600
|
+
recordLifecycle = ownState || settled.acceptedHere;
|
|
2601
|
+
}
|
|
1973
2602
|
const checkoutSession = input.checkoutSession
|
|
1974
2603
|
? await updateCheckoutSession(deps, input.checkoutSession.checkoutSessionId, {
|
|
1975
2604
|
status: input.state.checkoutStatus,
|
|
@@ -1979,9 +2608,22 @@ async function applyVerificationState(deps, input) {
|
|
|
1979
2608
|
metadata: mergeJson(input.checkoutSession.metadata, input.state.raw)
|
|
1980
2609
|
})
|
|
1981
2610
|
: input.checkoutSession;
|
|
1982
|
-
|
|
2611
|
+
// A superseded read leaves the subscription's state, so its entitlements stand and no lifecycle event is recorded;
|
|
2612
|
+
// an import that ends on another run's state publishes that state's plan.
|
|
2613
|
+
const entitlements = await upsertEntitlements(deps, input.billingAccount.id, ownState ? input.plan : requirePlan(deps.catalog, subscription.planKey), subscription, timestamp);
|
|
2614
|
+
if (!recordLifecycle) {
|
|
2615
|
+
return {
|
|
2616
|
+
checkoutSession: checkoutSession ?? input.checkoutSession,
|
|
2617
|
+
subscription,
|
|
2618
|
+
entitlements,
|
|
2619
|
+
providerStateRaced
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
1983
2622
|
await recordBillingEvent(deps, {
|
|
1984
|
-
|
|
2623
|
+
// A provider event keys its lifecycle entry, so applying it again (a reclaimed replay) records it once.
|
|
2624
|
+
id: input.providerEventId
|
|
2625
|
+
? buildProviderEventBillingEventId(subscription.provider, input.providerEventId)
|
|
2626
|
+
: deps.generateId('evt'),
|
|
1985
2627
|
billingAccountId: input.billingAccount.id,
|
|
1986
2628
|
type: `billfn.subscription.${subscription.status}`,
|
|
1987
2629
|
payload: {
|
|
@@ -1998,9 +2640,159 @@ async function applyVerificationState(deps, input) {
|
|
|
1998
2640
|
return {
|
|
1999
2641
|
checkoutSession: checkoutSession ?? input.checkoutSession,
|
|
2000
2642
|
subscription,
|
|
2001
|
-
entitlements
|
|
2643
|
+
entitlements,
|
|
2644
|
+
providerStateRaced
|
|
2002
2645
|
};
|
|
2003
2646
|
}
|
|
2647
|
+
/**
|
|
2648
|
+
* The row to retry a lost ordered write against: the row again, or for an
|
|
2649
|
+
* import the row that holds its reference now (null when none does, and the
|
|
2650
|
+
* import creates one), so the import never writes its reference back onto a
|
|
2651
|
+
* row a writer took it from.
|
|
2652
|
+
*/
|
|
2653
|
+
function reloadAfterLostWrite(deps, input, subscription) {
|
|
2654
|
+
return input.importReference
|
|
2655
|
+
? findSubscriptionByProviderRef(deps, input.price.provider, input.importReference)
|
|
2656
|
+
: getSubscriptionById(deps, subscription.id);
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* The provider-state time a write saves: the event's time, or for live state
|
|
2660
|
+
* the read start (a committed mutation: its commit), never moving the stored
|
|
2661
|
+
* time backwards on any path. Null when the state carries no time.
|
|
2662
|
+
*/
|
|
2663
|
+
function providerStateTimeFor(occurredAt, liveStateAt, subscription) {
|
|
2664
|
+
const stateAt = occurredAt ?? liveStateAt;
|
|
2665
|
+
return stateAt ? laterProviderEventTime(readProviderStateOccurredAt(subscription), stateAt) : null;
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2668
|
+
* The subscription row after a live provider read that an applied event has
|
|
2669
|
+
* superseded: missing provider references and metadata keys from the read are
|
|
2670
|
+
* added and every state field is kept. Returns null when the read adds neither.
|
|
2671
|
+
*/
|
|
2672
|
+
function linkLiveReadReferences(subscription, input, timestamp) {
|
|
2673
|
+
const providerSubscriptionId = subscription.providerSubscriptionId ?? input.state.providerSubscriptionId;
|
|
2674
|
+
const providerCheckoutId = subscription.providerCheckoutId ?? input.checkoutSession?.providerCheckoutId;
|
|
2675
|
+
const providerChargeId = subscription.providerChargeId ?? input.state.providerChargeId;
|
|
2676
|
+
const addsMetadata = Object.keys(input.metadataDefaults ?? {}).some((key) => subscription.metadata?.[key] === undefined);
|
|
2677
|
+
if (providerSubscriptionId === subscription.providerSubscriptionId &&
|
|
2678
|
+
providerCheckoutId === subscription.providerCheckoutId &&
|
|
2679
|
+
providerChargeId === subscription.providerChargeId &&
|
|
2680
|
+
!addsMetadata) {
|
|
2681
|
+
return null;
|
|
2682
|
+
}
|
|
2683
|
+
const metadata = addsMetadata ? { ...input.metadataDefaults, ...subscription.metadata } : subscription.metadata;
|
|
2684
|
+
return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, metadata, updatedAt: timestamp };
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* Links the provider references of an event skipped as older than the
|
|
2688
|
+
* subscription's provider state (the last applied event or a live provider
|
|
2689
|
+
* read). The state, entitlements and provider-state time are kept; only a
|
|
2690
|
+
* missing provider subscription or charge id is filled, never replaced, and a
|
|
2691
|
+
* reference another subscription already holds is left alone. The write is
|
|
2692
|
+
* ordered against the row it was computed from. Returns the subscription after
|
|
2693
|
+
* the write, or the current row when nothing was added; the caller replays the
|
|
2694
|
+
* events retained for an added reference.
|
|
2695
|
+
*/
|
|
2696
|
+
async function linkSkippedEventReferences(deps, subscription, state) {
|
|
2697
|
+
let current = subscription;
|
|
2698
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2699
|
+
const data = await linkableMissingReferences(deps, current, state);
|
|
2700
|
+
if (Object.keys(data).length === 0) {
|
|
2701
|
+
return current;
|
|
2702
|
+
}
|
|
2703
|
+
const linked = {
|
|
2704
|
+
...current,
|
|
2705
|
+
...data,
|
|
2706
|
+
updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
|
|
2707
|
+
};
|
|
2708
|
+
if (await writeOrderedSubscription(deps, current, linked)) {
|
|
2709
|
+
deps.metrics.track('reconciliation.event.references_linked', {
|
|
2710
|
+
provider: current.provider,
|
|
2711
|
+
subscriptionId: current.id
|
|
2712
|
+
});
|
|
2713
|
+
return linked;
|
|
2714
|
+
}
|
|
2715
|
+
const reread = await getSubscriptionById(deps, current.id);
|
|
2716
|
+
if (!reread) {
|
|
2717
|
+
return current;
|
|
2718
|
+
}
|
|
2719
|
+
current = reread;
|
|
2720
|
+
await Promise.resolve();
|
|
2721
|
+
}
|
|
2722
|
+
throw createBillFnError({
|
|
2723
|
+
code: 'BILLFN_CONFLICT',
|
|
2724
|
+
message: 'Unable to link provider references after multiple retries'
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
/** The references the subscription lacks that the state names and no other subscription holds. */
|
|
2728
|
+
async function linkableMissingReferences(deps, subscription, state) {
|
|
2729
|
+
const data = {};
|
|
2730
|
+
for (const field of ['providerSubscriptionId', 'providerChargeId']) {
|
|
2731
|
+
if (await isMissingReferenceLinkable(deps, subscription, field, state[field])) {
|
|
2732
|
+
data[field] = state[field];
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
return data;
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* The ordered update of an existing row that `applyVerificationState` (state and superseded-read link updates)
|
|
2739
|
+
* and an import's acceptance (`settleImportBinding`) go through. With an import's audit, a write that leaves the
|
|
2740
|
+
* row usable (not pending) records the run's audit for the state it publishes first, so no later failure or
|
|
2741
|
+
* interruption leaves an import's change without its operator. The row an import creates is pending, so it is
|
|
2742
|
+
* audited when this accepts it.
|
|
2743
|
+
*/
|
|
2744
|
+
async function updateSubscriptionAfterImportAudit(deps, current, next, importAudit) {
|
|
2745
|
+
if (importAudit && !isImportPending(next)) {
|
|
2746
|
+
await recordBillingEvent(deps, importAudit(next));
|
|
2747
|
+
}
|
|
2748
|
+
const updated = await deps.db.updateMany({
|
|
2749
|
+
model: TABLES.subscriptions,
|
|
2750
|
+
where: [
|
|
2751
|
+
{ field: 'id', operator: 'eq', value: current.id },
|
|
2752
|
+
{ field: 'updatedAt', operator: 'eq', value: current.updatedAt }
|
|
2753
|
+
],
|
|
2754
|
+
data: next,
|
|
2755
|
+
namespace: deps.namespace
|
|
2756
|
+
});
|
|
2757
|
+
return updated === 1;
|
|
2758
|
+
}
|
|
2759
|
+
/** Writes a subscription row ordered against the row it was computed from; false when that row changed. */
|
|
2760
|
+
async function writeOrderedSubscription(deps, current, next) {
|
|
2761
|
+
try {
|
|
2762
|
+
const updated = await deps.db.updateMany({
|
|
2763
|
+
model: TABLES.subscriptions,
|
|
2764
|
+
where: [
|
|
2765
|
+
{ field: 'id', operator: 'eq', value: current.id },
|
|
2766
|
+
{ field: 'updatedAt', operator: 'eq', value: current.updatedAt }
|
|
2767
|
+
],
|
|
2768
|
+
data: next,
|
|
2769
|
+
namespace: deps.namespace
|
|
2770
|
+
});
|
|
2771
|
+
return updated === 1;
|
|
2772
|
+
}
|
|
2773
|
+
catch (error) {
|
|
2774
|
+
// Another subscription took the reference between the check and the write: re-check it.
|
|
2775
|
+
if (!isDuplicateRecordError(error)) {
|
|
2776
|
+
throw error;
|
|
2777
|
+
}
|
|
2778
|
+
return false;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
/** Whether the subscription lacks this reference and no other subscription holds it. */
|
|
2782
|
+
async function isMissingReferenceLinkable(deps, subscription, field, reference) {
|
|
2783
|
+
return Boolean(!subscription[field] && reference && !(await isProviderReferenceHeldElsewhere(deps, subscription, field, reference)));
|
|
2784
|
+
}
|
|
2785
|
+
async function isProviderReferenceHeldElsewhere(deps, subscription, field, reference) {
|
|
2786
|
+
const holder = await deps.db.findOne({
|
|
2787
|
+
model: TABLES.subscriptions,
|
|
2788
|
+
where: [
|
|
2789
|
+
{ field: 'provider', operator: 'eq', value: subscription.provider },
|
|
2790
|
+
{ field, operator: 'eq', value: reference }
|
|
2791
|
+
],
|
|
2792
|
+
namespace: deps.namespace
|
|
2793
|
+
});
|
|
2794
|
+
return Boolean(holder && holder.id !== subscription.id);
|
|
2795
|
+
}
|
|
2004
2796
|
async function upsertEntitlements(deps, billingAccountId, plan, subscription, timestamp) {
|
|
2005
2797
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2006
2798
|
const existing = await findEntitlementSnapshot(deps, billingAccountId);
|
|
@@ -2058,11 +2850,23 @@ async function upsertEntitlements(deps, billingAccountId, plan, subscription, ti
|
|
|
2058
2850
|
});
|
|
2059
2851
|
}
|
|
2060
2852
|
async function recordBillingEvent(deps, event) {
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2853
|
+
try {
|
|
2854
|
+
await deps.db.create({
|
|
2855
|
+
model: TABLES.billingEvents,
|
|
2856
|
+
data: event,
|
|
2857
|
+
namespace: deps.namespace
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
catch (error) {
|
|
2861
|
+
// Already recorded: a provider-event-keyed entry by an earlier apply of the same event, or a digest-keyed
|
|
2862
|
+
// import event by an earlier write of the same import run and state. The first write wins.
|
|
2863
|
+
if (!isDuplicateRecordError(error)) {
|
|
2864
|
+
throw error;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
function buildProviderEventBillingEventId(provider, providerEventId) {
|
|
2869
|
+
return `evt_${provider}_${Buffer.from(providerEventId).toString('base64url')}`;
|
|
2066
2870
|
}
|
|
2067
2871
|
async function createRefundRecord(deps, input) {
|
|
2068
2872
|
const refund = {
|
|
@@ -2215,6 +3019,10 @@ async function appendUsageLedgerEntry(deps, billingAccountId, resource, amount)
|
|
|
2215
3019
|
}
|
|
2216
3020
|
async function resolveProjectionContextForProviderState(deps, input) {
|
|
2217
3021
|
const subscription = await findSubscriptionByProviderRef(deps, input.provider, input.state.providerSubscriptionId, input.state.providerChargeId, input.checkoutReferenceId);
|
|
3022
|
+
if (isImportPending(subscription)) {
|
|
3023
|
+
// Not usable until its import accepts it: the event is stored unlinked and replayed then.
|
|
3024
|
+
return null;
|
|
3025
|
+
}
|
|
2218
3026
|
if (subscription) {
|
|
2219
3027
|
const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
|
|
2220
3028
|
if (!billingAccount) {
|
|
@@ -2250,23 +3058,81 @@ async function resolveProjectionContextForProviderState(deps, input) {
|
|
|
2250
3058
|
};
|
|
2251
3059
|
}
|
|
2252
3060
|
async function processParsedWebhookEvent(deps, providerName, event) {
|
|
2253
|
-
|
|
3061
|
+
const state = event.billingState;
|
|
3062
|
+
if (!state) {
|
|
2254
3063
|
return null;
|
|
2255
3064
|
}
|
|
2256
|
-
|
|
3065
|
+
let projection = await resolveProjectionContextForProviderState(deps, {
|
|
2257
3066
|
provider: providerName,
|
|
2258
|
-
state
|
|
3067
|
+
state,
|
|
2259
3068
|
checkoutReferenceId: event.checkoutReferenceId
|
|
2260
3069
|
});
|
|
2261
3070
|
if (!projection) {
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
3071
|
+
const outcome = await bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, state);
|
|
3072
|
+
if (!('linked' in outcome)) {
|
|
3073
|
+
return outcome.handled;
|
|
3074
|
+
}
|
|
3075
|
+
projection = outcome.linked;
|
|
3076
|
+
}
|
|
3077
|
+
if (projection.subscription) {
|
|
3078
|
+
return applyEventToLinkedSubscription(deps, providerName, { ...event, billingState: state }, projection.subscription);
|
|
3079
|
+
}
|
|
3080
|
+
// The event resolved only through a checkout session, so applying it links the reference:
|
|
3081
|
+
// events stored for it before now are replayed after it, newer ones applied and older ones skipped.
|
|
3082
|
+
const applied = await applyParsedWebhookEvent(deps, providerName, { ...event, billingState: state }, projection);
|
|
3083
|
+
const linked = applied?.subscription ??
|
|
3084
|
+
(await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId));
|
|
3085
|
+
if (linked) {
|
|
3086
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, linked);
|
|
3087
|
+
}
|
|
3088
|
+
return applied;
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Applies an event that resolved to a subscription. Events stored before its
|
|
3092
|
+
* reference was linked go first, so provider event-time order holds; an unread
|
|
3093
|
+
* window event does not hold this event back and is retried after it.
|
|
3094
|
+
*/
|
|
3095
|
+
async function applyEventToLinkedSubscription(deps, providerName, event, subscription) {
|
|
3096
|
+
const state = event.billingState;
|
|
3097
|
+
const { windowUnread } = await replayUnlinkedWebhookEventsOrThrow(deps, subscription, { deferWindowUnread: true });
|
|
3098
|
+
// This run's replay, or one another run finished, may have replaced the reference this event matched.
|
|
3099
|
+
const refreshed = await resolveProjectionContextForProviderState(deps, {
|
|
3100
|
+
provider: providerName,
|
|
3101
|
+
state,
|
|
3102
|
+
checkoutReferenceId: event.checkoutReferenceId
|
|
3103
|
+
});
|
|
3104
|
+
if (!refreshed) {
|
|
3105
|
+
// The event is retained, never applied to the subscription it matched nor used to bootstrap another.
|
|
3106
|
+
await storeUnlinkedWebhookEvent(deps, providerName, event, {
|
|
3107
|
+
providerReference: (state.providerSubscriptionId ?? state.providerChargeId),
|
|
3108
|
+
reason: 'provider_reference_replaced',
|
|
3109
|
+
metadataBillingAccountId: readBillingAccountMetadata(event, state)
|
|
2265
3110
|
});
|
|
3111
|
+
if (windowUnread) {
|
|
3112
|
+
// The replay that replaced the reference closed the window, so the unread event is now skipped;
|
|
3113
|
+
// this delivery's redelivery would no longer reach the subscription, so it is replayed here.
|
|
3114
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, subscription);
|
|
3115
|
+
}
|
|
3116
|
+
return null;
|
|
3117
|
+
}
|
|
3118
|
+
const linkedBefore = refreshed.subscription;
|
|
3119
|
+
const applied = await applyParsedWebhookEvent(deps, providerName, event, refreshed);
|
|
3120
|
+
// An event that adds a provider reference links the events stored for it, including an event
|
|
3121
|
+
// skipped as older than the subscription's state, which still links its missing references.
|
|
3122
|
+
const linkedAfter = applied?.subscription ?? (linkedBefore ? await getSubscriptionById(deps, linkedBefore.id) : null);
|
|
3123
|
+
const replayAgain = linkedAfter ?? linkedBefore;
|
|
3124
|
+
// A newer event closes the window, so the unread event is then skipped; one still unread fails this
|
|
3125
|
+
// delivery. One on a reference this event replaced was recorded as replaced by its write.
|
|
3126
|
+
if (replayAgain && (windowUnread || (linkedAfter && addsProviderReference(linkedBefore, linkedAfter)))) {
|
|
3127
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, replayAgain);
|
|
2266
3128
|
}
|
|
3129
|
+
return applied;
|
|
3130
|
+
}
|
|
3131
|
+
async function applyParsedWebhookEvent(deps, providerName, event, projection) {
|
|
2267
3132
|
if (projection.subscription &&
|
|
2268
3133
|
event.occurredAt &&
|
|
2269
|
-
|
|
3134
|
+
orderProviderEvent(projection.subscription, event.occurredAt) === 'skip') {
|
|
3135
|
+
await linkSkippedEventReferences(deps, projection.subscription, event.billingState);
|
|
2270
3136
|
deps.metrics.track('reconciliation.event.skipped', {
|
|
2271
3137
|
provider: providerName,
|
|
2272
3138
|
providerEventId: event.providerEventId,
|
|
@@ -2282,7 +3148,9 @@ async function processParsedWebhookEvent(deps, providerName, event) {
|
|
|
2282
3148
|
checkoutSession: projection.checkoutSession ?? undefined,
|
|
2283
3149
|
subscription: projection.subscription ?? undefined,
|
|
2284
3150
|
state: event.billingState,
|
|
2285
|
-
occurredAt: event.occurredAt
|
|
3151
|
+
occurredAt: event.occurredAt,
|
|
3152
|
+
// The provider event keys its lifecycle entry, so a delivery and a replay of it record it once.
|
|
3153
|
+
providerEventId: event.providerEventId
|
|
2286
3154
|
});
|
|
2287
3155
|
if (!applied) {
|
|
2288
3156
|
deps.metrics.track('reconciliation.event.skipped', {
|
|
@@ -2293,33 +3161,835 @@ async function processParsedWebhookEvent(deps, providerName, event) {
|
|
|
2293
3161
|
}
|
|
2294
3162
|
return applied;
|
|
2295
3163
|
}
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
const resolved = findCatalogPriceReference(deps.catalog, provider, reference);
|
|
2313
|
-
if (resolved) {
|
|
2314
|
-
return resolved;
|
|
2315
|
-
}
|
|
2316
|
-
}
|
|
3164
|
+
/**
|
|
3165
|
+
* Handles a verified event whose provider reference matches no local
|
|
3166
|
+
* subscription or checkout. Provider metadata naming an existing billing
|
|
3167
|
+
* account bootstraps the subscription from the provider record; any other
|
|
3168
|
+
* event is stored as unlinked, so the webhook is acknowledged and the event is
|
|
3169
|
+
* replayed once the reference is linked.
|
|
3170
|
+
*/
|
|
3171
|
+
async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, state) {
|
|
3172
|
+
const providerReference = state.providerSubscriptionId ?? state.providerChargeId;
|
|
3173
|
+
if (!providerReference) {
|
|
3174
|
+
deps.metrics.track('reconciliation.event.skipped', {
|
|
3175
|
+
provider: providerName,
|
|
3176
|
+
providerEventId: event.providerEventId,
|
|
3177
|
+
reason: 'provider_reference_missing'
|
|
3178
|
+
});
|
|
3179
|
+
return { handled: null };
|
|
2317
3180
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
3181
|
+
const metadataBillingAccountId = readBillingAccountMetadata(event, state);
|
|
3182
|
+
const existing = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
3183
|
+
let reason;
|
|
3184
|
+
const importPending = isImportPending(existing);
|
|
3185
|
+
if (importPending) {
|
|
3186
|
+
// Its import replays this event once it accepts the row; a refused import leaves it unlinked.
|
|
3187
|
+
reason = 'subscription_import_pending';
|
|
2320
3188
|
}
|
|
2321
|
-
|
|
2322
|
-
|
|
3189
|
+
else if (existing) {
|
|
3190
|
+
// A link that committed after this event's first lookup is applied to like any linked reference.
|
|
3191
|
+
const projection = await resolveProjectionContextForProviderState(deps, {
|
|
3192
|
+
provider: providerName,
|
|
3193
|
+
state,
|
|
3194
|
+
checkoutReferenceId: event.checkoutReferenceId
|
|
3195
|
+
});
|
|
3196
|
+
if (projection) {
|
|
3197
|
+
return { linked: projection };
|
|
3198
|
+
}
|
|
3199
|
+
// The subscription's billing account is gone; the reference must not be rebound to another one.
|
|
3200
|
+
reason = 'billing_account_unavailable';
|
|
3201
|
+
}
|
|
3202
|
+
else if (await namesReplacedReference(deps, providerName, event, state)) {
|
|
3203
|
+
// No subscription holds a reference a write replaced: the event never bootstraps another one.
|
|
3204
|
+
reason = 'provider_reference_replaced';
|
|
3205
|
+
}
|
|
3206
|
+
else if (metadataBillingAccountId) {
|
|
3207
|
+
const bootstrap = await bootstrapFromMetadata(deps, providerName, event, state, metadataBillingAccountId);
|
|
3208
|
+
if (typeof bootstrap !== 'string') {
|
|
3209
|
+
return bootstrap;
|
|
3210
|
+
}
|
|
3211
|
+
reason = bootstrap;
|
|
3212
|
+
}
|
|
3213
|
+
else {
|
|
3214
|
+
reason = 'billing_account_metadata_missing';
|
|
3215
|
+
}
|
|
3216
|
+
await storeUnlinkedWebhookEvent(deps, providerName, event, {
|
|
3217
|
+
providerReference,
|
|
3218
|
+
reason,
|
|
3219
|
+
metadataBillingAccountId
|
|
3220
|
+
});
|
|
3221
|
+
if (existing && !importPending) {
|
|
3222
|
+
return { handled: null };
|
|
3223
|
+
}
|
|
3224
|
+
// A link (or an import's acceptance) that committed while this event was being stored would have missed it;
|
|
3225
|
+
// replay it now. The replay skips a row still pending.
|
|
3226
|
+
const linked = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
3227
|
+
if (linked) {
|
|
3228
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, linked);
|
|
3229
|
+
}
|
|
3230
|
+
return { handled: null };
|
|
3231
|
+
}
|
|
3232
|
+
/** Bootstraps the subscription for the account the metadata names, or returns why the event is stored unlinked. */
|
|
3233
|
+
async function bootstrapFromMetadata(deps, providerName, event, state, billingAccountId) {
|
|
3234
|
+
const billingAccount = await getBillingAccountById(deps, billingAccountId);
|
|
3235
|
+
if (!billingAccount) {
|
|
3236
|
+
return 'billing_account_not_found';
|
|
3237
|
+
}
|
|
3238
|
+
const eventPrice = await resolveProviderEventPriceReference(deps, providerName, event, null);
|
|
3239
|
+
if (!eventPrice) {
|
|
3240
|
+
return 'price_not_found';
|
|
3241
|
+
}
|
|
3242
|
+
return {
|
|
3243
|
+
handled: await bootstrapSubscriptionFromWebhookEvent(deps, providerName, event, {
|
|
3244
|
+
billingAccount,
|
|
3245
|
+
plan: eventPrice.plan,
|
|
3246
|
+
price: eventPrice.price,
|
|
3247
|
+
state
|
|
3248
|
+
})
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
async function bootstrapSubscriptionFromWebhookEvent(deps, providerName, event, input) {
|
|
3252
|
+
// A concurrent delivery that creates the same reference first makes this an ordered update.
|
|
3253
|
+
const applied = await applyVerificationState(deps, {
|
|
3254
|
+
billingAccount: input.billingAccount,
|
|
3255
|
+
plan: input.plan,
|
|
3256
|
+
price: input.price,
|
|
3257
|
+
state: input.state,
|
|
3258
|
+
occurredAt: event.occurredAt,
|
|
3259
|
+
providerEventId: event.providerEventId
|
|
3260
|
+
});
|
|
3261
|
+
deps.metrics.track('webhook.subscription.bootstrapped', {
|
|
3262
|
+
provider: providerName,
|
|
3263
|
+
applied: Boolean(applied)
|
|
3264
|
+
});
|
|
3265
|
+
if (!applied) {
|
|
3266
|
+
deps.metrics.track('reconciliation.event.skipped', {
|
|
3267
|
+
provider: providerName,
|
|
3268
|
+
providerEventId: event.providerEventId,
|
|
3269
|
+
reason: 'out_of_order'
|
|
3270
|
+
});
|
|
3271
|
+
}
|
|
3272
|
+
const subscription = applied?.subscription ??
|
|
3273
|
+
(await findSubscriptionByProviderRef(deps, providerName, input.state.providerSubscriptionId, input.state.providerChargeId));
|
|
3274
|
+
if (subscription) {
|
|
3275
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, subscription);
|
|
3276
|
+
}
|
|
3277
|
+
return applied;
|
|
3278
|
+
}
|
|
3279
|
+
async function storeUnlinkedWebhookEvent(deps, providerName, event, input) {
|
|
3280
|
+
const now = deps.now();
|
|
3281
|
+
const timestamp = toIsoString(now);
|
|
3282
|
+
const record = {
|
|
3283
|
+
id: buildUnlinkedWebhookEventId(providerName, event.providerEventId),
|
|
3284
|
+
provider: providerName,
|
|
3285
|
+
providerEventId: event.providerEventId,
|
|
3286
|
+
providerReference: input.providerReference,
|
|
3287
|
+
// The charge of an event that also names its subscription, so linking either reference finds it.
|
|
3288
|
+
providerSecondaryReference: event.billingState?.providerChargeId && event.billingState.providerChargeId !== input.providerReference
|
|
3289
|
+
? event.billingState.providerChargeId
|
|
3290
|
+
: null,
|
|
3291
|
+
eventType: event.type,
|
|
3292
|
+
occurredAt: event.occurredAt ?? null,
|
|
3293
|
+
reason: input.reason,
|
|
3294
|
+
metadataBillingAccountId: input.metadataBillingAccountId,
|
|
3295
|
+
status: 'unlinked',
|
|
3296
|
+
event: serializeParsedWebhookEvent(event),
|
|
3297
|
+
replayClaimId: null,
|
|
3298
|
+
replayClaimedAt: null,
|
|
3299
|
+
replayedAt: null,
|
|
3300
|
+
subscriptionId: null,
|
|
3301
|
+
error: null,
|
|
3302
|
+
expiresAt: toIsoString(new Date(now.getTime() + UNLINKED_WEBHOOK_EVENT_RETENTION_MS)),
|
|
3303
|
+
createdAt: timestamp,
|
|
3304
|
+
updatedAt: timestamp
|
|
3305
|
+
};
|
|
3306
|
+
try {
|
|
3307
|
+
await deps.db.create({
|
|
3308
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3309
|
+
data: record,
|
|
3310
|
+
namespace: deps.namespace
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
catch (error) {
|
|
3314
|
+
// A redelivered or replayed event keeps the row it already has, but a replaced reference is recorded
|
|
3315
|
+
// on it, so the event never bootstraps a subscription later.
|
|
3316
|
+
if (!isDuplicateRecordError(error)) {
|
|
3317
|
+
throw error;
|
|
3318
|
+
}
|
|
3319
|
+
if (input.reason === 'provider_reference_replaced') {
|
|
3320
|
+
await deps.db.updateMany({
|
|
3321
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3322
|
+
where: [{ field: 'id', operator: 'eq', value: record.id }],
|
|
3323
|
+
data: { reason: input.reason, updatedAt: timestamp },
|
|
3324
|
+
namespace: deps.namespace
|
|
3325
|
+
});
|
|
3326
|
+
}
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
deps.metrics.track('webhook.event.unlinked', {
|
|
3330
|
+
provider: providerName,
|
|
3331
|
+
reason: input.reason
|
|
3332
|
+
});
|
|
3333
|
+
await purgeExpiredUnlinkedWebhookEvents(deps, timestamp);
|
|
3334
|
+
}
|
|
3335
|
+
async function purgeExpiredUnlinkedWebhookEvents(deps, timestamp) {
|
|
3336
|
+
try {
|
|
3337
|
+
await deps.db.deleteMany({
|
|
3338
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3339
|
+
where: [{ field: 'expiresAt', operator: 'lt', value: timestamp }],
|
|
3340
|
+
namespace: deps.namespace
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
catch (error) {
|
|
3344
|
+
// Retention cleanup must not make the provider redeliver an event that is already stored.
|
|
3345
|
+
deps.metrics.track('webhook.unlinked.purge_failed', {
|
|
3346
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
/**
|
|
3351
|
+
* Applies unexpired unlinked events for the subscription's provider references
|
|
3352
|
+
* in provider event-time order, as one ordered queue. When an applied or
|
|
3353
|
+
* skipped event gives the subscription a provider reference, including one it
|
|
3354
|
+
* held earlier in the run, the events stored for that reference (as primary or
|
|
3355
|
+
* secondary reference) are merged into the queue in order before the next
|
|
3356
|
+
* event, so an event naming a reference the subscription held only between two
|
|
3357
|
+
* replayed events is still found. An event
|
|
3358
|
+
* older than the subscription's current provider state (the last applied
|
|
3359
|
+
* event, or a live provider read) is marked skipped. An event that no longer
|
|
3360
|
+
* resolves to a subscription, because an earlier replayed event replaced its
|
|
3361
|
+
* reference, stays unlinked with reason `provider_reference_replaced` and the
|
|
3362
|
+
* replay continues past it. Every write that drops a reference records it as
|
|
3363
|
+
* replaced (`recordReplacedProviderReferences`) and marks the events still
|
|
3364
|
+
* unlinked on it, as does the end of the pass for one held during it and lost
|
|
3365
|
+
* since; they no longer count as left at the bound. An event inside a
|
|
3366
|
+
* committed mutation's window whose provider read fails (no queue) stays
|
|
3367
|
+
* unlinked with the error, the replay continues, and the operation fails
|
|
3368
|
+
* afterwards so it is retried. A
|
|
3369
|
+
* replay stops at the first event another replay holds or that fails to apply,
|
|
3370
|
+
* leaving it and later events for the next replay, so the order is kept.
|
|
3371
|
+
*/
|
|
3372
|
+
async function replayUnlinkedWebhookEvents(deps, subscription) {
|
|
3373
|
+
const result = { applied: 0, skipped: 0, pending: 0 };
|
|
3374
|
+
if (isImportPending(subscription)) {
|
|
3375
|
+
// A pending import row is not linked yet: its import replays the events once it accepts the row.
|
|
3376
|
+
return result;
|
|
3377
|
+
}
|
|
3378
|
+
const queue = createUnlinkedReplayQueue(deps, subscription.provider);
|
|
3379
|
+
let latest = subscription;
|
|
3380
|
+
await queue.discover(subscription);
|
|
3381
|
+
for (let processed = 0; processed < UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS; processed += 1) {
|
|
3382
|
+
const candidate = queue.next();
|
|
3383
|
+
if (!candidate) {
|
|
3384
|
+
break;
|
|
3385
|
+
}
|
|
3386
|
+
const step = await replayUnlinkedWebhookEvent(deps, candidate, result);
|
|
3387
|
+
if (step === 'stopped') {
|
|
3388
|
+
result.pending = queue.remaining() + 1;
|
|
3389
|
+
break;
|
|
3390
|
+
}
|
|
3391
|
+
if (step === 'continue') {
|
|
3392
|
+
// Left unlinked (or finished elsewhere): it is queued again if its reference is regained.
|
|
3393
|
+
queue.release(candidate);
|
|
3394
|
+
}
|
|
3395
|
+
// Applied events, skipped ones that linked a missing reference, and events another run finished
|
|
3396
|
+
// (which may have stopped before replaying what they linked) can add a reference.
|
|
3397
|
+
const current = await getSubscriptionById(deps, subscription.id);
|
|
3398
|
+
if (current) {
|
|
3399
|
+
latest = current;
|
|
3400
|
+
await queue.discover(current);
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
// Events on a reference held earlier in the pass and lost since (including to another run) are
|
|
3404
|
+
// marked replaced; they are no longer replayable.
|
|
3405
|
+
const latestReferences = providerReferencesOf(latest);
|
|
3406
|
+
const lost = queue.heldReferences().filter((reference) => !latestReferences.includes(reference));
|
|
3407
|
+
await recordReplacedReferenceEvents(deps, subscription.provider, lost, latestReferences);
|
|
3408
|
+
// An unread event on a reference the subscription no longer holds is recorded as replaced, not retried.
|
|
3409
|
+
result.windowUnread = result.unreadRows?.find((unread) => namesAnyReference(unread.row, latestReferences));
|
|
3410
|
+
const remaining = queue.remaining(latestReferences);
|
|
3411
|
+
if (!result.held && !result.failure && remaining > 0) {
|
|
3412
|
+
// The bound was reached with replayable events left: the replay is unfinished, so the operation is retried.
|
|
3413
|
+
result.pending = remaining;
|
|
3414
|
+
result.bounded = true;
|
|
3415
|
+
}
|
|
3416
|
+
if (result.applied > 0 || result.skipped > 0) {
|
|
3417
|
+
deps.metrics.track('webhook.unlinked.replayed', {
|
|
3418
|
+
provider: subscription.provider,
|
|
3419
|
+
applied: result.applied,
|
|
3420
|
+
skipped: result.skipped
|
|
3421
|
+
});
|
|
3422
|
+
}
|
|
3423
|
+
return result;
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* The ordered replay queue. `discover` merges the unexpired events stored for
|
|
3427
|
+
* every reference the subscription gained since the previous discovery (a
|
|
3428
|
+
* first gain or a regain); `release` lets a visited event that stayed unlinked
|
|
3429
|
+
* be queued again. `heldReferences` lists every reference seen in the pass.
|
|
3430
|
+
*/
|
|
3431
|
+
function createUnlinkedReplayQueue(deps, provider) {
|
|
3432
|
+
const timestamp = toIsoString(deps.now());
|
|
3433
|
+
let observed = new Set();
|
|
3434
|
+
const held = new Set();
|
|
3435
|
+
const queued = new Set();
|
|
3436
|
+
let pending = [];
|
|
3437
|
+
return {
|
|
3438
|
+
async discover(subscription) {
|
|
3439
|
+
const current = providerReferencesOf(subscription);
|
|
3440
|
+
const gained = current.filter((reference) => !observed.has(reference));
|
|
3441
|
+
observed = new Set(current);
|
|
3442
|
+
current.forEach((reference) => held.add(reference));
|
|
3443
|
+
if (gained.length === 0) {
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3446
|
+
const found = await findUnlinkedWebhookEventsForReferences(deps, provider, gained, timestamp);
|
|
3447
|
+
const added = found.filter((row) => !queued.has(row.id));
|
|
3448
|
+
added.forEach((row) => queued.add(row.id));
|
|
3449
|
+
pending = [...pending, ...added].sort(compareUnlinkedWebhookEvents);
|
|
3450
|
+
},
|
|
3451
|
+
release: (row) => queued.delete(row.id),
|
|
3452
|
+
/** Every reference the subscription held during the pass. */
|
|
3453
|
+
heldReferences: () => [...held],
|
|
3454
|
+
next: () => pending.shift(),
|
|
3455
|
+
/** Queued events, or with `references` only those naming one: the ones a later replay still finds. */
|
|
3456
|
+
remaining: (references) => references ? pending.filter((row) => namesAnyReference(row, references)).length : pending.length
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* Marks every unfinished event stored for a reference a subscription dropped,
|
|
3461
|
+
* and naming none it kept, with reason `provider_reference_replaced`, whatever
|
|
3462
|
+
* it was left as (window-unread, unresolved, not visited, or claimed by a
|
|
3463
|
+
* replay run that may never release it). The recorded reference, not this
|
|
3464
|
+
* reason, keeps such an event from bootstrapping. Only the reason (and an
|
|
3465
|
+
* unclaimed event's last error) is written, never on an event another run
|
|
3466
|
+
* finished, so a run holding the event
|
|
3467
|
+
* keeps its claim. A regain still replays it, since discovery ignores the
|
|
3468
|
+
* reason, as does the subscription that holds a reference it names.
|
|
3469
|
+
*/
|
|
3470
|
+
async function recordReplacedReferenceEvents(deps, provider, dropped, kept) {
|
|
3471
|
+
if (dropped.length === 0) {
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
const timestamp = toIsoString(deps.now());
|
|
3475
|
+
const rows = await findUnlinkedWebhookEventsForReferences(deps, provider, dropped, timestamp);
|
|
3476
|
+
const stale = rows.filter((row) => row.reason !== 'provider_reference_replaced' && !namesAnyReference(row, kept));
|
|
3477
|
+
const replaced = { reason: 'provider_reference_replaced', updatedAt: timestamp };
|
|
3478
|
+
const record = (row, statuses, data) => deps.db.updateMany({
|
|
3479
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3480
|
+
where: [
|
|
3481
|
+
{ field: 'id', operator: 'eq', value: row.id },
|
|
3482
|
+
{ field: 'status', operator: 'in', value: statuses }
|
|
3483
|
+
],
|
|
3484
|
+
data,
|
|
3485
|
+
namespace: deps.namespace
|
|
3486
|
+
});
|
|
3487
|
+
// An unclaimed event also drops its last replay error; a claimed one gets only the reason.
|
|
3488
|
+
await Promise.all(stale.map(async (row) => {
|
|
3489
|
+
if ((await record(row, ['unlinked'], { ...replaced, error: null })) === 0) {
|
|
3490
|
+
await record(row, ['unlinked', 'replaying'], replaced);
|
|
3491
|
+
}
|
|
3492
|
+
}));
|
|
3493
|
+
}
|
|
3494
|
+
/**
|
|
3495
|
+
* Before a write from `before` to `after`, records each reference it drops, and
|
|
3496
|
+
* each one `before` has pending that `after` does not hold, as replaced
|
|
3497
|
+
* (`recordReplacedProviderReferences`), then marks the retained events on them.
|
|
3498
|
+
* A failure throws, except during a committed provider mutation, which is never
|
|
3499
|
+
* failed by it: the references it could not record are returned, and its write
|
|
3500
|
+
* saves them pending (`withPendingReplacements`). Pending references are
|
|
3501
|
+
* recorded here before the caller's write clears them, so a guard that reads
|
|
3502
|
+
* pending and then recorded references never misses one
|
|
3503
|
+
* (`namesReplacedReference`).
|
|
3504
|
+
*/
|
|
3505
|
+
async function recordDroppedReferences(deps, before, after, committed) {
|
|
3506
|
+
const kept = providerReferencesOf(after);
|
|
3507
|
+
const dropped = [...new Set([...providerReferencesOf(before), ...pendingReplacedReferencesOf(before)])].filter((reference) => !kept.includes(reference));
|
|
3508
|
+
if (dropped.length === 0) {
|
|
3509
|
+
return [];
|
|
3510
|
+
}
|
|
3511
|
+
try {
|
|
3512
|
+
await recordReplacedProviderReferences(deps, before, dropped);
|
|
3513
|
+
}
|
|
3514
|
+
catch (error) {
|
|
3515
|
+
if (!committed) {
|
|
3516
|
+
throw error;
|
|
3517
|
+
}
|
|
3518
|
+
return dropped;
|
|
3519
|
+
}
|
|
3520
|
+
try {
|
|
3521
|
+
await recordReplacedReferenceEvents(deps, before.provider, dropped, kept);
|
|
3522
|
+
}
|
|
3523
|
+
catch (error) {
|
|
3524
|
+
// Once the reference is recorded, the events' reason is informational.
|
|
3525
|
+
if (!committed) {
|
|
3526
|
+
throw error;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
return [];
|
|
3530
|
+
}
|
|
3531
|
+
/**
|
|
3532
|
+
* Records references a subscription dropped in `replacedProviderReferences`,
|
|
3533
|
+
* one row per provider and reference, naming the subscription that dropped it
|
|
3534
|
+
* last. While no subscription holds the reference, no event naming it, with
|
|
3535
|
+
* any event id or billing account metadata, bootstraps a subscription.
|
|
3536
|
+
*/
|
|
3537
|
+
async function recordReplacedProviderReferences(deps, subscription, references) {
|
|
3538
|
+
const timestamp = toIsoString(deps.now());
|
|
3539
|
+
const data = { subscriptionId: subscription.id, billingAccountId: subscription.billingAccountId, replacedAt: timestamp, updatedAt: timestamp };
|
|
3540
|
+
await Promise.all(references.map(async (providerReference) => {
|
|
3541
|
+
const id = `rpr_${subscription.provider}_${Buffer.from(providerReference).toString('base64url')}`;
|
|
3542
|
+
const update = () => deps.db.updateMany({
|
|
3543
|
+
model: TABLES.replacedProviderReferences,
|
|
3544
|
+
where: [{ field: 'id', operator: 'eq', value: id }],
|
|
3545
|
+
data,
|
|
3546
|
+
namespace: deps.namespace
|
|
3547
|
+
});
|
|
3548
|
+
if ((await update()) > 0) {
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
try {
|
|
3552
|
+
await deps.db.create({
|
|
3553
|
+
model: TABLES.replacedProviderReferences,
|
|
3554
|
+
data: { id, provider: subscription.provider, providerReference, ...data, createdAt: timestamp },
|
|
3555
|
+
namespace: deps.namespace
|
|
3556
|
+
});
|
|
3557
|
+
}
|
|
3558
|
+
catch (error) {
|
|
3559
|
+
// A concurrent write recorded it first.
|
|
3560
|
+
if (!isDuplicateRecordError(error)) {
|
|
3561
|
+
throw error;
|
|
3562
|
+
}
|
|
3563
|
+
await update();
|
|
3564
|
+
}
|
|
3565
|
+
}));
|
|
3566
|
+
}
|
|
3567
|
+
/** The references a subscription's write saved pending, without any it holds again. */
|
|
3568
|
+
function pendingReplacedReferencesOf(subscription) {
|
|
3569
|
+
const pending = subscription.metadata?.[PENDING_REPLACED_REFERENCES_METADATA_KEY];
|
|
3570
|
+
const held = new Set(providerReferencesOf(subscription));
|
|
3571
|
+
return Array.isArray(pending)
|
|
3572
|
+
? pending.filter((reference) => typeof reference === 'string' && !held.has(reference))
|
|
3573
|
+
: [];
|
|
3574
|
+
}
|
|
3575
|
+
/** A write's metadata and flag for the references it saves pending; both are cleared once none are. */
|
|
3576
|
+
function withPendingReplacements(metadata, pending) {
|
|
3577
|
+
const { [PENDING_REPLACED_REFERENCES_METADATA_KEY]: _previous, ...rest } = metadata ?? {};
|
|
3578
|
+
return pending.length > 0
|
|
3579
|
+
? { metadata: { ...rest, [PENDING_REPLACED_REFERENCES_METADATA_KEY]: pending }, replacedReferencesPending: true }
|
|
3580
|
+
: { metadata: rest, replacedReferencesPending: null };
|
|
3581
|
+
}
|
|
3582
|
+
/**
|
|
3583
|
+
* Whether an event that resolves to no subscription names a replaced
|
|
3584
|
+
* reference, whatever its event id or the account its metadata names: one a
|
|
3585
|
+
* subscription write dropped and recorded, one a committed mutation's write
|
|
3586
|
+
* saved pending (any subscription of the provider), or the one it was
|
|
3587
|
+
* retained on when a replay replaced it. A reference a subscription holds
|
|
3588
|
+
* again resolves to it first. Only bootstrap is gated by it.
|
|
3589
|
+
*
|
|
3590
|
+
* Linearization contract with the writers: a write that drops a held
|
|
3591
|
+
* reference either records it (`recordDroppedReferences`) before the write or
|
|
3592
|
+
* saves it pending in that same write; a later write records a pending
|
|
3593
|
+
* reference before its write clears the pending flag; recorded references are
|
|
3594
|
+
* never deleted. The reads therefore run one after another in the opposite
|
|
3595
|
+
* order, after the caller found no holder: pending, then recorded, then the
|
|
3596
|
+
* retained event. A writer that clears pending between them has already
|
|
3597
|
+
* recorded the reference, so one of the reads sees it.
|
|
3598
|
+
*/
|
|
3599
|
+
async function namesReplacedReference(deps, provider, event, state) {
|
|
3600
|
+
const references = [state.providerSubscriptionId, state.providerChargeId].filter((reference) => typeof reference === 'string' && reference.length > 0);
|
|
3601
|
+
const named = new Set(references);
|
|
3602
|
+
if (named.size > 0) {
|
|
3603
|
+
const pending = await deps.db.findMany({
|
|
3604
|
+
model: TABLES.subscriptions,
|
|
3605
|
+
where: [
|
|
3606
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
3607
|
+
{ field: 'replacedReferencesPending', operator: 'eq', value: true }
|
|
3608
|
+
],
|
|
3609
|
+
namespace: deps.namespace
|
|
3610
|
+
});
|
|
3611
|
+
if (pending.some((subscription) => pendingReplacedReferencesOf(subscription).some((reference) => named.has(reference)))) {
|
|
3612
|
+
return true;
|
|
3613
|
+
}
|
|
3614
|
+
const replaced = await deps.db.findMany({
|
|
3615
|
+
model: TABLES.replacedProviderReferences,
|
|
3616
|
+
where: [
|
|
3617
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
3618
|
+
{ field: 'providerReference', operator: 'in', value: references }
|
|
3619
|
+
],
|
|
3620
|
+
namespace: deps.namespace
|
|
3621
|
+
});
|
|
3622
|
+
if (replaced.length > 0) {
|
|
3623
|
+
return true;
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
const retained = await deps.db.findOne({
|
|
3627
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3628
|
+
where: [{ field: 'id', operator: 'eq', value: buildUnlinkedWebhookEventId(provider, event.providerEventId) }],
|
|
3629
|
+
namespace: deps.namespace
|
|
3630
|
+
});
|
|
3631
|
+
return retained?.reason === 'provider_reference_replaced';
|
|
3632
|
+
}
|
|
3633
|
+
function namesAnyReference(row, references) {
|
|
3634
|
+
return [row.providerReference, row.providerSecondaryReference].some((reference) => typeof reference === 'string' && references.includes(reference));
|
|
3635
|
+
}
|
|
3636
|
+
async function findUnlinkedWebhookEventsForReferences(deps, provider, references, timestamp) {
|
|
3637
|
+
const byField = await Promise.all(['providerReference', 'providerSecondaryReference'].map((field) => deps.db.findMany({
|
|
3638
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3639
|
+
where: [
|
|
3640
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
3641
|
+
{ field, operator: 'in', value: references },
|
|
3642
|
+
{ field: 'status', operator: 'in', value: ['unlinked', 'replaying'] }
|
|
3643
|
+
],
|
|
3644
|
+
namespace: deps.namespace
|
|
3645
|
+
})));
|
|
3646
|
+
const rows = new Map(byField.flat().map((row) => [row.id, row]));
|
|
3647
|
+
return [...rows.values()].filter((row) => row.expiresAt > timestamp);
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* Claims and applies one retained event, then finishes or releases its row.
|
|
3651
|
+
* `finished`: applied or skipped; `continue`: finished by another run, or
|
|
3652
|
+
* displaced, unresolved or window-unread and left unlinked; `stopped`: held by
|
|
3653
|
+
* another run or failed.
|
|
3654
|
+
* The caller looks for newly linked references after every step but `stopped`.
|
|
3655
|
+
*/
|
|
3656
|
+
async function replayUnlinkedWebhookEvent(deps, candidate, result) {
|
|
3657
|
+
const claimId = deps.generateId('uwr');
|
|
3658
|
+
const claim = await claimUnlinkedWebhookEvent(deps, candidate, claimId);
|
|
3659
|
+
if (claim === 'finished') {
|
|
3660
|
+
return 'continue';
|
|
3661
|
+
}
|
|
3662
|
+
if (claim === 'held') {
|
|
3663
|
+
result.held = true;
|
|
3664
|
+
return 'stopped';
|
|
3665
|
+
}
|
|
3666
|
+
let outcome = null;
|
|
3667
|
+
let failure = null;
|
|
3668
|
+
try {
|
|
3669
|
+
outcome = await applyUnlinkedWebhookEvent(deps, candidate);
|
|
3670
|
+
}
|
|
3671
|
+
catch (error) {
|
|
3672
|
+
// Stop on any failure, whatever its message: a later event must not advance past this one.
|
|
3673
|
+
failure = errorMessage(error);
|
|
3674
|
+
result.failure = { error };
|
|
3675
|
+
}
|
|
3676
|
+
const finishedAt = toIsoString(deps.now());
|
|
3677
|
+
await deps.db.updateMany({
|
|
3678
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3679
|
+
where: [
|
|
3680
|
+
{ field: 'id', operator: 'eq', value: candidate.id },
|
|
3681
|
+
{ field: 'replayClaimId', operator: 'eq', value: claimId }
|
|
3682
|
+
],
|
|
3683
|
+
data: unlinkedReplayRowUpdate(outcome, failure, finishedAt),
|
|
3684
|
+
namespace: deps.namespace
|
|
3685
|
+
});
|
|
3686
|
+
if (!outcome) {
|
|
3687
|
+
deps.metrics.track('webhook.unlinked.replay_stopped', {
|
|
3688
|
+
provider: candidate.provider,
|
|
3689
|
+
providerEventId: candidate.providerEventId,
|
|
3690
|
+
reason: 'error'
|
|
3691
|
+
});
|
|
3692
|
+
return 'stopped';
|
|
3693
|
+
}
|
|
3694
|
+
if (outcome.kind === 'replayed' || outcome.kind === 'skipped') {
|
|
3695
|
+
result[outcome.kind === 'replayed' ? 'applied' : 'skipped'] += 1;
|
|
3696
|
+
return 'finished';
|
|
3697
|
+
}
|
|
3698
|
+
if (outcome.kind === 'window_unread') {
|
|
3699
|
+
// Retained with its error; the operation fails after the pass, so its retry reads the provider again.
|
|
3700
|
+
result.unreadRows = [...(result.unreadRows ?? []), { row: candidate, error: outcome.error }];
|
|
3701
|
+
}
|
|
3702
|
+
// A displaced event, delivered in order, would have been stored unlinked as well, so it stays
|
|
3703
|
+
// unlinked (and replays if its reference is linked again) and does not hold back later events.
|
|
3704
|
+
deps.metrics.track('webhook.unlinked.replay_unresolved', {
|
|
3705
|
+
provider: candidate.provider,
|
|
3706
|
+
providerEventId: candidate.providerEventId,
|
|
3707
|
+
reason: outcome.kind
|
|
3708
|
+
});
|
|
3709
|
+
return 'continue';
|
|
3710
|
+
}
|
|
3711
|
+
/** Finishes a replayed or skipped row, or releases it as unlinked with its error and, if displaced, reason. */
|
|
3712
|
+
function unlinkedReplayRowUpdate(outcome, failure, finishedAt) {
|
|
3713
|
+
const released = { replayClaimId: null, replayClaimedAt: null, updatedAt: finishedAt };
|
|
3714
|
+
if (outcome?.kind === 'replayed' || outcome?.kind === 'skipped') {
|
|
3715
|
+
return { ...released, status: outcome.kind, subscriptionId: outcome.subscriptionId, replayedAt: finishedAt, error: null };
|
|
3716
|
+
}
|
|
3717
|
+
return {
|
|
3718
|
+
...released,
|
|
3719
|
+
status: 'unlinked',
|
|
3720
|
+
error: outcome?.kind === 'window_unread' ? errorMessage(outcome.error) : failure,
|
|
3721
|
+
// A replaced reference is recorded, so the event never bootstraps another subscription later.
|
|
3722
|
+
...(outcome?.kind === 'displaced' ? { reason: 'provider_reference_replaced' } : {})
|
|
3723
|
+
};
|
|
3724
|
+
}
|
|
3725
|
+
function errorMessage(error) {
|
|
3726
|
+
return error instanceof Error ? error.message : String(error);
|
|
3727
|
+
}
|
|
3728
|
+
function providerReferencesOf(subscription) {
|
|
3729
|
+
return [...new Set([subscription.providerSubscriptionId, subscription.providerChargeId])].filter((reference) => typeof reference === 'string' && reference.length > 0);
|
|
3730
|
+
}
|
|
3731
|
+
/** Whether a write gave the subscription a provider reference it did not have, linking events stored for it. */
|
|
3732
|
+
function addsProviderReference(before, after) {
|
|
3733
|
+
const previous = before ? providerReferencesOf(before) : [];
|
|
3734
|
+
return providerReferencesOf(after).some((reference) => !previous.includes(reference));
|
|
3735
|
+
}
|
|
3736
|
+
async function applyUnlinkedWebhookEvent(deps, row) {
|
|
3737
|
+
const event = deserializeParsedWebhookEvent(row.event);
|
|
3738
|
+
const state = event?.billingState;
|
|
3739
|
+
if (!event || !state) {
|
|
3740
|
+
return { kind: 'unresolved' };
|
|
3741
|
+
}
|
|
3742
|
+
const projection = await resolveProjectionContextForProviderState(deps, {
|
|
3743
|
+
provider: row.provider,
|
|
3744
|
+
state,
|
|
3745
|
+
checkoutReferenceId: event.checkoutReferenceId
|
|
3746
|
+
});
|
|
3747
|
+
if (!projection?.subscription) {
|
|
3748
|
+
// The row was found by a reference of the subscription, which an event replayed before it replaced.
|
|
3749
|
+
return { kind: 'displaced' };
|
|
3750
|
+
}
|
|
3751
|
+
// An event without a provider time is ordered by when it was stored, through the same ordered write.
|
|
3752
|
+
const occurredAt = event.occurredAt ?? row.createdAt;
|
|
3753
|
+
let applied;
|
|
3754
|
+
try {
|
|
3755
|
+
applied = await applyParsedWebhookEvent(deps, row.provider, { ...event, occurredAt, billingState: state }, projection);
|
|
3756
|
+
}
|
|
3757
|
+
catch (error) {
|
|
3758
|
+
// An unread event inside a mutation window stays retained without holding back the events after it.
|
|
3759
|
+
if (isWindowReadFailure(error)) {
|
|
3760
|
+
return { kind: 'window_unread', error };
|
|
3761
|
+
}
|
|
3762
|
+
throw error;
|
|
3763
|
+
}
|
|
3764
|
+
return applied
|
|
3765
|
+
? { kind: 'replayed', subscriptionId: applied.subscription.id }
|
|
3766
|
+
: { kind: 'skipped', subscriptionId: projection.subscription.id };
|
|
3767
|
+
}
|
|
3768
|
+
/**
|
|
3769
|
+
* Claims a row for this replay run. `finished` means another run completed it
|
|
3770
|
+
* since it was read; `held` means another run holds an unexpired claim on it,
|
|
3771
|
+
* or released it after a failure, so it is not finished.
|
|
3772
|
+
*/
|
|
3773
|
+
async function claimUnlinkedWebhookEvent(deps, row, claimId) {
|
|
3774
|
+
const claimTime = deps.now();
|
|
3775
|
+
const where = [
|
|
3776
|
+
{ field: 'id', operator: 'eq', value: row.id },
|
|
3777
|
+
{ field: 'status', operator: 'eq', value: row.status }
|
|
3778
|
+
];
|
|
3779
|
+
if (row.status === 'replaying') {
|
|
3780
|
+
const previousClaimedAt = row.replayClaimedAt ? Date.parse(row.replayClaimedAt) : Number.NaN;
|
|
3781
|
+
if (Number.isFinite(previousClaimedAt) &&
|
|
3782
|
+
claimTime.getTime() - previousClaimedAt < UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS) {
|
|
3783
|
+
return 'held';
|
|
3784
|
+
}
|
|
3785
|
+
where.push({ field: 'replayClaimId', operator: 'eq', value: row.replayClaimId ?? null });
|
|
3786
|
+
}
|
|
3787
|
+
const claimed = await deps.db.updateMany({
|
|
3788
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3789
|
+
where,
|
|
3790
|
+
data: {
|
|
3791
|
+
status: 'replaying',
|
|
3792
|
+
replayClaimId: claimId,
|
|
3793
|
+
replayClaimedAt: toIsoString(claimTime),
|
|
3794
|
+
updatedAt: toIsoString(claimTime)
|
|
3795
|
+
},
|
|
3796
|
+
namespace: deps.namespace
|
|
3797
|
+
});
|
|
3798
|
+
if (claimed === 1) {
|
|
3799
|
+
return 'claimed';
|
|
3800
|
+
}
|
|
3801
|
+
const current = await deps.db.findOne({
|
|
3802
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3803
|
+
where: [{ field: 'id', operator: 'eq', value: row.id }],
|
|
3804
|
+
namespace: deps.namespace
|
|
3805
|
+
});
|
|
3806
|
+
return !current || current.status === 'replayed' || current.status === 'skipped' ? 'finished' : 'held';
|
|
3807
|
+
}
|
|
3808
|
+
function compareUnlinkedWebhookEvents(left, right) {
|
|
3809
|
+
const leftTime = left.occurredAt ? parseProviderEventTime(left.occurredAt) : null;
|
|
3810
|
+
const rightTime = right.occurredAt ? parseProviderEventTime(right.occurredAt) : null;
|
|
3811
|
+
if (leftTime !== rightTime) {
|
|
3812
|
+
// Events without a provider time cannot be ordered against timed ones and go last.
|
|
3813
|
+
if (leftTime === null) {
|
|
3814
|
+
return 1;
|
|
3815
|
+
}
|
|
3816
|
+
if (rightTime === null) {
|
|
3817
|
+
return -1;
|
|
3818
|
+
}
|
|
3819
|
+
return leftTime - rightTime;
|
|
3820
|
+
}
|
|
3821
|
+
if (left.createdAt !== right.createdAt) {
|
|
3822
|
+
return left.createdAt < right.createdAt ? -1 : 1;
|
|
3823
|
+
}
|
|
3824
|
+
if (left.id === right.id) {
|
|
3825
|
+
return 0;
|
|
3826
|
+
}
|
|
3827
|
+
return left.id < right.id ? -1 : 1;
|
|
3828
|
+
}
|
|
3829
|
+
/**
|
|
3830
|
+
* Replays and fails the operation that linked the reference unless every
|
|
3831
|
+
* retained event for it is finished, so that operation is retried (a webhook
|
|
3832
|
+
* receipt stays unprocessed and the provider redelivers it, a queued webhook
|
|
3833
|
+
* job is retried after the replay lease expires, and checkout verification or
|
|
3834
|
+
* restore can be called again). An event that could not be applied is
|
|
3835
|
+
* released first and its error rethrown; an event another replay run still
|
|
3836
|
+
* holds, including a run interrupted before it released the event, raises a
|
|
3837
|
+
* lease conflict. An event inside a mutation window whose read failed raises
|
|
3838
|
+
* that window-read error once the rest of the replay is done, unless its
|
|
3839
|
+
* reference was replaced meanwhile or `deferWindowUnread` returns it for the
|
|
3840
|
+
* caller to raise later.
|
|
3841
|
+
*/
|
|
3842
|
+
async function replayUnlinkedWebhookEventsOrThrow(deps, subscription, options = {}) {
|
|
3843
|
+
const result = await replayUnlinkedWebhookEvents(deps, subscription);
|
|
3844
|
+
if (result.failure) {
|
|
3845
|
+
throw result.failure.error;
|
|
3846
|
+
}
|
|
3847
|
+
if (result.held) {
|
|
3848
|
+
throw reconciliationLeaseConflict('Unlinked webhook events for this provider reference are being replayed');
|
|
3849
|
+
}
|
|
3850
|
+
if (result.bounded) {
|
|
3851
|
+
throw unlinkedReplayBounded(result.applied + result.skipped > 0);
|
|
3852
|
+
}
|
|
3853
|
+
if (result.windowUnread && !options.deferWindowUnread) {
|
|
3854
|
+
throw result.windowUnread.error;
|
|
3855
|
+
}
|
|
3856
|
+
return result;
|
|
3857
|
+
}
|
|
3858
|
+
const unlinkedReplayBoundErrors = new WeakSet();
|
|
3859
|
+
const productiveReplayBoundErrors = new WeakSet();
|
|
3860
|
+
/**
|
|
3861
|
+
* An unfinished replay that reached its bound, reported as `bounded`. One that
|
|
3862
|
+
* finished events (`productive`) is continued by a queue worker in a fresh
|
|
3863
|
+
* message; otherwise, and without a queue, it is retried like a lease conflict.
|
|
3864
|
+
*/
|
|
3865
|
+
function unlinkedReplayBounded(productive) {
|
|
3866
|
+
const error = reconciliationLeaseConflict('Unlinked webhook event replay reached its bound with events left');
|
|
3867
|
+
unlinkedReplayBoundErrors.add(error);
|
|
3868
|
+
if (productive) {
|
|
3869
|
+
productiveReplayBoundErrors.add(error);
|
|
3870
|
+
}
|
|
3871
|
+
return error;
|
|
3872
|
+
}
|
|
3873
|
+
function isProductiveReplayBound(error) {
|
|
3874
|
+
return typeof error === 'object' && error !== null && productiveReplayBoundErrors.has(error);
|
|
3875
|
+
}
|
|
3876
|
+
function replayDeferredReason(error) {
|
|
3877
|
+
if (typeof error === 'object' && error !== null && unlinkedReplayBoundErrors.has(error)) {
|
|
3878
|
+
return 'bounded';
|
|
3879
|
+
}
|
|
3880
|
+
return isReconciliationLeaseConflict(error) ? 'held' : 'error';
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* Applies state from a live provider read or a provider mutation response
|
|
3884
|
+
* (checkout verification, restore, sync, cancel, change, resume, refund).
|
|
3885
|
+
* `liveStateObservedAt` is when the provider call started: a read saves it as
|
|
3886
|
+
* the subscription's provider-state time, and a state an event newer than it
|
|
3887
|
+
* has superseded only links its references. A committed mutation is ordered by
|
|
3888
|
+
* `committedAt`, when the provider call returned, instead. The retained unlinked events for the
|
|
3889
|
+
* resulting reference set are then replayed every time, not only when this
|
|
3890
|
+
* call added a reference, so a retry after an interrupted replay applies them;
|
|
3891
|
+
* events older than the read are skipped. Returns the projection after replay.
|
|
3892
|
+
*
|
|
3893
|
+
* A read (verification, restore, sync) fails when the replay does not finish,
|
|
3894
|
+
* so the read is retried. After a provider mutation has committed, the replay
|
|
3895
|
+
* is best-effort (`onReplayDeferred` set): an unfinished replay is recorded and
|
|
3896
|
+
* handed to `onReplayDeferred`, its events stay retained for the next webhook,
|
|
3897
|
+
* sync or live read, and the mutation still returns its result, because
|
|
3898
|
+
* retrying it would repeat the provider mutation.
|
|
3899
|
+
*/
|
|
3900
|
+
async function applyLiveProviderState(deps, input, options = {}) {
|
|
3901
|
+
const result = await applyVerificationState(deps, input);
|
|
3902
|
+
if (!options.onReplayDeferred) {
|
|
3903
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, result.subscription);
|
|
3904
|
+
return refreshProjectionAfterReplay(deps, result);
|
|
3905
|
+
}
|
|
3906
|
+
await replayAfterCommittedMutation(deps, result, options.onReplayDeferred);
|
|
3907
|
+
try {
|
|
3908
|
+
return { ...(await refreshProjectionAfterReplay(deps, result)), providerStateRaced: result.providerStateRaced };
|
|
3909
|
+
}
|
|
3910
|
+
catch (error) {
|
|
3911
|
+
// The provider mutation and its state are committed; a failed re-read returns the written state.
|
|
3912
|
+
deps.metrics.track('webhook.unlinked.replay_refresh_failed', {
|
|
3913
|
+
provider: result.subscription.provider,
|
|
3914
|
+
subscriptionId: result.subscription.id,
|
|
3915
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3916
|
+
});
|
|
3917
|
+
return result;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
/** A replay after a committed provider mutation that cannot finish now is deferred, never failing the mutation. */
|
|
3921
|
+
async function replayAfterCommittedMutation(deps, { subscription }, onReplayDeferred) {
|
|
3922
|
+
try {
|
|
3923
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, subscription);
|
|
3924
|
+
}
|
|
3925
|
+
catch (error) {
|
|
3926
|
+
deps.metrics.track('webhook.unlinked.replay_deferred', {
|
|
3927
|
+
provider: subscription.provider,
|
|
3928
|
+
subscriptionId: subscription.id,
|
|
3929
|
+
reason: replayDeferredReason(error),
|
|
3930
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3931
|
+
});
|
|
3932
|
+
try {
|
|
3933
|
+
await onReplayDeferred(subscription);
|
|
3934
|
+
}
|
|
3935
|
+
catch (deferError) {
|
|
3936
|
+
deps.metrics.track('webhook.unlinked.replay_defer_failed', {
|
|
3937
|
+
provider: subscription.provider,
|
|
3938
|
+
subscriptionId: subscription.id,
|
|
3939
|
+
error: deferError instanceof Error ? deferError.message : String(deferError)
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
/** Another run may have replayed events for these references since the write, so the state is read again. */
|
|
3945
|
+
async function refreshProjectionAfterReplay(deps, result) {
|
|
3946
|
+
const subscription = (await getSubscriptionById(deps, result.subscription.id)) ?? result.subscription;
|
|
3947
|
+
const entitlements = (await findEntitlementSnapshot(deps, subscription.billingAccountId)) ?? result.entitlements;
|
|
3948
|
+
const checkoutSession = result.checkoutSession
|
|
3949
|
+
? ((await getCheckoutSession(deps, result.checkoutSession.checkoutSessionId)) ?? result.checkoutSession)
|
|
3950
|
+
: undefined;
|
|
3951
|
+
return { checkoutSession, subscription, entitlements };
|
|
3952
|
+
}
|
|
3953
|
+
function readBillingAccountMetadata(event, state) {
|
|
3954
|
+
const stateRaw = asRecord(state.raw);
|
|
3955
|
+
const candidates = [
|
|
3956
|
+
asRecord(stateRaw?.metadata),
|
|
3957
|
+
asRecord(event.raw.metadata),
|
|
3958
|
+
asRecord(asRecord(event.raw.data)?.metadata)
|
|
3959
|
+
];
|
|
3960
|
+
for (const metadata of candidates) {
|
|
3961
|
+
const value = metadata ? readString(metadata, BILLING_ACCOUNT_METADATA_KEY) : null;
|
|
3962
|
+
if (value) {
|
|
3963
|
+
return value;
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
return null;
|
|
3967
|
+
}
|
|
3968
|
+
function buildUnlinkedWebhookEventId(provider, providerEventId) {
|
|
3969
|
+
return `uwe_${provider}_${Buffer.from(providerEventId).toString('base64url')}`;
|
|
3970
|
+
}
|
|
3971
|
+
async function resolveProviderEventPriceReference(deps, provider, event, subscription) {
|
|
3972
|
+
const normalizedType = event.type.toLowerCase();
|
|
3973
|
+
const schedulingOnly = normalizedType.includes('change_renewal_pref');
|
|
3974
|
+
if (!schedulingOnly) {
|
|
3975
|
+
const raw = asRecord(event.billingState?.raw);
|
|
3976
|
+
// A provider record may carry its product on the transaction it describes (Apple).
|
|
3977
|
+
const records = [raw, asRecord(raw?.transaction)];
|
|
3978
|
+
const references = [
|
|
3979
|
+
event.billingState?.providerProductId,
|
|
3980
|
+
event.priceId,
|
|
3981
|
+
...records.flatMap((record) => record ? ['product_id', 'productId', 'price_id', 'priceId'].map((key) => readString(record, key)) : [])
|
|
3982
|
+
];
|
|
3983
|
+
const resolved = findCatalogPriceReference(deps.catalog, provider, references, subscription?.priceId);
|
|
3984
|
+
if (resolved) {
|
|
3985
|
+
return resolved;
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
if (!subscription || (!normalizedType.includes('renew') && !normalizedType.includes('plan_changed'))) {
|
|
3989
|
+
return null;
|
|
3990
|
+
}
|
|
3991
|
+
const changeRequests = await deps.db.findMany({
|
|
3992
|
+
model: TABLES.subscriptionChangeRequests,
|
|
2323
3993
|
where: [
|
|
2324
3994
|
{ field: 'subscriptionId', operator: 'eq', value: subscription.id },
|
|
2325
3995
|
{ field: 'effectiveAt', operator: 'eq', value: 'next_renewal' },
|
|
@@ -2331,12 +4001,26 @@ async function resolveProviderEventPriceReference(deps, provider, event, subscri
|
|
|
2331
4001
|
const latest = changeRequests[0];
|
|
2332
4002
|
return latest ? requirePriceReference(deps.catalog, latest.targetPriceId) : null;
|
|
2333
4003
|
}
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
4004
|
+
/**
|
|
4005
|
+
* The catalog price the references name: an exact catalog price id first, then
|
|
4006
|
+
* a provider product id. A product several prices share names the current
|
|
4007
|
+
* price if it is one of them, else the first of them when they are all in one
|
|
4008
|
+
* plan; across plans it names none, so the caller keeps its stored selection.
|
|
4009
|
+
*/
|
|
4010
|
+
function findCatalogPriceReference(catalog, provider, references, currentPriceId) {
|
|
4011
|
+
const prices = catalog.plans.flatMap((plan) => plan.prices.filter((price) => price.provider === provider).map((price) => ({ plan, price })));
|
|
4012
|
+
const named = references.filter((reference) => Boolean(reference));
|
|
4013
|
+
for (const reference of named) {
|
|
4014
|
+
const exact = prices.find((candidate) => candidate.price.priceId === reference);
|
|
4015
|
+
if (exact) {
|
|
4016
|
+
return exact;
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
for (const reference of named) {
|
|
4020
|
+
const products = prices.filter((candidate) => candidate.price.providerProductId === reference);
|
|
4021
|
+
const current = products.find((candidate) => candidate.price.priceId === currentPriceId);
|
|
4022
|
+
if (current || new Set(products.map((candidate) => candidate.plan.planKey)).size === 1) {
|
|
4023
|
+
return current ?? products[0];
|
|
2340
4024
|
}
|
|
2341
4025
|
}
|
|
2342
4026
|
return null;
|
|
@@ -2421,9 +4105,21 @@ function readString(record, key) {
|
|
|
2421
4105
|
const value = record[key];
|
|
2422
4106
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
2423
4107
|
}
|
|
4108
|
+
function readProviderStateOccurredAt(subscription) {
|
|
4109
|
+
const metadata = asRecord(subscription?.metadata);
|
|
4110
|
+
return metadata ? readString(metadata, PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY) : null;
|
|
4111
|
+
}
|
|
4112
|
+
/** The later of two provider-state times; an unparsable current time is replaced. */
|
|
4113
|
+
function laterProviderEventTime(current, candidate) {
|
|
4114
|
+
const currentTime = current ? parseProviderEventTime(current) : null;
|
|
4115
|
+
const candidateTime = parseProviderEventTime(candidate);
|
|
4116
|
+
if (currentTime === null || candidateTime === null) {
|
|
4117
|
+
return currentTime === null ? candidate : current;
|
|
4118
|
+
}
|
|
4119
|
+
return candidateTime > currentTime ? candidate : current;
|
|
4120
|
+
}
|
|
2424
4121
|
function isProviderEventOlderThanSubscription(subscription, occurredAt) {
|
|
2425
|
-
const
|
|
2426
|
-
const lastOccurredAt = metadata ? readString(metadata, PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY) : null;
|
|
4122
|
+
const lastOccurredAt = readProviderStateOccurredAt(subscription);
|
|
2427
4123
|
if (!lastOccurredAt) {
|
|
2428
4124
|
return false;
|
|
2429
4125
|
}
|
|
@@ -2431,6 +4127,94 @@ function isProviderEventOlderThanSubscription(subscription, occurredAt) {
|
|
|
2431
4127
|
const currentTime = parseProviderEventTime(lastOccurredAt);
|
|
2432
4128
|
return incomingTime !== null && currentTime !== null && incomingTime < currentTime;
|
|
2433
4129
|
}
|
|
4130
|
+
/**
|
|
4131
|
+
* Orders an event against the subscription's provider state. A newer event is
|
|
4132
|
+
* applied and an older one skipped; one at the same time is applied. An event
|
|
4133
|
+
* inside the open window of the committed provider mutation that set that
|
|
4134
|
+
* state (after the provider call started, up to and including when it
|
|
4135
|
+
* returned) cannot be ordered against the mutation and is never applied over
|
|
4136
|
+
* it: the subscription is reconciled with a provider read instead.
|
|
4137
|
+
*/
|
|
4138
|
+
function orderProviderEvent(subscription, occurredAt) {
|
|
4139
|
+
const startedAt = readMutationStartedAt(subscription);
|
|
4140
|
+
const stateAt = readProviderStateOccurredAt(subscription);
|
|
4141
|
+
const startTime = startedAt ? parseProviderEventTime(startedAt) : null;
|
|
4142
|
+
const stateTime = stateAt ? parseProviderEventTime(stateAt) : null;
|
|
4143
|
+
const incomingTime = parseProviderEventTime(occurredAt);
|
|
4144
|
+
if (startTime !== null && stateTime !== null && incomingTime !== null && incomingTime > startTime && incomingTime <= stateTime) {
|
|
4145
|
+
return 'reconcile';
|
|
4146
|
+
}
|
|
4147
|
+
return isProviderEventOlderThanSubscription(subscription, occurredAt) ? 'skip' : 'apply';
|
|
4148
|
+
}
|
|
4149
|
+
function readMutationStartedAt(subscription) {
|
|
4150
|
+
const metadata = asRecord(subscription.metadata);
|
|
4151
|
+
return metadata ? readString(metadata, PROVIDER_MUTATION_STARTED_AT_METADATA_KEY) : null;
|
|
4152
|
+
}
|
|
4153
|
+
const windowReadFailures = new WeakSet();
|
|
4154
|
+
/** No provider read succeeded for an event inside a committed mutation's window and no sync could be queued. */
|
|
4155
|
+
function windowReadFailed(cause) {
|
|
4156
|
+
const error = createBillFnError({
|
|
4157
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
4158
|
+
message: `Provider read for an event inside a committed mutation window failed: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
4159
|
+
});
|
|
4160
|
+
windowReadFailures.add(error);
|
|
4161
|
+
return error;
|
|
4162
|
+
}
|
|
4163
|
+
function isWindowReadFailure(error) {
|
|
4164
|
+
return typeof error === 'object' && error !== null && windowReadFailures.has(error);
|
|
4165
|
+
}
|
|
4166
|
+
function withoutMutationWindow(metadata) {
|
|
4167
|
+
if (!metadata || !(PROVIDER_MUTATION_STARTED_AT_METADATA_KEY in metadata)) {
|
|
4168
|
+
return metadata;
|
|
4169
|
+
}
|
|
4170
|
+
const { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: _closed, ...rest } = metadata;
|
|
4171
|
+
return rest;
|
|
4172
|
+
}
|
|
4173
|
+
/**
|
|
4174
|
+
* Handles an event inside a committed mutation's window: links its missing
|
|
4175
|
+
* references and reconciles the subscription with an inline provider read
|
|
4176
|
+
* (`reconcileProviderState`). When the provider cannot be read, the committed
|
|
4177
|
+
* mutation's state stands. The event is never applied over the mutation.
|
|
4178
|
+
*/
|
|
4179
|
+
async function reconcileMutationWindowEvent(deps, subscription, input) {
|
|
4180
|
+
const linked = await linkSkippedEventReferences(deps, subscription, input.state);
|
|
4181
|
+
const readable = Boolean(deps.reconcileProviderState) && typeof deps.providers[linked.provider]?.fetchSubscription === 'function';
|
|
4182
|
+
deps.metrics.track(readable ? 'subscription.mutation.window_event' : 'subscription.mutation.window_event_unreconciled', {
|
|
4183
|
+
provider: linked.provider,
|
|
4184
|
+
subscriptionId: linked.id
|
|
4185
|
+
});
|
|
4186
|
+
if (readable) {
|
|
4187
|
+
await deps.reconcileProviderState?.(linked, {
|
|
4188
|
+
providerEventId: input.providerEventId,
|
|
4189
|
+
selection: { plan: input.plan, price: input.price }
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
/**
|
|
4194
|
+
* Closes the committed mutation window a reconcile read covered, once its
|
|
4195
|
+
* effects are recorded; a newer mutation's window is left open.
|
|
4196
|
+
*/
|
|
4197
|
+
async function closeMutationWindow(deps, subscriptionId, startedAt) {
|
|
4198
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
4199
|
+
const current = await getSubscriptionById(deps, subscriptionId);
|
|
4200
|
+
const metadata = asRecord(current?.metadata);
|
|
4201
|
+
if (!current || !metadata || !startedAt || readMutationStartedAt(current) !== startedAt) {
|
|
4202
|
+
return;
|
|
4203
|
+
}
|
|
4204
|
+
const closed = {
|
|
4205
|
+
...current,
|
|
4206
|
+
metadata: withoutMutationWindow(metadata),
|
|
4207
|
+
updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
|
|
4208
|
+
};
|
|
4209
|
+
if (await writeOrderedSubscription(deps, current, closed)) {
|
|
4210
|
+
return;
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
throw createBillFnError({
|
|
4214
|
+
code: 'BILLFN_CONFLICT',
|
|
4215
|
+
message: 'Unable to close the mutation window after multiple retries'
|
|
4216
|
+
});
|
|
4217
|
+
}
|
|
2434
4218
|
function nextSubscriptionUpdatedAt(now, previous) {
|
|
2435
4219
|
const currentTime = now.getTime();
|
|
2436
4220
|
const previousTime = previous ? Date.parse(previous) : Number.NaN;
|