@billfn/core 0.1.0 → 0.2.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/schema.d.ts.map +1 -1
- package/dist/schema.js +56 -3
- package/dist/schema.js.map +1 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1550 -263
- package/dist/service.js.map +1 -1
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/service.js
CHANGED
|
@@ -15,11 +15,24 @@ const TABLES = {
|
|
|
15
15
|
refunds: 'refunds',
|
|
16
16
|
subscriptionChangeRequests: 'subscriptionChangeRequests',
|
|
17
17
|
reconciliationJobs: 'reconciliationJobs',
|
|
18
|
-
reconciliationCursors: 'reconciliationCursors'
|
|
18
|
+
reconciliationCursors: 'reconciliationCursors',
|
|
19
|
+
unlinkedWebhookEvents: 'unlinkedWebhookEvents',
|
|
20
|
+
replacedProviderReferences: 'replacedProviderReferences'
|
|
19
21
|
};
|
|
20
22
|
const PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY = 'billfnProviderEventOccurredAt';
|
|
23
|
+
/** When the committed provider mutation that set the provider-state time started; it opens that mutation's window. */
|
|
24
|
+
const PROVIDER_MUTATION_STARTED_AT_METADATA_KEY = 'billfnProviderMutationStartedAt';
|
|
25
|
+
/** References a committed mutation's write dropped but could not record as replaced (`replacedReferencesPending`). */
|
|
26
|
+
const PENDING_REPLACED_REFERENCES_METADATA_KEY = 'billfnPendingReplacedReferences';
|
|
21
27
|
const WEBHOOK_RECEIPT_CLAIM_LEASE_MS = 15 * 60 * 1000;
|
|
22
28
|
const RECONCILIATION_JOB_CLAIM_LEASE_MS = 15 * 60 * 1000;
|
|
29
|
+
/** Unlinked webhook events are kept this long for a later link to replay them. */
|
|
30
|
+
const UNLINKED_WEBHOOK_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
31
|
+
const UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS = 15 * 60 * 1000;
|
|
32
|
+
/** Bounds the events one replay run applies; the rest are left for the retried operation's next run. */
|
|
33
|
+
const UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS = 500;
|
|
34
|
+
/** Provider metadata key naming the billing account that owns a provider record. */
|
|
35
|
+
const BILLING_ACCOUNT_METADATA_KEY = 'billfn_billing_account_id';
|
|
23
36
|
/** Deliveries of one queue message refused by lease conflicts before it is dead-lettered. */
|
|
24
37
|
const RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS = 5;
|
|
25
38
|
/**
|
|
@@ -64,6 +77,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
64
77
|
metrics,
|
|
65
78
|
queue: config.queue
|
|
66
79
|
};
|
|
80
|
+
deps.reconcileProviderState = (subscription, event) => reconcileWindowEvent(subscription, event);
|
|
67
81
|
const subscriptionProvider = {
|
|
68
82
|
getActiveSubscription: async (subject) => {
|
|
69
83
|
const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
|
|
@@ -145,7 +159,8 @@ function createBillFnServiceWithInternals(config) {
|
|
|
145
159
|
};
|
|
146
160
|
}
|
|
147
161
|
};
|
|
148
|
-
|
|
162
|
+
/** Reads the subscription from the provider (see `fetchLiveSubscription`). */
|
|
163
|
+
async function readProviderSubscription(subscriptionId, candidate) {
|
|
149
164
|
const subscription = await getSubscriptionById(deps, subscriptionId);
|
|
150
165
|
if (!subscription) {
|
|
151
166
|
throw createBillFnError({
|
|
@@ -160,26 +175,162 @@ function createBillFnServiceWithInternals(config) {
|
|
|
160
175
|
message: 'Billing account not found'
|
|
161
176
|
});
|
|
162
177
|
}
|
|
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) {
|
|
178
|
+
const live = await fetchLiveSubscription(deps, subscription, candidate);
|
|
179
|
+
if (!live) {
|
|
172
180
|
throw createBillFnError({
|
|
173
181
|
code: 'BILLFN_NOT_FOUND',
|
|
174
182
|
message: 'Provider did not return subscription state'
|
|
175
183
|
});
|
|
176
184
|
}
|
|
177
|
-
return
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
return { billingAccount, subscription, ...live };
|
|
186
|
+
}
|
|
187
|
+
async function syncSubscriptionById(subscriptionId) {
|
|
188
|
+
return applyLiveProviderState(deps, await readProviderSubscription(subscriptionId));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Reconciles an event inside a committed mutation's window with a provider
|
|
192
|
+
* read for the committed plan and price, or for the event's price when that
|
|
193
|
+
* fails and the live record confirms it (`fetchLiveSubscription`). When no
|
|
194
|
+
* read succeeds, the committed state stands: with a queue a subscription-sync
|
|
195
|
+
* job carrying the event's price is enqueued, otherwise a window-read error is
|
|
196
|
+
* thrown so the delivery, job or replay trigger is retried. The window stays
|
|
197
|
+
* open until the read's entitlements and its lifecycle entry, keyed by the
|
|
198
|
+
* event, are written, so a retry after a failure there reconciles again.
|
|
199
|
+
*/
|
|
200
|
+
async function reconcileWindowEvent(subscription, event) {
|
|
201
|
+
let read;
|
|
202
|
+
try {
|
|
203
|
+
read = await readProviderSubscription(subscription.id, event.selection);
|
|
204
|
+
}
|
|
205
|
+
catch (error_) {
|
|
206
|
+
deps.metrics.track('subscription.mutation.window_event_unreconciled', {
|
|
207
|
+
provider: subscription.provider,
|
|
208
|
+
subscriptionId: subscription.id,
|
|
209
|
+
reason: 'read_failed',
|
|
210
|
+
error: error_ instanceof Error ? error_.message : String(error_)
|
|
211
|
+
});
|
|
212
|
+
if (!deps.queue) {
|
|
213
|
+
throw windowReadFailed(error_);
|
|
214
|
+
}
|
|
215
|
+
await scheduleSubscriptionSync(subscription, event.selection);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await applyVerificationState(deps, {
|
|
219
|
+
...read,
|
|
220
|
+
keepMutationWindow: true,
|
|
221
|
+
providerEventId: event.providerEventId ? `${event.providerEventId}#window-read` : undefined
|
|
222
|
+
});
|
|
223
|
+
await closeMutationWindow(deps, subscription.id, readMutationStartedAt(subscription));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Applies the provider state a committed provider mutation (cancel, change,
|
|
227
|
+
* resume, refund) returned. `committedAt` is taken as soon as the provider
|
|
228
|
+
* call returns, before any other write.
|
|
229
|
+
* Retained events that cannot be replayed now do not fail the mutation; with a
|
|
230
|
+
* queue and a provider that supports sync, a subscription-sync job is enqueued
|
|
231
|
+
* to replay them. When a provider event was applied while the mutation was in
|
|
232
|
+
* flight, the subscription is reconciled with an authoritative provider read.
|
|
233
|
+
*/
|
|
234
|
+
async function applyMutationProviderState(input) {
|
|
235
|
+
const projection = await applyLiveProviderState(deps, input, { onReplayDeferred: scheduleSubscriptionSync });
|
|
236
|
+
if (!projection.providerStateRaced) {
|
|
237
|
+
return projection;
|
|
238
|
+
}
|
|
239
|
+
deps.metrics.track('subscription.mutation.raced_event', {
|
|
240
|
+
provider: projection.subscription.provider,
|
|
241
|
+
subscriptionId: projection.subscription.id
|
|
242
|
+
});
|
|
243
|
+
return (await reconcileCommittedMutation(projection.subscription, projection.providerStateRaced.priceId)) ?? projection;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Reads the subscription from the provider after a mutation an event raced:
|
|
247
|
+
* a queued sync job when a queue is configured, otherwise an inline sync. It
|
|
248
|
+
* never repeats the mutation and never fails it. Returns the inline result.
|
|
249
|
+
* The price the raced event wrote is the read's candidate (`fetchLiveSubscription`).
|
|
250
|
+
*/
|
|
251
|
+
async function reconcileCommittedMutation(subscription, racedPriceId) {
|
|
252
|
+
const candidate = findCatalogPriceReference(deps.catalog, subscription.provider, [racedPriceId]) ?? undefined;
|
|
253
|
+
try {
|
|
254
|
+
if (deps.queue) {
|
|
255
|
+
await scheduleSubscriptionSync(subscription, candidate);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (typeof deps.providers[subscription.provider]?.fetchSubscription !== 'function') {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return await applyLiveProviderState(deps, await readProviderSubscription(subscription.id, candidate));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
deps.metrics.track('subscription.mutation.reconcile_failed', {
|
|
265
|
+
provider: subscription.provider,
|
|
266
|
+
subscriptionId: subscription.id,
|
|
267
|
+
error: error instanceof Error ? error.message : String(error)
|
|
268
|
+
});
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Loads the caller's subscription, plan and price for a subscription mutation. */
|
|
273
|
+
async function loadMutationSubscription(input) {
|
|
274
|
+
const subject = requireSubject(input.subject);
|
|
275
|
+
const { billingAccount } = await getOrCreateBillingAccount(deps, subject);
|
|
276
|
+
const subscription = await getSubscriptionForAccount(deps, billingAccount.id, input.subscriptionId);
|
|
277
|
+
if (!subscription) {
|
|
278
|
+
throw createBillFnError({
|
|
279
|
+
code: 'BILLFN_NOT_FOUND',
|
|
280
|
+
message: 'Subscription not found'
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
284
|
+
const price = requirePriceById(plan, subscription.priceId);
|
|
285
|
+
return { billingAccount, subscription, plan, price };
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Completes a committed cancel, change or resume: applies the state the
|
|
289
|
+
* provider returned (or keeps the stored subscription and entitlements when it
|
|
290
|
+
* returned none) and records the requested billing event.
|
|
291
|
+
*/
|
|
292
|
+
async function finishSubscriptionMutation(input) {
|
|
293
|
+
const { billingAccount, subscription, operation } = input;
|
|
294
|
+
const projection = operation.billingState
|
|
295
|
+
? await applyMutationProviderState({
|
|
296
|
+
billingAccount,
|
|
297
|
+
plan: input.plan,
|
|
298
|
+
price: input.price,
|
|
299
|
+
subscription,
|
|
300
|
+
state: operation.billingState,
|
|
301
|
+
liveStateObservedAt: input.liveStateObservedAt,
|
|
302
|
+
committedAt: input.timestamp
|
|
303
|
+
})
|
|
304
|
+
: {
|
|
305
|
+
subscription,
|
|
306
|
+
entitlements: (await findEntitlementSnapshot(deps, billingAccount.id)) ??
|
|
307
|
+
(await upsertEntitlements(deps, billingAccount.id, input.entitlementPlan, subscription, input.timestamp))
|
|
308
|
+
};
|
|
309
|
+
await recordBillingEvent(deps, {
|
|
310
|
+
id: deps.generateId('evt'),
|
|
311
|
+
billingAccountId: billingAccount.id,
|
|
312
|
+
type: input.eventType,
|
|
313
|
+
payload: { subscriptionId: subscription.id, ...input.eventPayload, operationStatus: operation.operationStatus },
|
|
314
|
+
createdAt: input.timestamp
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
subscription: projection.subscription,
|
|
318
|
+
entitlements: projection.entitlements,
|
|
319
|
+
operationStatus: operation.operationStatus,
|
|
320
|
+
clientAction: operation.clientAction
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** `candidate` is the catalog price an unreconciled window event names; the job reads it if the stored one fails. */
|
|
324
|
+
async function scheduleSubscriptionSync(subscription, candidate) {
|
|
325
|
+
if (!deps.queue || typeof deps.providers[subscription.provider]?.fetchSubscription !== 'function') {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
await enqueueReconciliationJobInternal({
|
|
329
|
+
kind: 'subscription-sync',
|
|
330
|
+
provider: subscription.provider,
|
|
331
|
+
billingAccountId: subscription.billingAccountId,
|
|
332
|
+
subscriptionId: subscription.id,
|
|
333
|
+
payload: candidate ? { candidatePriceId: candidate.price.priceId } : undefined
|
|
183
334
|
});
|
|
184
335
|
}
|
|
185
336
|
async function createReconciliationJobRecord(input) {
|
|
@@ -418,6 +569,8 @@ function createBillFnServiceWithInternals(config) {
|
|
|
418
569
|
const plan = requirePlan(deps.catalog, checkoutSession.planKey);
|
|
419
570
|
const price = requirePriceById(plan, checkoutSession.priceId);
|
|
420
571
|
const provider = requireProvider(deps, checkoutSession.provider, 'verifyCheckout');
|
|
572
|
+
// Events that occurred before this live read are already reflected in its state.
|
|
573
|
+
const verificationObservedAt = toIsoString(deps.now());
|
|
421
574
|
const verification = await provider.verifyCheckout({
|
|
422
575
|
checkoutSession,
|
|
423
576
|
billingAccount,
|
|
@@ -425,12 +578,13 @@ function createBillFnServiceWithInternals(config) {
|
|
|
425
578
|
price,
|
|
426
579
|
payload: input.payload
|
|
427
580
|
});
|
|
428
|
-
const result = await
|
|
581
|
+
const result = await applyLiveProviderState(deps, {
|
|
429
582
|
billingAccount,
|
|
430
583
|
plan,
|
|
431
584
|
price,
|
|
432
585
|
checkoutSession,
|
|
433
|
-
state: verification
|
|
586
|
+
state: verification,
|
|
587
|
+
liveStateObservedAt: verificationObservedAt
|
|
434
588
|
});
|
|
435
589
|
if (!result.checkoutSession) {
|
|
436
590
|
throw createBillFnError({
|
|
@@ -445,18 +599,9 @@ function createBillFnServiceWithInternals(config) {
|
|
|
445
599
|
});
|
|
446
600
|
},
|
|
447
601
|
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);
|
|
602
|
+
const { billingAccount, subscription, plan, price } = await loadMutationSubscription(input);
|
|
459
603
|
const provider = requireProvider(deps, subscription.provider, 'cancelSubscription');
|
|
604
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
460
605
|
const operation = (await provider.cancelSubscription({
|
|
461
606
|
subscription,
|
|
462
607
|
reason: input.reason
|
|
@@ -472,48 +617,22 @@ function createBillFnServiceWithInternals(config) {
|
|
|
472
617
|
currentPeriodEnd: subscription.currentPeriodEnd
|
|
473
618
|
}
|
|
474
619
|
};
|
|
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
|
-
});
|
|
620
|
+
const committedAt = toIsoString(deps.now());
|
|
621
|
+
return ok(await finishSubscriptionMutation({
|
|
622
|
+
billingAccount,
|
|
623
|
+
subscription,
|
|
624
|
+
plan,
|
|
625
|
+
price,
|
|
626
|
+
entitlementPlan: plan,
|
|
627
|
+
operation,
|
|
628
|
+
liveStateObservedAt,
|
|
629
|
+
timestamp: committedAt,
|
|
630
|
+
eventType: 'billfn.subscription.cancel.requested',
|
|
631
|
+
eventPayload: { reason: input.reason }
|
|
632
|
+
}));
|
|
504
633
|
},
|
|
505
634
|
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);
|
|
635
|
+
const { billingAccount, subscription, plan: currentPlan, price: currentPrice } = await loadMutationSubscription(input);
|
|
517
636
|
const target = requirePriceReference(deps.catalog, input.targetPriceId);
|
|
518
637
|
if (target.price.provider !== subscription.provider) {
|
|
519
638
|
throw createBillFnError({
|
|
@@ -529,6 +648,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
529
648
|
const provider = requireProvider(deps, subscription.provider, 'changeSubscription');
|
|
530
649
|
const effectiveAt = input.effectiveAt ?? 'immediate';
|
|
531
650
|
const prorationBehavior = input.prorationBehavior ?? 'provider_default';
|
|
651
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
532
652
|
const operation = await provider.changeSubscription({
|
|
533
653
|
subscription,
|
|
534
654
|
currentPlan,
|
|
@@ -556,82 +676,40 @@ function createBillFnServiceWithInternals(config) {
|
|
|
556
676
|
updatedAt: timestamp
|
|
557
677
|
});
|
|
558
678
|
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
|
|
679
|
+
const result = await finishSubscriptionMutation({
|
|
680
|
+
billingAccount,
|
|
681
|
+
subscription,
|
|
682
|
+
plan: appliesTargetImmediately ? target.plan : currentPlan,
|
|
683
|
+
price: appliesTargetImmediately ? target.price : currentPrice,
|
|
684
|
+
entitlementPlan: currentPlan,
|
|
685
|
+
operation,
|
|
686
|
+
liveStateObservedAt,
|
|
687
|
+
timestamp,
|
|
688
|
+
eventType: 'billfn.subscription.change.requested',
|
|
689
|
+
eventPayload: { currentPriceId: currentPrice.priceId, targetPriceId: target.price.priceId }
|
|
589
690
|
});
|
|
691
|
+
return ok({ ...result, changeRequest });
|
|
590
692
|
},
|
|
591
693
|
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);
|
|
694
|
+
const { billingAccount, subscription, plan, price } = await loadMutationSubscription(input);
|
|
603
695
|
const provider = requireProvider(deps, subscription.provider, 'resumeSubscription');
|
|
696
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
604
697
|
const operation = await provider.resumeSubscription({
|
|
605
698
|
subscription
|
|
606
699
|
});
|
|
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
|
-
});
|
|
700
|
+
const committedAt = toIsoString(deps.now());
|
|
701
|
+
return ok(await finishSubscriptionMutation({
|
|
702
|
+
billingAccount,
|
|
703
|
+
subscription,
|
|
704
|
+
plan,
|
|
705
|
+
price,
|
|
706
|
+
entitlementPlan: plan,
|
|
707
|
+
operation,
|
|
708
|
+
liveStateObservedAt,
|
|
709
|
+
timestamp: committedAt,
|
|
710
|
+
eventType: 'billfn.subscription.resume.requested',
|
|
711
|
+
eventPayload: {}
|
|
712
|
+
}));
|
|
635
713
|
},
|
|
636
714
|
async refundCharge(input) {
|
|
637
715
|
const subject = requireSubject(input.subject);
|
|
@@ -655,6 +733,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
655
733
|
const provider = requireProvider(deps, providerName, 'refundCharge');
|
|
656
734
|
const mode = input.mode ?? 'full';
|
|
657
735
|
const providerChargeId = input.providerChargeId ?? subscription.providerChargeId;
|
|
736
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
658
737
|
const operation = await provider.refundCharge({
|
|
659
738
|
subscription,
|
|
660
739
|
billingAccount,
|
|
@@ -685,12 +764,14 @@ function createBillFnServiceWithInternals(config) {
|
|
|
685
764
|
if (subscription && operation.billingState) {
|
|
686
765
|
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
687
766
|
const price = requirePriceById(plan, subscription.priceId);
|
|
688
|
-
const projection = await
|
|
767
|
+
const projection = await applyMutationProviderState({
|
|
689
768
|
billingAccount,
|
|
690
769
|
plan,
|
|
691
770
|
price,
|
|
692
771
|
subscription,
|
|
693
|
-
state: operation.billingState
|
|
772
|
+
state: operation.billingState,
|
|
773
|
+
liveStateObservedAt,
|
|
774
|
+
committedAt: timestamp
|
|
694
775
|
});
|
|
695
776
|
projectedSubscription = projection.subscription;
|
|
696
777
|
projectedEntitlements = projection.entitlements;
|
|
@@ -757,6 +838,8 @@ function createBillFnServiceWithInternals(config) {
|
|
|
757
838
|
});
|
|
758
839
|
}
|
|
759
840
|
const provider = requireProvider(deps, input.provider, 'restorePurchases');
|
|
841
|
+
// Events that occurred before this live read are already reflected in its state.
|
|
842
|
+
const restoreObservedAt = toIsoString(deps.now());
|
|
760
843
|
const states = await provider.restorePurchases({
|
|
761
844
|
billingAccount,
|
|
762
845
|
plan,
|
|
@@ -777,11 +860,12 @@ function createBillFnServiceWithInternals(config) {
|
|
|
777
860
|
checkoutReferenceId: input.purchaseReference
|
|
778
861
|
});
|
|
779
862
|
if (!projection) {
|
|
780
|
-
const applied = await
|
|
863
|
+
const applied = await applyLiveProviderState(deps, {
|
|
781
864
|
billingAccount,
|
|
782
865
|
plan,
|
|
783
866
|
price,
|
|
784
|
-
state
|
|
867
|
+
state,
|
|
868
|
+
liveStateObservedAt: restoreObservedAt
|
|
785
869
|
});
|
|
786
870
|
const response = {
|
|
787
871
|
subscription: applied.subscription,
|
|
@@ -792,13 +876,14 @@ function createBillFnServiceWithInternals(config) {
|
|
|
792
876
|
if (projection.billingAccount.id !== billingAccount.id) {
|
|
793
877
|
continue;
|
|
794
878
|
}
|
|
795
|
-
const applied = await
|
|
879
|
+
const applied = await applyLiveProviderState(deps, {
|
|
796
880
|
billingAccount: projection.billingAccount,
|
|
797
881
|
plan: projection.plan,
|
|
798
882
|
price: projection.price,
|
|
799
883
|
checkoutSession: projection.checkoutSession ?? undefined,
|
|
800
884
|
subscription: projection.subscription ?? undefined,
|
|
801
|
-
state
|
|
885
|
+
state,
|
|
886
|
+
liveStateObservedAt: restoreObservedAt
|
|
802
887
|
});
|
|
803
888
|
const response = {
|
|
804
889
|
subscription: applied.subscription,
|
|
@@ -1056,7 +1141,13 @@ export function createBillFnReconciliationWorker(config) {
|
|
|
1056
1141
|
// The queue rejects an empty dead-letter reason, which would mask the run's error.
|
|
1057
1142
|
const reason = (error instanceof Error ? error.message || error.name : String(error)) || 'reconciliation run failed';
|
|
1058
1143
|
try {
|
|
1059
|
-
if (
|
|
1144
|
+
if (isProductiveReplayBound(error)) {
|
|
1145
|
+
// A replay that finished events before its bound continues in a fresh message, so a long
|
|
1146
|
+
// backlog never uses up the lease-conflict budget; one that finished none counts against it.
|
|
1147
|
+
await queue.enqueue(RECONCILIATION_QUEUE, claimed.payload);
|
|
1148
|
+
await queue.ack(claimed.id, fence);
|
|
1149
|
+
}
|
|
1150
|
+
else if (isReconciliationLeaseConflict(error) &&
|
|
1060
1151
|
claimed.attempt < RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS) {
|
|
1061
1152
|
// Another runner holds or took over the job's processing lease; retry once it has finished or expired.
|
|
1062
1153
|
await queue.fail(claimed.id, {
|
|
@@ -1162,7 +1253,8 @@ async function processReconciliationJob(deps, job, assertLease) {
|
|
|
1162
1253
|
message: 'Subscription sync job requires subscriptionId'
|
|
1163
1254
|
});
|
|
1164
1255
|
}
|
|
1165
|
-
|
|
1256
|
+
const candidatePriceId = readString(job.payload ?? {}, 'candidatePriceId');
|
|
1257
|
+
await syncSubscriptionByIdForJob(deps, job.subscriptionId, assertLease, candidatePriceId);
|
|
1166
1258
|
return;
|
|
1167
1259
|
}
|
|
1168
1260
|
case 'account-scan': {
|
|
@@ -1333,7 +1425,7 @@ async function processReconciliationJob(deps, job, assertLease) {
|
|
|
1333
1425
|
}
|
|
1334
1426
|
}
|
|
1335
1427
|
}
|
|
1336
|
-
async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease) {
|
|
1428
|
+
async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease, candidatePriceId) {
|
|
1337
1429
|
const subscription = await getSubscriptionById(deps, subscriptionId);
|
|
1338
1430
|
if (!subscription) {
|
|
1339
1431
|
return;
|
|
@@ -1342,25 +1434,58 @@ async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease) {
|
|
|
1342
1434
|
if (!billingAccount) {
|
|
1343
1435
|
return;
|
|
1344
1436
|
}
|
|
1345
|
-
const
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
plan,
|
|
1351
|
-
price
|
|
1352
|
-
});
|
|
1353
|
-
if (!verification) {
|
|
1437
|
+
const candidate = candidatePriceId
|
|
1438
|
+
? findCatalogPriceReference(deps.catalog, subscription.provider, [candidatePriceId])
|
|
1439
|
+
: null;
|
|
1440
|
+
const live = await fetchLiveSubscription(deps, subscription, candidate ?? undefined);
|
|
1441
|
+
if (!live) {
|
|
1354
1442
|
return;
|
|
1355
1443
|
}
|
|
1356
1444
|
await assertLease();
|
|
1357
|
-
await
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1445
|
+
await applyLiveProviderState(deps, { billingAccount, subscription, ...live });
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Reads the subscription from the provider for its stored plan and price. If
|
|
1449
|
+
* that read fails or returns no state and `candidate` names another catalog
|
|
1450
|
+
* price, it reads for that price (a provider such as Apple reads only the live
|
|
1451
|
+
* product); such a read counts only when the state it returns names a catalog
|
|
1452
|
+
* price (`providerProductId` or the raw record), whatever the stored read's
|
|
1453
|
+
* failure was, else it counts as failed. The catalog price the live record
|
|
1454
|
+
* names is the selection applied. Returns null when every read returned no
|
|
1455
|
+
* state, and otherwise throws the first failure.
|
|
1456
|
+
*/
|
|
1457
|
+
async function fetchLiveSubscription(deps, subscription, candidate) {
|
|
1458
|
+
const plan = requirePlan(deps.catalog, subscription.planKey);
|
|
1459
|
+
const stored = { plan, price: requirePriceById(plan, subscription.priceId) };
|
|
1460
|
+
const provider = requireProvider(deps, subscription.provider, 'fetchSubscription');
|
|
1461
|
+
const selections = candidate?.price.priceId && candidate.price.priceId !== stored.price.priceId ? [stored, candidate] : [stored];
|
|
1462
|
+
const failures = [];
|
|
1463
|
+
for (const selection of selections) {
|
|
1464
|
+
const liveStateObservedAt = toIsoString(deps.now());
|
|
1465
|
+
try {
|
|
1466
|
+
const state = await provider.fetchSubscription({ subscription, plan: selection.plan, price: selection.price });
|
|
1467
|
+
const live = state
|
|
1468
|
+
? await resolveProviderEventPriceReference(deps, subscription.provider, { type: 'subscription.read', billingState: state }, subscription)
|
|
1469
|
+
: null;
|
|
1470
|
+
// A read for another price counts only when the provider confirms that price.
|
|
1471
|
+
if (state && (live || selection === stored)) {
|
|
1472
|
+
return { ...(live ?? selection), state, liveStateObservedAt };
|
|
1473
|
+
}
|
|
1474
|
+
if (state) {
|
|
1475
|
+
failures.push(createBillFnError({
|
|
1476
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
1477
|
+
message: 'The live provider record does not confirm the requested catalog price'
|
|
1478
|
+
}));
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
failures.push(error);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (failures.length > 0) {
|
|
1486
|
+
throw failures[0];
|
|
1487
|
+
}
|
|
1488
|
+
return null;
|
|
1364
1489
|
}
|
|
1365
1490
|
async function getOrCreateBillingAccount(deps, subject) {
|
|
1366
1491
|
const resolved = await resolveBillingAccountReference(deps, subject);
|
|
@@ -1869,6 +1994,10 @@ async function applyVerificationState(deps, input) {
|
|
|
1869
1994
|
(await findSubscriptionByProviderRef(deps, input.price.provider, input.state.providerSubscriptionId, input.state.providerChargeId, input.checkoutSession?.checkoutSessionId));
|
|
1870
1995
|
let subscription = null;
|
|
1871
1996
|
let timestamp = toIsoString(deps.now());
|
|
1997
|
+
let liveStateSuperseded = false;
|
|
1998
|
+
let providerStateRaced;
|
|
1999
|
+
// A read is superseded by an event newer than its start; a committed mutation only by one after its commit.
|
|
2000
|
+
const supersededBefore = input.committedAt ?? input.liveStateObservedAt;
|
|
1872
2001
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
1873
2002
|
if (baseSubscription && baseSubscription.billingAccountId !== input.billingAccount.id) {
|
|
1874
2003
|
throw createBillFnError({
|
|
@@ -1876,17 +2005,71 @@ async function applyVerificationState(deps, input) {
|
|
|
1876
2005
|
message: 'Provider billing reference belongs to a different billing account'
|
|
1877
2006
|
});
|
|
1878
2007
|
}
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2008
|
+
const order = baseSubscription && input.occurredAt ? orderProviderEvent(baseSubscription, input.occurredAt) : 'apply';
|
|
2009
|
+
if (baseSubscription && order === 'skip') {
|
|
2010
|
+
// The older event's state is skipped, but a reference it adds still belongs to this subscription.
|
|
2011
|
+
await linkSkippedEventReferences(deps, baseSubscription, input.state);
|
|
2012
|
+
return null;
|
|
2013
|
+
}
|
|
2014
|
+
if (baseSubscription && order === 'reconcile') {
|
|
2015
|
+
// Never applied over the committed mutation; without a queue a failed read fails this call, so it is retried.
|
|
2016
|
+
await reconcileMutationWindowEvent(deps, baseSubscription, input);
|
|
1882
2017
|
return null;
|
|
1883
2018
|
}
|
|
2019
|
+
// An event applied after a live read started is newer than what the read returned: the read
|
|
2020
|
+
// only links its references and the subscription keeps the event's state.
|
|
2021
|
+
liveStateSuperseded = Boolean(baseSubscription &&
|
|
2022
|
+
!input.occurredAt &&
|
|
2023
|
+
supersededBefore &&
|
|
2024
|
+
isProviderEventOlderThanSubscription(baseSubscription, supersededBefore));
|
|
2025
|
+
// An event applied while a committed mutation was in flight is reconciled by the caller.
|
|
2026
|
+
providerStateRaced =
|
|
2027
|
+
baseSubscription &&
|
|
2028
|
+
input.committedAt &&
|
|
2029
|
+
input.liveStateObservedAt &&
|
|
2030
|
+
isProviderEventOlderThanSubscription(baseSubscription, input.liveStateObservedAt)
|
|
2031
|
+
? { priceId: baseSubscription.priceId }
|
|
2032
|
+
: undefined;
|
|
1884
2033
|
timestamp = nextSubscriptionUpdatedAt(deps.now(), baseSubscription?.updatedAt);
|
|
1885
|
-
|
|
1886
|
-
|
|
2034
|
+
if (baseSubscription && liveStateSuperseded) {
|
|
2035
|
+
const linked = linkLiveReadReferences(baseSubscription, input, timestamp);
|
|
2036
|
+
if (!linked) {
|
|
2037
|
+
subscription = baseSubscription;
|
|
2038
|
+
break;
|
|
2039
|
+
}
|
|
2040
|
+
const updated = await deps.db.updateMany({
|
|
2041
|
+
model: TABLES.subscriptions,
|
|
2042
|
+
where: [
|
|
2043
|
+
{ field: 'id', operator: 'eq', value: baseSubscription.id },
|
|
2044
|
+
{ field: 'updatedAt', operator: 'eq', value: baseSubscription.updatedAt }
|
|
2045
|
+
],
|
|
2046
|
+
data: linked,
|
|
2047
|
+
namespace: deps.namespace
|
|
2048
|
+
});
|
|
2049
|
+
if (updated === 1) {
|
|
2050
|
+
subscription = linked;
|
|
2051
|
+
break;
|
|
2052
|
+
}
|
|
2053
|
+
const current = await getSubscriptionById(deps, baseSubscription.id);
|
|
2054
|
+
if (!current) {
|
|
2055
|
+
throw createBillFnError({
|
|
2056
|
+
code: 'BILLFN_CONFLICT',
|
|
2057
|
+
message: 'Subscription changed while applying provider state'
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
baseSubscription = current;
|
|
2061
|
+
await Promise.resolve();
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
// A state write closes the window of an earlier committed mutation; a committed mutation opens its own.
|
|
2065
|
+
const merged = mergeJson(baseSubscription?.metadata, input.state.raw);
|
|
2066
|
+
const mergedMetadata = input.keepMutationWindow ? merged : withoutMutationWindow(merged);
|
|
2067
|
+
const providerStateAt = providerStateTimeFor(input.occurredAt, supersededBefore, baseSubscription);
|
|
2068
|
+
const metadata = providerStateAt
|
|
1887
2069
|
? {
|
|
1888
2070
|
...mergedMetadata,
|
|
1889
|
-
[PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]:
|
|
2071
|
+
[PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]: providerStateAt,
|
|
2072
|
+
...(input.committedAt ? { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: input.liveStateObservedAt } : {})
|
|
1890
2073
|
}
|
|
1891
2074
|
: mergedMetadata;
|
|
1892
2075
|
const subscriptionId = baseSubscription?.id ?? deps.generateId('sub');
|
|
@@ -1911,38 +2094,38 @@ async function applyVerificationState(deps, input) {
|
|
|
1911
2094
|
trialEnd: baseSubscription?.trialEnd
|
|
1912
2095
|
};
|
|
1913
2096
|
if (baseSubscription) {
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
});
|
|
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
|
-
}
|
|
1939
|
-
subscription = await deps.db.update({
|
|
2097
|
+
// A reference this write drops is recorded as replaced before it, so no failure or interruption
|
|
2098
|
+
// after the write lets an event on it bootstrap another subscription; one a committed mutation
|
|
2099
|
+
// cannot record is saved pending with the write. A pending one is recorded before this write
|
|
2100
|
+
// clears it, the order `namesReplacedReference` reads against.
|
|
2101
|
+
const pending = await recordDroppedReferences(deps, baseSubscription, record, Boolean(input.committedAt));
|
|
2102
|
+
Object.assign(record, withPendingReplacements(record.metadata, pending));
|
|
2103
|
+
// Every update is ordered against the row it was computed from, so a provider-state time and the
|
|
2104
|
+
// pending references it clears always come from the row it replaces.
|
|
2105
|
+
const updated = await deps.db.updateMany({
|
|
1940
2106
|
model: TABLES.subscriptions,
|
|
1941
|
-
where: [
|
|
2107
|
+
where: [
|
|
2108
|
+
{ field: 'id', operator: 'eq', value: baseSubscription.id },
|
|
2109
|
+
{ field: 'updatedAt', operator: 'eq', value: baseSubscription.updatedAt }
|
|
2110
|
+
],
|
|
1942
2111
|
data: record,
|
|
1943
2112
|
namespace: deps.namespace
|
|
1944
2113
|
});
|
|
1945
|
-
|
|
2114
|
+
if (updated === 1) {
|
|
2115
|
+
subscription = record;
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
// A lost race leaves its records: a reference the winner keeps is held, so its events still link to it.
|
|
2119
|
+
const current = await getSubscriptionById(deps, baseSubscription.id);
|
|
2120
|
+
if (!current) {
|
|
2121
|
+
throw createBillFnError({
|
|
2122
|
+
code: 'BILLFN_CONFLICT',
|
|
2123
|
+
message: 'Subscription changed while applying provider event state'
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
baseSubscription = current;
|
|
2127
|
+
await Promise.resolve();
|
|
2128
|
+
continue;
|
|
1946
2129
|
}
|
|
1947
2130
|
try {
|
|
1948
2131
|
subscription = await deps.db.create({
|
|
@@ -1979,9 +2162,22 @@ async function applyVerificationState(deps, input) {
|
|
|
1979
2162
|
metadata: mergeJson(input.checkoutSession.metadata, input.state.raw)
|
|
1980
2163
|
})
|
|
1981
2164
|
: input.checkoutSession;
|
|
2165
|
+
if (liveStateSuperseded) {
|
|
2166
|
+
// The subscription's state did not change, so its entitlements stand and no lifecycle event is recorded.
|
|
2167
|
+
const entitlements = await upsertEntitlements(deps, input.billingAccount.id, requirePlan(deps.catalog, subscription.planKey), subscription, timestamp);
|
|
2168
|
+
return {
|
|
2169
|
+
checkoutSession: checkoutSession ?? input.checkoutSession,
|
|
2170
|
+
subscription,
|
|
2171
|
+
entitlements,
|
|
2172
|
+
providerStateRaced
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
1982
2175
|
const entitlements = await upsertEntitlements(deps, input.billingAccount.id, input.plan, subscription, timestamp);
|
|
1983
2176
|
await recordBillingEvent(deps, {
|
|
1984
|
-
|
|
2177
|
+
// A provider event keys its lifecycle entry, so applying it again (a reclaimed replay) records it once.
|
|
2178
|
+
id: input.providerEventId
|
|
2179
|
+
? buildProviderEventBillingEventId(subscription.provider, input.providerEventId)
|
|
2180
|
+
: deps.generateId('evt'),
|
|
1985
2181
|
billingAccountId: input.billingAccount.id,
|
|
1986
2182
|
type: `billfn.subscription.${subscription.status}`,
|
|
1987
2183
|
payload: {
|
|
@@ -1998,43 +2194,157 @@ async function applyVerificationState(deps, input) {
|
|
|
1998
2194
|
return {
|
|
1999
2195
|
checkoutSession: checkoutSession ?? input.checkoutSession,
|
|
2000
2196
|
subscription,
|
|
2001
|
-
entitlements
|
|
2197
|
+
entitlements,
|
|
2198
|
+
providerStateRaced
|
|
2002
2199
|
};
|
|
2003
2200
|
}
|
|
2004
|
-
|
|
2201
|
+
/**
|
|
2202
|
+
* The provider-state time a write saves: the event's time, or for live state
|
|
2203
|
+
* the read start (a committed mutation: its commit), never moving the stored
|
|
2204
|
+
* time backwards on any path. Null when the state carries no time.
|
|
2205
|
+
*/
|
|
2206
|
+
function providerStateTimeFor(occurredAt, liveStateAt, subscription) {
|
|
2207
|
+
const stateAt = occurredAt ?? liveStateAt;
|
|
2208
|
+
return stateAt ? laterProviderEventTime(readProviderStateOccurredAt(subscription), stateAt) : null;
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* The subscription row after a live provider read that an applied event has
|
|
2212
|
+
* superseded: missing provider references from the read are added and every
|
|
2213
|
+
* state field is kept. Returns null when the read adds no reference.
|
|
2214
|
+
*/
|
|
2215
|
+
function linkLiveReadReferences(subscription, input, timestamp) {
|
|
2216
|
+
const providerSubscriptionId = subscription.providerSubscriptionId ?? input.state.providerSubscriptionId;
|
|
2217
|
+
const providerCheckoutId = subscription.providerCheckoutId ?? input.checkoutSession?.providerCheckoutId;
|
|
2218
|
+
const providerChargeId = subscription.providerChargeId ?? input.state.providerChargeId;
|
|
2219
|
+
if (providerSubscriptionId === subscription.providerSubscriptionId &&
|
|
2220
|
+
providerCheckoutId === subscription.providerCheckoutId &&
|
|
2221
|
+
providerChargeId === subscription.providerChargeId) {
|
|
2222
|
+
return null;
|
|
2223
|
+
}
|
|
2224
|
+
return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, updatedAt: timestamp };
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Links the provider references of an event skipped as older than the
|
|
2228
|
+
* subscription's provider state (the last applied event or a live provider
|
|
2229
|
+
* read). The state, entitlements and provider-state time are kept; only a
|
|
2230
|
+
* missing provider subscription or charge id is filled, never replaced, and a
|
|
2231
|
+
* reference another subscription already holds is left alone. The write is
|
|
2232
|
+
* ordered against the row it was computed from. Returns the subscription after
|
|
2233
|
+
* the write, or the current row when nothing was added; the caller replays the
|
|
2234
|
+
* events retained for an added reference.
|
|
2235
|
+
*/
|
|
2236
|
+
async function linkSkippedEventReferences(deps, subscription, state) {
|
|
2237
|
+
let current = subscription;
|
|
2005
2238
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2006
|
-
const
|
|
2007
|
-
if (
|
|
2008
|
-
return
|
|
2239
|
+
const data = await linkableMissingReferences(deps, current, state);
|
|
2240
|
+
if (Object.keys(data).length === 0) {
|
|
2241
|
+
return current;
|
|
2009
2242
|
}
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
status: mapSubscriptionToEntitlementStatus(subscription.status),
|
|
2015
|
-
features: { ...plan.features },
|
|
2016
|
-
limits: { ...plan.limits },
|
|
2017
|
-
effectiveAt: timestamp,
|
|
2018
|
-
expiresAt: subscription.currentPeriodEnd,
|
|
2019
|
-
sourceEventId: buildEntitlementSourceEventId(subscription),
|
|
2020
|
-
createdAt: existing?.createdAt ?? timestamp,
|
|
2021
|
-
updatedAt: timestamp
|
|
2243
|
+
const linked = {
|
|
2244
|
+
...current,
|
|
2245
|
+
...data,
|
|
2246
|
+
updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
|
|
2022
2247
|
};
|
|
2023
|
-
if (
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
{ field: 'billingAccountId', operator: 'eq', value: billingAccountId },
|
|
2028
|
-
existing.sourceEventId
|
|
2029
|
-
? { field: 'sourceEventId', operator: 'eq', value: existing.sourceEventId }
|
|
2030
|
-
: { field: 'updatedAt', operator: 'eq', value: existing.updatedAt }
|
|
2031
|
-
],
|
|
2032
|
-
data: snapshot,
|
|
2033
|
-
namespace: deps.namespace
|
|
2248
|
+
if (await writeOrderedSubscription(deps, current, linked)) {
|
|
2249
|
+
deps.metrics.track('reconciliation.event.references_linked', {
|
|
2250
|
+
provider: current.provider,
|
|
2251
|
+
subscriptionId: current.id
|
|
2034
2252
|
});
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2253
|
+
return linked;
|
|
2254
|
+
}
|
|
2255
|
+
const reread = await getSubscriptionById(deps, current.id);
|
|
2256
|
+
if (!reread) {
|
|
2257
|
+
return current;
|
|
2258
|
+
}
|
|
2259
|
+
current = reread;
|
|
2260
|
+
await Promise.resolve();
|
|
2261
|
+
}
|
|
2262
|
+
throw createBillFnError({
|
|
2263
|
+
code: 'BILLFN_CONFLICT',
|
|
2264
|
+
message: 'Unable to link provider references after multiple retries'
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
/** The references the subscription lacks that the state names and no other subscription holds. */
|
|
2268
|
+
async function linkableMissingReferences(deps, subscription, state) {
|
|
2269
|
+
const data = {};
|
|
2270
|
+
for (const field of ['providerSubscriptionId', 'providerChargeId']) {
|
|
2271
|
+
if (await isMissingReferenceLinkable(deps, subscription, field, state[field])) {
|
|
2272
|
+
data[field] = state[field];
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
return data;
|
|
2276
|
+
}
|
|
2277
|
+
/** Writes a subscription row ordered against the row it was computed from; false when that row changed. */
|
|
2278
|
+
async function writeOrderedSubscription(deps, current, next) {
|
|
2279
|
+
try {
|
|
2280
|
+
const updated = await deps.db.updateMany({
|
|
2281
|
+
model: TABLES.subscriptions,
|
|
2282
|
+
where: [
|
|
2283
|
+
{ field: 'id', operator: 'eq', value: current.id },
|
|
2284
|
+
{ field: 'updatedAt', operator: 'eq', value: current.updatedAt }
|
|
2285
|
+
],
|
|
2286
|
+
data: next,
|
|
2287
|
+
namespace: deps.namespace
|
|
2288
|
+
});
|
|
2289
|
+
return updated === 1;
|
|
2290
|
+
}
|
|
2291
|
+
catch (error) {
|
|
2292
|
+
// Another subscription took the reference between the check and the write: re-check it.
|
|
2293
|
+
if (!isDuplicateRecordError(error)) {
|
|
2294
|
+
throw error;
|
|
2295
|
+
}
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
/** Whether the subscription lacks this reference and no other subscription holds it. */
|
|
2300
|
+
async function isMissingReferenceLinkable(deps, subscription, field, reference) {
|
|
2301
|
+
return Boolean(!subscription[field] && reference && !(await isProviderReferenceHeldElsewhere(deps, subscription, field, reference)));
|
|
2302
|
+
}
|
|
2303
|
+
async function isProviderReferenceHeldElsewhere(deps, subscription, field, reference) {
|
|
2304
|
+
const holder = await deps.db.findOne({
|
|
2305
|
+
model: TABLES.subscriptions,
|
|
2306
|
+
where: [
|
|
2307
|
+
{ field: 'provider', operator: 'eq', value: subscription.provider },
|
|
2308
|
+
{ field, operator: 'eq', value: reference }
|
|
2309
|
+
],
|
|
2310
|
+
namespace: deps.namespace
|
|
2311
|
+
});
|
|
2312
|
+
return Boolean(holder && holder.id !== subscription.id);
|
|
2313
|
+
}
|
|
2314
|
+
async function upsertEntitlements(deps, billingAccountId, plan, subscription, timestamp) {
|
|
2315
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2316
|
+
const existing = await findEntitlementSnapshot(deps, billingAccountId);
|
|
2317
|
+
if (existing && isEntitlementFromNewerSubscription(existing, subscription)) {
|
|
2318
|
+
return existing;
|
|
2319
|
+
}
|
|
2320
|
+
const snapshot = {
|
|
2321
|
+
id: existing?.id ?? `ent_${billingAccountId}`,
|
|
2322
|
+
billingAccountId,
|
|
2323
|
+
planKey: plan.planKey,
|
|
2324
|
+
status: mapSubscriptionToEntitlementStatus(subscription.status),
|
|
2325
|
+
features: { ...plan.features },
|
|
2326
|
+
limits: { ...plan.limits },
|
|
2327
|
+
effectiveAt: timestamp,
|
|
2328
|
+
expiresAt: subscription.currentPeriodEnd,
|
|
2329
|
+
sourceEventId: buildEntitlementSourceEventId(subscription),
|
|
2330
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
2331
|
+
updatedAt: timestamp
|
|
2332
|
+
};
|
|
2333
|
+
if (existing) {
|
|
2334
|
+
const updated = await deps.db.updateMany({
|
|
2335
|
+
model: TABLES.entitlementSnapshots,
|
|
2336
|
+
where: [
|
|
2337
|
+
{ field: 'billingAccountId', operator: 'eq', value: billingAccountId },
|
|
2338
|
+
existing.sourceEventId
|
|
2339
|
+
? { field: 'sourceEventId', operator: 'eq', value: existing.sourceEventId }
|
|
2340
|
+
: { field: 'updatedAt', operator: 'eq', value: existing.updatedAt }
|
|
2341
|
+
],
|
|
2342
|
+
data: snapshot,
|
|
2343
|
+
namespace: deps.namespace
|
|
2344
|
+
});
|
|
2345
|
+
if (updated === 1) {
|
|
2346
|
+
return snapshot;
|
|
2347
|
+
}
|
|
2038
2348
|
await Promise.resolve();
|
|
2039
2349
|
continue;
|
|
2040
2350
|
}
|
|
@@ -2058,11 +2368,22 @@ async function upsertEntitlements(deps, billingAccountId, plan, subscription, ti
|
|
|
2058
2368
|
});
|
|
2059
2369
|
}
|
|
2060
2370
|
async function recordBillingEvent(deps, event) {
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2371
|
+
try {
|
|
2372
|
+
await deps.db.create({
|
|
2373
|
+
model: TABLES.billingEvents,
|
|
2374
|
+
data: event,
|
|
2375
|
+
namespace: deps.namespace
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
catch (error) {
|
|
2379
|
+
// A provider-event-keyed entry already recorded by an earlier apply of the same event.
|
|
2380
|
+
if (!isDuplicateRecordError(error)) {
|
|
2381
|
+
throw error;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
function buildProviderEventBillingEventId(provider, providerEventId) {
|
|
2386
|
+
return `evt_${provider}_${Buffer.from(providerEventId).toString('base64url')}`;
|
|
2066
2387
|
}
|
|
2067
2388
|
async function createRefundRecord(deps, input) {
|
|
2068
2389
|
const refund = {
|
|
@@ -2250,23 +2571,81 @@ async function resolveProjectionContextForProviderState(deps, input) {
|
|
|
2250
2571
|
};
|
|
2251
2572
|
}
|
|
2252
2573
|
async function processParsedWebhookEvent(deps, providerName, event) {
|
|
2253
|
-
|
|
2574
|
+
const state = event.billingState;
|
|
2575
|
+
if (!state) {
|
|
2254
2576
|
return null;
|
|
2255
2577
|
}
|
|
2256
|
-
|
|
2578
|
+
let projection = await resolveProjectionContextForProviderState(deps, {
|
|
2257
2579
|
provider: providerName,
|
|
2258
|
-
state
|
|
2580
|
+
state,
|
|
2259
2581
|
checkoutReferenceId: event.checkoutReferenceId
|
|
2260
2582
|
});
|
|
2261
2583
|
if (!projection) {
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2584
|
+
const outcome = await bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, state);
|
|
2585
|
+
if (!('linked' in outcome)) {
|
|
2586
|
+
return outcome.handled;
|
|
2587
|
+
}
|
|
2588
|
+
projection = outcome.linked;
|
|
2589
|
+
}
|
|
2590
|
+
if (projection.subscription) {
|
|
2591
|
+
return applyEventToLinkedSubscription(deps, providerName, { ...event, billingState: state }, projection.subscription);
|
|
2592
|
+
}
|
|
2593
|
+
// The event resolved only through a checkout session, so applying it links the reference:
|
|
2594
|
+
// events stored for it before now are replayed after it, newer ones applied and older ones skipped.
|
|
2595
|
+
const applied = await applyParsedWebhookEvent(deps, providerName, { ...event, billingState: state }, projection);
|
|
2596
|
+
const linked = applied?.subscription ??
|
|
2597
|
+
(await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId));
|
|
2598
|
+
if (linked) {
|
|
2599
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, linked);
|
|
2600
|
+
}
|
|
2601
|
+
return applied;
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Applies an event that resolved to a subscription. Events stored before its
|
|
2605
|
+
* reference was linked go first, so provider event-time order holds; an unread
|
|
2606
|
+
* window event does not hold this event back and is retried after it.
|
|
2607
|
+
*/
|
|
2608
|
+
async function applyEventToLinkedSubscription(deps, providerName, event, subscription) {
|
|
2609
|
+
const state = event.billingState;
|
|
2610
|
+
const { windowUnread } = await replayUnlinkedWebhookEventsOrThrow(deps, subscription, { deferWindowUnread: true });
|
|
2611
|
+
// This run's replay, or one another run finished, may have replaced the reference this event matched.
|
|
2612
|
+
const refreshed = await resolveProjectionContextForProviderState(deps, {
|
|
2613
|
+
provider: providerName,
|
|
2614
|
+
state,
|
|
2615
|
+
checkoutReferenceId: event.checkoutReferenceId
|
|
2616
|
+
});
|
|
2617
|
+
if (!refreshed) {
|
|
2618
|
+
// The event is retained, never applied to the subscription it matched nor used to bootstrap another.
|
|
2619
|
+
await storeUnlinkedWebhookEvent(deps, providerName, event, {
|
|
2620
|
+
providerReference: (state.providerSubscriptionId ?? state.providerChargeId),
|
|
2621
|
+
reason: 'provider_reference_replaced',
|
|
2622
|
+
metadataBillingAccountId: readBillingAccountMetadata(event, state)
|
|
2265
2623
|
});
|
|
2624
|
+
if (windowUnread) {
|
|
2625
|
+
// The replay that replaced the reference closed the window, so the unread event is now skipped;
|
|
2626
|
+
// this delivery's redelivery would no longer reach the subscription, so it is replayed here.
|
|
2627
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, subscription);
|
|
2628
|
+
}
|
|
2629
|
+
return null;
|
|
2630
|
+
}
|
|
2631
|
+
const linkedBefore = refreshed.subscription;
|
|
2632
|
+
const applied = await applyParsedWebhookEvent(deps, providerName, event, refreshed);
|
|
2633
|
+
// An event that adds a provider reference links the events stored for it, including an event
|
|
2634
|
+
// skipped as older than the subscription's state, which still links its missing references.
|
|
2635
|
+
const linkedAfter = applied?.subscription ?? (linkedBefore ? await getSubscriptionById(deps, linkedBefore.id) : null);
|
|
2636
|
+
const replayAgain = linkedAfter ?? linkedBefore;
|
|
2637
|
+
// A newer event closes the window, so the unread event is then skipped; one still unread fails this
|
|
2638
|
+
// delivery. One on a reference this event replaced was recorded as replaced by its write.
|
|
2639
|
+
if (replayAgain && (windowUnread || (linkedAfter && addsProviderReference(linkedBefore, linkedAfter)))) {
|
|
2640
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, replayAgain);
|
|
2266
2641
|
}
|
|
2642
|
+
return applied;
|
|
2643
|
+
}
|
|
2644
|
+
async function applyParsedWebhookEvent(deps, providerName, event, projection) {
|
|
2267
2645
|
if (projection.subscription &&
|
|
2268
2646
|
event.occurredAt &&
|
|
2269
|
-
|
|
2647
|
+
orderProviderEvent(projection.subscription, event.occurredAt) === 'skip') {
|
|
2648
|
+
await linkSkippedEventReferences(deps, projection.subscription, event.billingState);
|
|
2270
2649
|
deps.metrics.track('reconciliation.event.skipped', {
|
|
2271
2650
|
provider: providerName,
|
|
2272
2651
|
providerEventId: event.providerEventId,
|
|
@@ -2282,7 +2661,113 @@ async function processParsedWebhookEvent(deps, providerName, event) {
|
|
|
2282
2661
|
checkoutSession: projection.checkoutSession ?? undefined,
|
|
2283
2662
|
subscription: projection.subscription ?? undefined,
|
|
2284
2663
|
state: event.billingState,
|
|
2285
|
-
occurredAt: event.occurredAt
|
|
2664
|
+
occurredAt: event.occurredAt,
|
|
2665
|
+
// The provider event keys its lifecycle entry, so a delivery and a replay of it record it once.
|
|
2666
|
+
providerEventId: event.providerEventId
|
|
2667
|
+
});
|
|
2668
|
+
if (!applied) {
|
|
2669
|
+
deps.metrics.track('reconciliation.event.skipped', {
|
|
2670
|
+
provider: providerName,
|
|
2671
|
+
providerEventId: event.providerEventId,
|
|
2672
|
+
reason: 'out_of_order'
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
return applied;
|
|
2676
|
+
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Handles a verified event whose provider reference matches no local
|
|
2679
|
+
* subscription or checkout. Provider metadata naming an existing billing
|
|
2680
|
+
* account bootstraps the subscription from the provider record; any other
|
|
2681
|
+
* event is stored as unlinked, so the webhook is acknowledged and the event is
|
|
2682
|
+
* replayed once the reference is linked.
|
|
2683
|
+
*/
|
|
2684
|
+
async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, state) {
|
|
2685
|
+
const providerReference = state.providerSubscriptionId ?? state.providerChargeId;
|
|
2686
|
+
if (!providerReference) {
|
|
2687
|
+
deps.metrics.track('reconciliation.event.skipped', {
|
|
2688
|
+
provider: providerName,
|
|
2689
|
+
providerEventId: event.providerEventId,
|
|
2690
|
+
reason: 'provider_reference_missing'
|
|
2691
|
+
});
|
|
2692
|
+
return { handled: null };
|
|
2693
|
+
}
|
|
2694
|
+
const metadataBillingAccountId = readBillingAccountMetadata(event, state);
|
|
2695
|
+
const existing = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
2696
|
+
let reason;
|
|
2697
|
+
if (existing) {
|
|
2698
|
+
// A link that committed after this event's first lookup is applied to like any linked reference.
|
|
2699
|
+
const projection = await resolveProjectionContextForProviderState(deps, {
|
|
2700
|
+
provider: providerName,
|
|
2701
|
+
state,
|
|
2702
|
+
checkoutReferenceId: event.checkoutReferenceId
|
|
2703
|
+
});
|
|
2704
|
+
if (projection) {
|
|
2705
|
+
return { linked: projection };
|
|
2706
|
+
}
|
|
2707
|
+
// The subscription's billing account is gone; the reference must not be rebound to another one.
|
|
2708
|
+
reason = 'billing_account_unavailable';
|
|
2709
|
+
}
|
|
2710
|
+
else if (await namesReplacedReference(deps, providerName, event, state)) {
|
|
2711
|
+
// No subscription holds a reference a write replaced: the event never bootstraps another one.
|
|
2712
|
+
reason = 'provider_reference_replaced';
|
|
2713
|
+
}
|
|
2714
|
+
else if (metadataBillingAccountId) {
|
|
2715
|
+
const bootstrap = await bootstrapFromMetadata(deps, providerName, event, state, metadataBillingAccountId);
|
|
2716
|
+
if (typeof bootstrap !== 'string') {
|
|
2717
|
+
return bootstrap;
|
|
2718
|
+
}
|
|
2719
|
+
reason = bootstrap;
|
|
2720
|
+
}
|
|
2721
|
+
else {
|
|
2722
|
+
reason = 'billing_account_metadata_missing';
|
|
2723
|
+
}
|
|
2724
|
+
await storeUnlinkedWebhookEvent(deps, providerName, event, {
|
|
2725
|
+
providerReference,
|
|
2726
|
+
reason,
|
|
2727
|
+
metadataBillingAccountId
|
|
2728
|
+
});
|
|
2729
|
+
if (existing) {
|
|
2730
|
+
return { handled: null };
|
|
2731
|
+
}
|
|
2732
|
+
// A link that committed while this event was being stored would have missed it; replay it now.
|
|
2733
|
+
const linked = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
2734
|
+
if (linked) {
|
|
2735
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, linked);
|
|
2736
|
+
}
|
|
2737
|
+
return { handled: null };
|
|
2738
|
+
}
|
|
2739
|
+
/** Bootstraps the subscription for the account the metadata names, or returns why the event is stored unlinked. */
|
|
2740
|
+
async function bootstrapFromMetadata(deps, providerName, event, state, billingAccountId) {
|
|
2741
|
+
const billingAccount = await getBillingAccountById(deps, billingAccountId);
|
|
2742
|
+
if (!billingAccount) {
|
|
2743
|
+
return 'billing_account_not_found';
|
|
2744
|
+
}
|
|
2745
|
+
const eventPrice = await resolveProviderEventPriceReference(deps, providerName, event, null);
|
|
2746
|
+
if (!eventPrice) {
|
|
2747
|
+
return 'price_not_found';
|
|
2748
|
+
}
|
|
2749
|
+
return {
|
|
2750
|
+
handled: await bootstrapSubscriptionFromWebhookEvent(deps, providerName, event, {
|
|
2751
|
+
billingAccount,
|
|
2752
|
+
plan: eventPrice.plan,
|
|
2753
|
+
price: eventPrice.price,
|
|
2754
|
+
state
|
|
2755
|
+
})
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
async function bootstrapSubscriptionFromWebhookEvent(deps, providerName, event, input) {
|
|
2759
|
+
// A concurrent delivery that creates the same reference first makes this an ordered update.
|
|
2760
|
+
const applied = await applyVerificationState(deps, {
|
|
2761
|
+
billingAccount: input.billingAccount,
|
|
2762
|
+
plan: input.plan,
|
|
2763
|
+
price: input.price,
|
|
2764
|
+
state: input.state,
|
|
2765
|
+
occurredAt: event.occurredAt,
|
|
2766
|
+
providerEventId: event.providerEventId
|
|
2767
|
+
});
|
|
2768
|
+
deps.metrics.track('webhook.subscription.bootstrapped', {
|
|
2769
|
+
provider: providerName,
|
|
2770
|
+
applied: Boolean(applied)
|
|
2286
2771
|
});
|
|
2287
2772
|
if (!applied) {
|
|
2288
2773
|
deps.metrics.track('reconciliation.event.skipped', {
|
|
@@ -2291,28 +2776,716 @@ async function processParsedWebhookEvent(deps, providerName, event) {
|
|
|
2291
2776
|
reason: 'out_of_order'
|
|
2292
2777
|
});
|
|
2293
2778
|
}
|
|
2779
|
+
const subscription = applied?.subscription ??
|
|
2780
|
+
(await findSubscriptionByProviderRef(deps, providerName, input.state.providerSubscriptionId, input.state.providerChargeId));
|
|
2781
|
+
if (subscription) {
|
|
2782
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, subscription);
|
|
2783
|
+
}
|
|
2294
2784
|
return applied;
|
|
2295
2785
|
}
|
|
2786
|
+
async function storeUnlinkedWebhookEvent(deps, providerName, event, input) {
|
|
2787
|
+
const now = deps.now();
|
|
2788
|
+
const timestamp = toIsoString(now);
|
|
2789
|
+
const record = {
|
|
2790
|
+
id: buildUnlinkedWebhookEventId(providerName, event.providerEventId),
|
|
2791
|
+
provider: providerName,
|
|
2792
|
+
providerEventId: event.providerEventId,
|
|
2793
|
+
providerReference: input.providerReference,
|
|
2794
|
+
// The charge of an event that also names its subscription, so linking either reference finds it.
|
|
2795
|
+
providerSecondaryReference: event.billingState?.providerChargeId && event.billingState.providerChargeId !== input.providerReference
|
|
2796
|
+
? event.billingState.providerChargeId
|
|
2797
|
+
: null,
|
|
2798
|
+
eventType: event.type,
|
|
2799
|
+
occurredAt: event.occurredAt ?? null,
|
|
2800
|
+
reason: input.reason,
|
|
2801
|
+
metadataBillingAccountId: input.metadataBillingAccountId,
|
|
2802
|
+
status: 'unlinked',
|
|
2803
|
+
event: serializeParsedWebhookEvent(event),
|
|
2804
|
+
replayClaimId: null,
|
|
2805
|
+
replayClaimedAt: null,
|
|
2806
|
+
replayedAt: null,
|
|
2807
|
+
subscriptionId: null,
|
|
2808
|
+
error: null,
|
|
2809
|
+
expiresAt: toIsoString(new Date(now.getTime() + UNLINKED_WEBHOOK_EVENT_RETENTION_MS)),
|
|
2810
|
+
createdAt: timestamp,
|
|
2811
|
+
updatedAt: timestamp
|
|
2812
|
+
};
|
|
2813
|
+
try {
|
|
2814
|
+
await deps.db.create({
|
|
2815
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
2816
|
+
data: record,
|
|
2817
|
+
namespace: deps.namespace
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
catch (error) {
|
|
2821
|
+
// A redelivered or replayed event keeps the row it already has, but a replaced reference is recorded
|
|
2822
|
+
// on it, so the event never bootstraps a subscription later.
|
|
2823
|
+
if (!isDuplicateRecordError(error)) {
|
|
2824
|
+
throw error;
|
|
2825
|
+
}
|
|
2826
|
+
if (input.reason === 'provider_reference_replaced') {
|
|
2827
|
+
await deps.db.updateMany({
|
|
2828
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
2829
|
+
where: [{ field: 'id', operator: 'eq', value: record.id }],
|
|
2830
|
+
data: { reason: input.reason, updatedAt: timestamp },
|
|
2831
|
+
namespace: deps.namespace
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
deps.metrics.track('webhook.event.unlinked', {
|
|
2837
|
+
provider: providerName,
|
|
2838
|
+
reason: input.reason
|
|
2839
|
+
});
|
|
2840
|
+
await purgeExpiredUnlinkedWebhookEvents(deps, timestamp);
|
|
2841
|
+
}
|
|
2842
|
+
async function purgeExpiredUnlinkedWebhookEvents(deps, timestamp) {
|
|
2843
|
+
try {
|
|
2844
|
+
await deps.db.deleteMany({
|
|
2845
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
2846
|
+
where: [{ field: 'expiresAt', operator: 'lt', value: timestamp }],
|
|
2847
|
+
namespace: deps.namespace
|
|
2848
|
+
});
|
|
2849
|
+
}
|
|
2850
|
+
catch (error) {
|
|
2851
|
+
// Retention cleanup must not make the provider redeliver an event that is already stored.
|
|
2852
|
+
deps.metrics.track('webhook.unlinked.purge_failed', {
|
|
2853
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Applies unexpired unlinked events for the subscription's provider references
|
|
2859
|
+
* in provider event-time order, as one ordered queue. When an applied or
|
|
2860
|
+
* skipped event gives the subscription a provider reference, including one it
|
|
2861
|
+
* held earlier in the run, the events stored for that reference (as primary or
|
|
2862
|
+
* secondary reference) are merged into the queue in order before the next
|
|
2863
|
+
* event, so an event naming a reference the subscription held only between two
|
|
2864
|
+
* replayed events is still found. An event
|
|
2865
|
+
* older than the subscription's current provider state (the last applied
|
|
2866
|
+
* event, or a live provider read) is marked skipped. An event that no longer
|
|
2867
|
+
* resolves to a subscription, because an earlier replayed event replaced its
|
|
2868
|
+
* reference, stays unlinked with reason `provider_reference_replaced` and the
|
|
2869
|
+
* replay continues past it. Every write that drops a reference records it as
|
|
2870
|
+
* replaced (`recordReplacedProviderReferences`) and marks the events still
|
|
2871
|
+
* unlinked on it, as does the end of the pass for one held during it and lost
|
|
2872
|
+
* since; they no longer count as left at the bound. An event inside a
|
|
2873
|
+
* committed mutation's window whose provider read fails (no queue) stays
|
|
2874
|
+
* unlinked with the error, the replay continues, and the operation fails
|
|
2875
|
+
* afterwards so it is retried. A
|
|
2876
|
+
* replay stops at the first event another replay holds or that fails to apply,
|
|
2877
|
+
* leaving it and later events for the next replay, so the order is kept.
|
|
2878
|
+
*/
|
|
2879
|
+
async function replayUnlinkedWebhookEvents(deps, subscription) {
|
|
2880
|
+
const result = { applied: 0, skipped: 0, pending: 0 };
|
|
2881
|
+
const queue = createUnlinkedReplayQueue(deps, subscription.provider);
|
|
2882
|
+
let latest = subscription;
|
|
2883
|
+
await queue.discover(subscription);
|
|
2884
|
+
for (let processed = 0; processed < UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS; processed += 1) {
|
|
2885
|
+
const candidate = queue.next();
|
|
2886
|
+
if (!candidate) {
|
|
2887
|
+
break;
|
|
2888
|
+
}
|
|
2889
|
+
const step = await replayUnlinkedWebhookEvent(deps, candidate, result);
|
|
2890
|
+
if (step === 'stopped') {
|
|
2891
|
+
result.pending = queue.remaining() + 1;
|
|
2892
|
+
break;
|
|
2893
|
+
}
|
|
2894
|
+
if (step === 'continue') {
|
|
2895
|
+
// Left unlinked (or finished elsewhere): it is queued again if its reference is regained.
|
|
2896
|
+
queue.release(candidate);
|
|
2897
|
+
}
|
|
2898
|
+
// Applied events, skipped ones that linked a missing reference, and events another run finished
|
|
2899
|
+
// (which may have stopped before replaying what they linked) can add a reference.
|
|
2900
|
+
const current = await getSubscriptionById(deps, subscription.id);
|
|
2901
|
+
if (current) {
|
|
2902
|
+
latest = current;
|
|
2903
|
+
await queue.discover(current);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
// Events on a reference held earlier in the pass and lost since (including to another run) are
|
|
2907
|
+
// marked replaced; they are no longer replayable.
|
|
2908
|
+
const latestReferences = providerReferencesOf(latest);
|
|
2909
|
+
const lost = queue.heldReferences().filter((reference) => !latestReferences.includes(reference));
|
|
2910
|
+
await recordReplacedReferenceEvents(deps, subscription.provider, lost, latestReferences);
|
|
2911
|
+
// An unread event on a reference the subscription no longer holds is recorded as replaced, not retried.
|
|
2912
|
+
result.windowUnread = result.unreadRows?.find((unread) => namesAnyReference(unread.row, latestReferences));
|
|
2913
|
+
const remaining = queue.remaining(latestReferences);
|
|
2914
|
+
if (!result.held && !result.failure && remaining > 0) {
|
|
2915
|
+
// The bound was reached with replayable events left: the replay is unfinished, so the operation is retried.
|
|
2916
|
+
result.pending = remaining;
|
|
2917
|
+
result.bounded = true;
|
|
2918
|
+
}
|
|
2919
|
+
if (result.applied > 0 || result.skipped > 0) {
|
|
2920
|
+
deps.metrics.track('webhook.unlinked.replayed', {
|
|
2921
|
+
provider: subscription.provider,
|
|
2922
|
+
applied: result.applied,
|
|
2923
|
+
skipped: result.skipped
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
return result;
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* The ordered replay queue. `discover` merges the unexpired events stored for
|
|
2930
|
+
* every reference the subscription gained since the previous discovery (a
|
|
2931
|
+
* first gain or a regain); `release` lets a visited event that stayed unlinked
|
|
2932
|
+
* be queued again. `heldReferences` lists every reference seen in the pass.
|
|
2933
|
+
*/
|
|
2934
|
+
function createUnlinkedReplayQueue(deps, provider) {
|
|
2935
|
+
const timestamp = toIsoString(deps.now());
|
|
2936
|
+
let observed = new Set();
|
|
2937
|
+
const held = new Set();
|
|
2938
|
+
const queued = new Set();
|
|
2939
|
+
let pending = [];
|
|
2940
|
+
return {
|
|
2941
|
+
async discover(subscription) {
|
|
2942
|
+
const current = providerReferencesOf(subscription);
|
|
2943
|
+
const gained = current.filter((reference) => !observed.has(reference));
|
|
2944
|
+
observed = new Set(current);
|
|
2945
|
+
current.forEach((reference) => held.add(reference));
|
|
2946
|
+
if (gained.length === 0) {
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
const found = await findUnlinkedWebhookEventsForReferences(deps, provider, gained, timestamp);
|
|
2950
|
+
const added = found.filter((row) => !queued.has(row.id));
|
|
2951
|
+
added.forEach((row) => queued.add(row.id));
|
|
2952
|
+
pending = [...pending, ...added].sort(compareUnlinkedWebhookEvents);
|
|
2953
|
+
},
|
|
2954
|
+
release: (row) => queued.delete(row.id),
|
|
2955
|
+
/** Every reference the subscription held during the pass. */
|
|
2956
|
+
heldReferences: () => [...held],
|
|
2957
|
+
next: () => pending.shift(),
|
|
2958
|
+
/** Queued events, or with `references` only those naming one: the ones a later replay still finds. */
|
|
2959
|
+
remaining: (references) => references ? pending.filter((row) => namesAnyReference(row, references)).length : pending.length
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Marks every unfinished event stored for a reference a subscription dropped,
|
|
2964
|
+
* and naming none it kept, with reason `provider_reference_replaced`, whatever
|
|
2965
|
+
* it was left as (window-unread, unresolved, not visited, or claimed by a
|
|
2966
|
+
* replay run that may never release it). The recorded reference, not this
|
|
2967
|
+
* reason, keeps such an event from bootstrapping. Only the reason (and an
|
|
2968
|
+
* unclaimed event's last error) is written, never on an event another run
|
|
2969
|
+
* finished, so a run holding the event
|
|
2970
|
+
* keeps its claim. A regain still replays it, since discovery ignores the
|
|
2971
|
+
* reason, as does the subscription that holds a reference it names.
|
|
2972
|
+
*/
|
|
2973
|
+
async function recordReplacedReferenceEvents(deps, provider, dropped, kept) {
|
|
2974
|
+
if (dropped.length === 0) {
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
const timestamp = toIsoString(deps.now());
|
|
2978
|
+
const rows = await findUnlinkedWebhookEventsForReferences(deps, provider, dropped, timestamp);
|
|
2979
|
+
const stale = rows.filter((row) => row.reason !== 'provider_reference_replaced' && !namesAnyReference(row, kept));
|
|
2980
|
+
const replaced = { reason: 'provider_reference_replaced', updatedAt: timestamp };
|
|
2981
|
+
const record = (row, statuses, data) => deps.db.updateMany({
|
|
2982
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
2983
|
+
where: [
|
|
2984
|
+
{ field: 'id', operator: 'eq', value: row.id },
|
|
2985
|
+
{ field: 'status', operator: 'in', value: statuses }
|
|
2986
|
+
],
|
|
2987
|
+
data,
|
|
2988
|
+
namespace: deps.namespace
|
|
2989
|
+
});
|
|
2990
|
+
// An unclaimed event also drops its last replay error; a claimed one gets only the reason.
|
|
2991
|
+
await Promise.all(stale.map(async (row) => {
|
|
2992
|
+
if ((await record(row, ['unlinked'], { ...replaced, error: null })) === 0) {
|
|
2993
|
+
await record(row, ['unlinked', 'replaying'], replaced);
|
|
2994
|
+
}
|
|
2995
|
+
}));
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Before a write from `before` to `after`, records each reference it drops, and
|
|
2999
|
+
* each one `before` has pending that `after` does not hold, as replaced
|
|
3000
|
+
* (`recordReplacedProviderReferences`), then marks the retained events on them.
|
|
3001
|
+
* A failure throws, except during a committed provider mutation, which is never
|
|
3002
|
+
* failed by it: the references it could not record are returned, and its write
|
|
3003
|
+
* saves them pending (`withPendingReplacements`). Pending references are
|
|
3004
|
+
* recorded here before the caller's write clears them, so a guard that reads
|
|
3005
|
+
* pending and then recorded references never misses one
|
|
3006
|
+
* (`namesReplacedReference`).
|
|
3007
|
+
*/
|
|
3008
|
+
async function recordDroppedReferences(deps, before, after, committed) {
|
|
3009
|
+
const kept = providerReferencesOf(after);
|
|
3010
|
+
const dropped = [...new Set([...providerReferencesOf(before), ...pendingReplacedReferencesOf(before)])].filter((reference) => !kept.includes(reference));
|
|
3011
|
+
if (dropped.length === 0) {
|
|
3012
|
+
return [];
|
|
3013
|
+
}
|
|
3014
|
+
try {
|
|
3015
|
+
await recordReplacedProviderReferences(deps, before, dropped);
|
|
3016
|
+
}
|
|
3017
|
+
catch (error) {
|
|
3018
|
+
if (!committed) {
|
|
3019
|
+
throw error;
|
|
3020
|
+
}
|
|
3021
|
+
return dropped;
|
|
3022
|
+
}
|
|
3023
|
+
try {
|
|
3024
|
+
await recordReplacedReferenceEvents(deps, before.provider, dropped, kept);
|
|
3025
|
+
}
|
|
3026
|
+
catch (error) {
|
|
3027
|
+
// Once the reference is recorded, the events' reason is informational.
|
|
3028
|
+
if (!committed) {
|
|
3029
|
+
throw error;
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return [];
|
|
3033
|
+
}
|
|
3034
|
+
/**
|
|
3035
|
+
* Records references a subscription dropped in `replacedProviderReferences`,
|
|
3036
|
+
* one row per provider and reference, naming the subscription that dropped it
|
|
3037
|
+
* last. While no subscription holds the reference, no event naming it, with
|
|
3038
|
+
* any event id or billing account metadata, bootstraps a subscription.
|
|
3039
|
+
*/
|
|
3040
|
+
async function recordReplacedProviderReferences(deps, subscription, references) {
|
|
3041
|
+
const timestamp = toIsoString(deps.now());
|
|
3042
|
+
const data = { subscriptionId: subscription.id, billingAccountId: subscription.billingAccountId, replacedAt: timestamp, updatedAt: timestamp };
|
|
3043
|
+
await Promise.all(references.map(async (providerReference) => {
|
|
3044
|
+
const id = `rpr_${subscription.provider}_${Buffer.from(providerReference).toString('base64url')}`;
|
|
3045
|
+
const update = () => deps.db.updateMany({
|
|
3046
|
+
model: TABLES.replacedProviderReferences,
|
|
3047
|
+
where: [{ field: 'id', operator: 'eq', value: id }],
|
|
3048
|
+
data,
|
|
3049
|
+
namespace: deps.namespace
|
|
3050
|
+
});
|
|
3051
|
+
if ((await update()) > 0) {
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
try {
|
|
3055
|
+
await deps.db.create({
|
|
3056
|
+
model: TABLES.replacedProviderReferences,
|
|
3057
|
+
data: { id, provider: subscription.provider, providerReference, ...data, createdAt: timestamp },
|
|
3058
|
+
namespace: deps.namespace
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
catch (error) {
|
|
3062
|
+
// A concurrent write recorded it first.
|
|
3063
|
+
if (!isDuplicateRecordError(error)) {
|
|
3064
|
+
throw error;
|
|
3065
|
+
}
|
|
3066
|
+
await update();
|
|
3067
|
+
}
|
|
3068
|
+
}));
|
|
3069
|
+
}
|
|
3070
|
+
/** The references a subscription's write saved pending, without any it holds again. */
|
|
3071
|
+
function pendingReplacedReferencesOf(subscription) {
|
|
3072
|
+
const pending = subscription.metadata?.[PENDING_REPLACED_REFERENCES_METADATA_KEY];
|
|
3073
|
+
const held = new Set(providerReferencesOf(subscription));
|
|
3074
|
+
return Array.isArray(pending)
|
|
3075
|
+
? pending.filter((reference) => typeof reference === 'string' && !held.has(reference))
|
|
3076
|
+
: [];
|
|
3077
|
+
}
|
|
3078
|
+
/** A write's metadata and flag for the references it saves pending; both are cleared once none are. */
|
|
3079
|
+
function withPendingReplacements(metadata, pending) {
|
|
3080
|
+
const { [PENDING_REPLACED_REFERENCES_METADATA_KEY]: _previous, ...rest } = metadata ?? {};
|
|
3081
|
+
return pending.length > 0
|
|
3082
|
+
? { metadata: { ...rest, [PENDING_REPLACED_REFERENCES_METADATA_KEY]: pending }, replacedReferencesPending: true }
|
|
3083
|
+
: { metadata: rest, replacedReferencesPending: null };
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Whether an event that resolves to no subscription names a replaced
|
|
3087
|
+
* reference, whatever its event id or the account its metadata names: one a
|
|
3088
|
+
* subscription write dropped and recorded, one a committed mutation's write
|
|
3089
|
+
* saved pending (any subscription of the provider), or the one it was
|
|
3090
|
+
* retained on when a replay replaced it. A reference a subscription holds
|
|
3091
|
+
* again resolves to it first. Only bootstrap is gated by it.
|
|
3092
|
+
*
|
|
3093
|
+
* Linearization contract with the writers: a write that drops a held
|
|
3094
|
+
* reference either records it (`recordDroppedReferences`) before the write or
|
|
3095
|
+
* saves it pending in that same write; a later write records a pending
|
|
3096
|
+
* reference before its write clears the pending flag; recorded references are
|
|
3097
|
+
* never deleted. The reads therefore run one after another in the opposite
|
|
3098
|
+
* order, after the caller found no holder: pending, then recorded, then the
|
|
3099
|
+
* retained event. A writer that clears pending between them has already
|
|
3100
|
+
* recorded the reference, so one of the reads sees it.
|
|
3101
|
+
*/
|
|
3102
|
+
async function namesReplacedReference(deps, provider, event, state) {
|
|
3103
|
+
const references = [state.providerSubscriptionId, state.providerChargeId].filter((reference) => typeof reference === 'string' && reference.length > 0);
|
|
3104
|
+
const named = new Set(references);
|
|
3105
|
+
if (named.size > 0) {
|
|
3106
|
+
const pending = await deps.db.findMany({
|
|
3107
|
+
model: TABLES.subscriptions,
|
|
3108
|
+
where: [
|
|
3109
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
3110
|
+
{ field: 'replacedReferencesPending', operator: 'eq', value: true }
|
|
3111
|
+
],
|
|
3112
|
+
namespace: deps.namespace
|
|
3113
|
+
});
|
|
3114
|
+
if (pending.some((subscription) => pendingReplacedReferencesOf(subscription).some((reference) => named.has(reference)))) {
|
|
3115
|
+
return true;
|
|
3116
|
+
}
|
|
3117
|
+
const replaced = await deps.db.findMany({
|
|
3118
|
+
model: TABLES.replacedProviderReferences,
|
|
3119
|
+
where: [
|
|
3120
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
3121
|
+
{ field: 'providerReference', operator: 'in', value: references }
|
|
3122
|
+
],
|
|
3123
|
+
namespace: deps.namespace
|
|
3124
|
+
});
|
|
3125
|
+
if (replaced.length > 0) {
|
|
3126
|
+
return true;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
const retained = await deps.db.findOne({
|
|
3130
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3131
|
+
where: [{ field: 'id', operator: 'eq', value: buildUnlinkedWebhookEventId(provider, event.providerEventId) }],
|
|
3132
|
+
namespace: deps.namespace
|
|
3133
|
+
});
|
|
3134
|
+
return retained?.reason === 'provider_reference_replaced';
|
|
3135
|
+
}
|
|
3136
|
+
function namesAnyReference(row, references) {
|
|
3137
|
+
return [row.providerReference, row.providerSecondaryReference].some((reference) => typeof reference === 'string' && references.includes(reference));
|
|
3138
|
+
}
|
|
3139
|
+
async function findUnlinkedWebhookEventsForReferences(deps, provider, references, timestamp) {
|
|
3140
|
+
const byField = await Promise.all(['providerReference', 'providerSecondaryReference'].map((field) => deps.db.findMany({
|
|
3141
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3142
|
+
where: [
|
|
3143
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
3144
|
+
{ field, operator: 'in', value: references },
|
|
3145
|
+
{ field: 'status', operator: 'in', value: ['unlinked', 'replaying'] }
|
|
3146
|
+
],
|
|
3147
|
+
namespace: deps.namespace
|
|
3148
|
+
})));
|
|
3149
|
+
const rows = new Map(byField.flat().map((row) => [row.id, row]));
|
|
3150
|
+
return [...rows.values()].filter((row) => row.expiresAt > timestamp);
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Claims and applies one retained event, then finishes or releases its row.
|
|
3154
|
+
* `finished`: applied or skipped; `continue`: finished by another run, or
|
|
3155
|
+
* displaced, unresolved or window-unread and left unlinked; `stopped`: held by
|
|
3156
|
+
* another run or failed.
|
|
3157
|
+
* The caller looks for newly linked references after every step but `stopped`.
|
|
3158
|
+
*/
|
|
3159
|
+
async function replayUnlinkedWebhookEvent(deps, candidate, result) {
|
|
3160
|
+
const claimId = deps.generateId('uwr');
|
|
3161
|
+
const claim = await claimUnlinkedWebhookEvent(deps, candidate, claimId);
|
|
3162
|
+
if (claim === 'finished') {
|
|
3163
|
+
return 'continue';
|
|
3164
|
+
}
|
|
3165
|
+
if (claim === 'held') {
|
|
3166
|
+
result.held = true;
|
|
3167
|
+
return 'stopped';
|
|
3168
|
+
}
|
|
3169
|
+
let outcome = null;
|
|
3170
|
+
let failure = null;
|
|
3171
|
+
try {
|
|
3172
|
+
outcome = await applyUnlinkedWebhookEvent(deps, candidate);
|
|
3173
|
+
}
|
|
3174
|
+
catch (error) {
|
|
3175
|
+
// Stop on any failure, whatever its message: a later event must not advance past this one.
|
|
3176
|
+
failure = errorMessage(error);
|
|
3177
|
+
result.failure = { error };
|
|
3178
|
+
}
|
|
3179
|
+
const finishedAt = toIsoString(deps.now());
|
|
3180
|
+
await deps.db.updateMany({
|
|
3181
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3182
|
+
where: [
|
|
3183
|
+
{ field: 'id', operator: 'eq', value: candidate.id },
|
|
3184
|
+
{ field: 'replayClaimId', operator: 'eq', value: claimId }
|
|
3185
|
+
],
|
|
3186
|
+
data: unlinkedReplayRowUpdate(outcome, failure, finishedAt),
|
|
3187
|
+
namespace: deps.namespace
|
|
3188
|
+
});
|
|
3189
|
+
if (!outcome) {
|
|
3190
|
+
deps.metrics.track('webhook.unlinked.replay_stopped', {
|
|
3191
|
+
provider: candidate.provider,
|
|
3192
|
+
providerEventId: candidate.providerEventId,
|
|
3193
|
+
reason: 'error'
|
|
3194
|
+
});
|
|
3195
|
+
return 'stopped';
|
|
3196
|
+
}
|
|
3197
|
+
if (outcome.kind === 'replayed' || outcome.kind === 'skipped') {
|
|
3198
|
+
result[outcome.kind === 'replayed' ? 'applied' : 'skipped'] += 1;
|
|
3199
|
+
return 'finished';
|
|
3200
|
+
}
|
|
3201
|
+
if (outcome.kind === 'window_unread') {
|
|
3202
|
+
// Retained with its error; the operation fails after the pass, so its retry reads the provider again.
|
|
3203
|
+
result.unreadRows = [...(result.unreadRows ?? []), { row: candidate, error: outcome.error }];
|
|
3204
|
+
}
|
|
3205
|
+
// A displaced event, delivered in order, would have been stored unlinked as well, so it stays
|
|
3206
|
+
// unlinked (and replays if its reference is linked again) and does not hold back later events.
|
|
3207
|
+
deps.metrics.track('webhook.unlinked.replay_unresolved', {
|
|
3208
|
+
provider: candidate.provider,
|
|
3209
|
+
providerEventId: candidate.providerEventId,
|
|
3210
|
+
reason: outcome.kind
|
|
3211
|
+
});
|
|
3212
|
+
return 'continue';
|
|
3213
|
+
}
|
|
3214
|
+
/** Finishes a replayed or skipped row, or releases it as unlinked with its error and, if displaced, reason. */
|
|
3215
|
+
function unlinkedReplayRowUpdate(outcome, failure, finishedAt) {
|
|
3216
|
+
const released = { replayClaimId: null, replayClaimedAt: null, updatedAt: finishedAt };
|
|
3217
|
+
if (outcome?.kind === 'replayed' || outcome?.kind === 'skipped') {
|
|
3218
|
+
return { ...released, status: outcome.kind, subscriptionId: outcome.subscriptionId, replayedAt: finishedAt, error: null };
|
|
3219
|
+
}
|
|
3220
|
+
return {
|
|
3221
|
+
...released,
|
|
3222
|
+
status: 'unlinked',
|
|
3223
|
+
error: outcome?.kind === 'window_unread' ? errorMessage(outcome.error) : failure,
|
|
3224
|
+
// A replaced reference is recorded, so the event never bootstraps another subscription later.
|
|
3225
|
+
...(outcome?.kind === 'displaced' ? { reason: 'provider_reference_replaced' } : {})
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
function errorMessage(error) {
|
|
3229
|
+
return error instanceof Error ? error.message : String(error);
|
|
3230
|
+
}
|
|
3231
|
+
function providerReferencesOf(subscription) {
|
|
3232
|
+
return [...new Set([subscription.providerSubscriptionId, subscription.providerChargeId])].filter((reference) => typeof reference === 'string' && reference.length > 0);
|
|
3233
|
+
}
|
|
3234
|
+
/** Whether a write gave the subscription a provider reference it did not have, linking events stored for it. */
|
|
3235
|
+
function addsProviderReference(before, after) {
|
|
3236
|
+
const previous = before ? providerReferencesOf(before) : [];
|
|
3237
|
+
return providerReferencesOf(after).some((reference) => !previous.includes(reference));
|
|
3238
|
+
}
|
|
3239
|
+
async function applyUnlinkedWebhookEvent(deps, row) {
|
|
3240
|
+
const event = deserializeParsedWebhookEvent(row.event);
|
|
3241
|
+
const state = event?.billingState;
|
|
3242
|
+
if (!event || !state) {
|
|
3243
|
+
return { kind: 'unresolved' };
|
|
3244
|
+
}
|
|
3245
|
+
const projection = await resolveProjectionContextForProviderState(deps, {
|
|
3246
|
+
provider: row.provider,
|
|
3247
|
+
state,
|
|
3248
|
+
checkoutReferenceId: event.checkoutReferenceId
|
|
3249
|
+
});
|
|
3250
|
+
if (!projection?.subscription) {
|
|
3251
|
+
// The row was found by a reference of the subscription, which an event replayed before it replaced.
|
|
3252
|
+
return { kind: 'displaced' };
|
|
3253
|
+
}
|
|
3254
|
+
// An event without a provider time is ordered by when it was stored, through the same ordered write.
|
|
3255
|
+
const occurredAt = event.occurredAt ?? row.createdAt;
|
|
3256
|
+
let applied;
|
|
3257
|
+
try {
|
|
3258
|
+
applied = await applyParsedWebhookEvent(deps, row.provider, { ...event, occurredAt, billingState: state }, projection);
|
|
3259
|
+
}
|
|
3260
|
+
catch (error) {
|
|
3261
|
+
// An unread event inside a mutation window stays retained without holding back the events after it.
|
|
3262
|
+
if (isWindowReadFailure(error)) {
|
|
3263
|
+
return { kind: 'window_unread', error };
|
|
3264
|
+
}
|
|
3265
|
+
throw error;
|
|
3266
|
+
}
|
|
3267
|
+
return applied
|
|
3268
|
+
? { kind: 'replayed', subscriptionId: applied.subscription.id }
|
|
3269
|
+
: { kind: 'skipped', subscriptionId: projection.subscription.id };
|
|
3270
|
+
}
|
|
3271
|
+
/**
|
|
3272
|
+
* Claims a row for this replay run. `finished` means another run completed it
|
|
3273
|
+
* since it was read; `held` means another run holds an unexpired claim on it,
|
|
3274
|
+
* or released it after a failure, so it is not finished.
|
|
3275
|
+
*/
|
|
3276
|
+
async function claimUnlinkedWebhookEvent(deps, row, claimId) {
|
|
3277
|
+
const claimTime = deps.now();
|
|
3278
|
+
const where = [
|
|
3279
|
+
{ field: 'id', operator: 'eq', value: row.id },
|
|
3280
|
+
{ field: 'status', operator: 'eq', value: row.status }
|
|
3281
|
+
];
|
|
3282
|
+
if (row.status === 'replaying') {
|
|
3283
|
+
const previousClaimedAt = row.replayClaimedAt ? Date.parse(row.replayClaimedAt) : Number.NaN;
|
|
3284
|
+
if (Number.isFinite(previousClaimedAt) &&
|
|
3285
|
+
claimTime.getTime() - previousClaimedAt < UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS) {
|
|
3286
|
+
return 'held';
|
|
3287
|
+
}
|
|
3288
|
+
where.push({ field: 'replayClaimId', operator: 'eq', value: row.replayClaimId ?? null });
|
|
3289
|
+
}
|
|
3290
|
+
const claimed = await deps.db.updateMany({
|
|
3291
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3292
|
+
where,
|
|
3293
|
+
data: {
|
|
3294
|
+
status: 'replaying',
|
|
3295
|
+
replayClaimId: claimId,
|
|
3296
|
+
replayClaimedAt: toIsoString(claimTime),
|
|
3297
|
+
updatedAt: toIsoString(claimTime)
|
|
3298
|
+
},
|
|
3299
|
+
namespace: deps.namespace
|
|
3300
|
+
});
|
|
3301
|
+
if (claimed === 1) {
|
|
3302
|
+
return 'claimed';
|
|
3303
|
+
}
|
|
3304
|
+
const current = await deps.db.findOne({
|
|
3305
|
+
model: TABLES.unlinkedWebhookEvents,
|
|
3306
|
+
where: [{ field: 'id', operator: 'eq', value: row.id }],
|
|
3307
|
+
namespace: deps.namespace
|
|
3308
|
+
});
|
|
3309
|
+
return !current || current.status === 'replayed' || current.status === 'skipped' ? 'finished' : 'held';
|
|
3310
|
+
}
|
|
3311
|
+
function compareUnlinkedWebhookEvents(left, right) {
|
|
3312
|
+
const leftTime = left.occurredAt ? parseProviderEventTime(left.occurredAt) : null;
|
|
3313
|
+
const rightTime = right.occurredAt ? parseProviderEventTime(right.occurredAt) : null;
|
|
3314
|
+
if (leftTime !== rightTime) {
|
|
3315
|
+
// Events without a provider time cannot be ordered against timed ones and go last.
|
|
3316
|
+
if (leftTime === null) {
|
|
3317
|
+
return 1;
|
|
3318
|
+
}
|
|
3319
|
+
if (rightTime === null) {
|
|
3320
|
+
return -1;
|
|
3321
|
+
}
|
|
3322
|
+
return leftTime - rightTime;
|
|
3323
|
+
}
|
|
3324
|
+
if (left.createdAt !== right.createdAt) {
|
|
3325
|
+
return left.createdAt < right.createdAt ? -1 : 1;
|
|
3326
|
+
}
|
|
3327
|
+
if (left.id === right.id) {
|
|
3328
|
+
return 0;
|
|
3329
|
+
}
|
|
3330
|
+
return left.id < right.id ? -1 : 1;
|
|
3331
|
+
}
|
|
3332
|
+
/**
|
|
3333
|
+
* Replays and fails the operation that linked the reference unless every
|
|
3334
|
+
* retained event for it is finished, so that operation is retried (a webhook
|
|
3335
|
+
* receipt stays unprocessed and the provider redelivers it, a queued webhook
|
|
3336
|
+
* job is retried after the replay lease expires, and checkout verification or
|
|
3337
|
+
* restore can be called again). An event that could not be applied is
|
|
3338
|
+
* released first and its error rethrown; an event another replay run still
|
|
3339
|
+
* holds, including a run interrupted before it released the event, raises a
|
|
3340
|
+
* lease conflict. An event inside a mutation window whose read failed raises
|
|
3341
|
+
* that window-read error once the rest of the replay is done, unless its
|
|
3342
|
+
* reference was replaced meanwhile or `deferWindowUnread` returns it for the
|
|
3343
|
+
* caller to raise later.
|
|
3344
|
+
*/
|
|
3345
|
+
async function replayUnlinkedWebhookEventsOrThrow(deps, subscription, options = {}) {
|
|
3346
|
+
const result = await replayUnlinkedWebhookEvents(deps, subscription);
|
|
3347
|
+
if (result.failure) {
|
|
3348
|
+
throw result.failure.error;
|
|
3349
|
+
}
|
|
3350
|
+
if (result.held) {
|
|
3351
|
+
throw reconciliationLeaseConflict('Unlinked webhook events for this provider reference are being replayed');
|
|
3352
|
+
}
|
|
3353
|
+
if (result.bounded) {
|
|
3354
|
+
throw unlinkedReplayBounded(result.applied + result.skipped > 0);
|
|
3355
|
+
}
|
|
3356
|
+
if (result.windowUnread && !options.deferWindowUnread) {
|
|
3357
|
+
throw result.windowUnread.error;
|
|
3358
|
+
}
|
|
3359
|
+
return result;
|
|
3360
|
+
}
|
|
3361
|
+
const unlinkedReplayBoundErrors = new WeakSet();
|
|
3362
|
+
const productiveReplayBoundErrors = new WeakSet();
|
|
3363
|
+
/**
|
|
3364
|
+
* An unfinished replay that reached its bound, reported as `bounded`. One that
|
|
3365
|
+
* finished events (`productive`) is continued by a queue worker in a fresh
|
|
3366
|
+
* message; otherwise, and without a queue, it is retried like a lease conflict.
|
|
3367
|
+
*/
|
|
3368
|
+
function unlinkedReplayBounded(productive) {
|
|
3369
|
+
const error = reconciliationLeaseConflict('Unlinked webhook event replay reached its bound with events left');
|
|
3370
|
+
unlinkedReplayBoundErrors.add(error);
|
|
3371
|
+
if (productive) {
|
|
3372
|
+
productiveReplayBoundErrors.add(error);
|
|
3373
|
+
}
|
|
3374
|
+
return error;
|
|
3375
|
+
}
|
|
3376
|
+
function isProductiveReplayBound(error) {
|
|
3377
|
+
return typeof error === 'object' && error !== null && productiveReplayBoundErrors.has(error);
|
|
3378
|
+
}
|
|
3379
|
+
function replayDeferredReason(error) {
|
|
3380
|
+
if (typeof error === 'object' && error !== null && unlinkedReplayBoundErrors.has(error)) {
|
|
3381
|
+
return 'bounded';
|
|
3382
|
+
}
|
|
3383
|
+
return isReconciliationLeaseConflict(error) ? 'held' : 'error';
|
|
3384
|
+
}
|
|
3385
|
+
/**
|
|
3386
|
+
* Applies state from a live provider read or a provider mutation response
|
|
3387
|
+
* (checkout verification, restore, sync, cancel, change, resume, refund).
|
|
3388
|
+
* `liveStateObservedAt` is when the provider call started: a read saves it as
|
|
3389
|
+
* the subscription's provider-state time, and a state an event newer than it
|
|
3390
|
+
* has superseded only links its references. A committed mutation is ordered by
|
|
3391
|
+
* `committedAt`, when the provider call returned, instead. The retained unlinked events for the
|
|
3392
|
+
* resulting reference set are then replayed every time, not only when this
|
|
3393
|
+
* call added a reference, so a retry after an interrupted replay applies them;
|
|
3394
|
+
* events older than the read are skipped. Returns the projection after replay.
|
|
3395
|
+
*
|
|
3396
|
+
* A read (verification, restore, sync) fails when the replay does not finish,
|
|
3397
|
+
* so the read is retried. After a provider mutation has committed, the replay
|
|
3398
|
+
* is best-effort (`onReplayDeferred` set): an unfinished replay is recorded and
|
|
3399
|
+
* handed to `onReplayDeferred`, its events stay retained for the next webhook,
|
|
3400
|
+
* sync or live read, and the mutation still returns its result, because
|
|
3401
|
+
* retrying it would repeat the provider mutation.
|
|
3402
|
+
*/
|
|
3403
|
+
async function applyLiveProviderState(deps, input, options = {}) {
|
|
3404
|
+
const result = await applyVerificationState(deps, input);
|
|
3405
|
+
if (!options.onReplayDeferred) {
|
|
3406
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, result.subscription);
|
|
3407
|
+
return refreshProjectionAfterReplay(deps, result);
|
|
3408
|
+
}
|
|
3409
|
+
await replayAfterCommittedMutation(deps, result, options.onReplayDeferred);
|
|
3410
|
+
try {
|
|
3411
|
+
return { ...(await refreshProjectionAfterReplay(deps, result)), providerStateRaced: result.providerStateRaced };
|
|
3412
|
+
}
|
|
3413
|
+
catch (error) {
|
|
3414
|
+
// The provider mutation and its state are committed; a failed re-read returns the written state.
|
|
3415
|
+
deps.metrics.track('webhook.unlinked.replay_refresh_failed', {
|
|
3416
|
+
provider: result.subscription.provider,
|
|
3417
|
+
subscriptionId: result.subscription.id,
|
|
3418
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3419
|
+
});
|
|
3420
|
+
return result;
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
/** A replay after a committed provider mutation that cannot finish now is deferred, never failing the mutation. */
|
|
3424
|
+
async function replayAfterCommittedMutation(deps, { subscription }, onReplayDeferred) {
|
|
3425
|
+
try {
|
|
3426
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, subscription);
|
|
3427
|
+
}
|
|
3428
|
+
catch (error) {
|
|
3429
|
+
deps.metrics.track('webhook.unlinked.replay_deferred', {
|
|
3430
|
+
provider: subscription.provider,
|
|
3431
|
+
subscriptionId: subscription.id,
|
|
3432
|
+
reason: replayDeferredReason(error),
|
|
3433
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3434
|
+
});
|
|
3435
|
+
try {
|
|
3436
|
+
await onReplayDeferred(subscription);
|
|
3437
|
+
}
|
|
3438
|
+
catch (deferError) {
|
|
3439
|
+
deps.metrics.track('webhook.unlinked.replay_defer_failed', {
|
|
3440
|
+
provider: subscription.provider,
|
|
3441
|
+
subscriptionId: subscription.id,
|
|
3442
|
+
error: deferError instanceof Error ? deferError.message : String(deferError)
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
/** Another run may have replayed events for these references since the write, so the state is read again. */
|
|
3448
|
+
async function refreshProjectionAfterReplay(deps, result) {
|
|
3449
|
+
const subscription = (await getSubscriptionById(deps, result.subscription.id)) ?? result.subscription;
|
|
3450
|
+
const entitlements = (await findEntitlementSnapshot(deps, subscription.billingAccountId)) ?? result.entitlements;
|
|
3451
|
+
const checkoutSession = result.checkoutSession
|
|
3452
|
+
? ((await getCheckoutSession(deps, result.checkoutSession.checkoutSessionId)) ?? result.checkoutSession)
|
|
3453
|
+
: undefined;
|
|
3454
|
+
return { checkoutSession, subscription, entitlements };
|
|
3455
|
+
}
|
|
3456
|
+
function readBillingAccountMetadata(event, state) {
|
|
3457
|
+
const stateRaw = asRecord(state.raw);
|
|
3458
|
+
const candidates = [
|
|
3459
|
+
asRecord(stateRaw?.metadata),
|
|
3460
|
+
asRecord(event.raw.metadata),
|
|
3461
|
+
asRecord(asRecord(event.raw.data)?.metadata)
|
|
3462
|
+
];
|
|
3463
|
+
for (const metadata of candidates) {
|
|
3464
|
+
const value = metadata ? readString(metadata, BILLING_ACCOUNT_METADATA_KEY) : null;
|
|
3465
|
+
if (value) {
|
|
3466
|
+
return value;
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
return null;
|
|
3470
|
+
}
|
|
3471
|
+
function buildUnlinkedWebhookEventId(provider, providerEventId) {
|
|
3472
|
+
return `uwe_${provider}_${Buffer.from(providerEventId).toString('base64url')}`;
|
|
3473
|
+
}
|
|
2296
3474
|
async function resolveProviderEventPriceReference(deps, provider, event, subscription) {
|
|
2297
3475
|
const normalizedType = event.type.toLowerCase();
|
|
2298
3476
|
const schedulingOnly = normalizedType.includes('change_renewal_pref');
|
|
2299
3477
|
if (!schedulingOnly) {
|
|
2300
3478
|
const raw = asRecord(event.billingState?.raw);
|
|
3479
|
+
// A provider record may carry its product on the transaction it describes (Apple).
|
|
3480
|
+
const records = [raw, asRecord(raw?.transaction)];
|
|
2301
3481
|
const references = [
|
|
3482
|
+
event.billingState?.providerProductId,
|
|
2302
3483
|
event.priceId,
|
|
2303
|
-
|
|
2304
|
-
raw ? readString(raw, 'productId') : null,
|
|
2305
|
-
raw ? readString(raw, 'price_id') : null,
|
|
2306
|
-
raw ? readString(raw, 'priceId') : null
|
|
3484
|
+
...records.flatMap((record) => record ? ['product_id', 'productId', 'price_id', 'priceId'].map((key) => readString(record, key)) : [])
|
|
2307
3485
|
];
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
}
|
|
2312
|
-
const resolved = findCatalogPriceReference(deps.catalog, provider, reference);
|
|
2313
|
-
if (resolved) {
|
|
2314
|
-
return resolved;
|
|
2315
|
-
}
|
|
3486
|
+
const resolved = findCatalogPriceReference(deps.catalog, provider, references, subscription?.priceId);
|
|
3487
|
+
if (resolved) {
|
|
3488
|
+
return resolved;
|
|
2316
3489
|
}
|
|
2317
3490
|
}
|
|
2318
3491
|
if (!subscription || (!normalizedType.includes('renew') && !normalizedType.includes('plan_changed'))) {
|
|
@@ -2331,12 +3504,26 @@ async function resolveProviderEventPriceReference(deps, provider, event, subscri
|
|
|
2331
3504
|
const latest = changeRequests[0];
|
|
2332
3505
|
return latest ? requirePriceReference(deps.catalog, latest.targetPriceId) : null;
|
|
2333
3506
|
}
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
3507
|
+
/**
|
|
3508
|
+
* The catalog price the references name: an exact catalog price id first, then
|
|
3509
|
+
* a provider product id. A product several prices share names the current
|
|
3510
|
+
* price if it is one of them, else the first of them when they are all in one
|
|
3511
|
+
* plan; across plans it names none, so the caller keeps its stored selection.
|
|
3512
|
+
*/
|
|
3513
|
+
function findCatalogPriceReference(catalog, provider, references, currentPriceId) {
|
|
3514
|
+
const prices = catalog.plans.flatMap((plan) => plan.prices.filter((price) => price.provider === provider).map((price) => ({ plan, price })));
|
|
3515
|
+
const named = references.filter((reference) => Boolean(reference));
|
|
3516
|
+
for (const reference of named) {
|
|
3517
|
+
const exact = prices.find((candidate) => candidate.price.priceId === reference);
|
|
3518
|
+
if (exact) {
|
|
3519
|
+
return exact;
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
for (const reference of named) {
|
|
3523
|
+
const products = prices.filter((candidate) => candidate.price.providerProductId === reference);
|
|
3524
|
+
const current = products.find((candidate) => candidate.price.priceId === currentPriceId);
|
|
3525
|
+
if (current || new Set(products.map((candidate) => candidate.plan.planKey)).size === 1) {
|
|
3526
|
+
return current ?? products[0];
|
|
2340
3527
|
}
|
|
2341
3528
|
}
|
|
2342
3529
|
return null;
|
|
@@ -2421,9 +3608,21 @@ function readString(record, key) {
|
|
|
2421
3608
|
const value = record[key];
|
|
2422
3609
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
2423
3610
|
}
|
|
3611
|
+
function readProviderStateOccurredAt(subscription) {
|
|
3612
|
+
const metadata = asRecord(subscription?.metadata);
|
|
3613
|
+
return metadata ? readString(metadata, PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY) : null;
|
|
3614
|
+
}
|
|
3615
|
+
/** The later of two provider-state times; an unparsable current time is replaced. */
|
|
3616
|
+
function laterProviderEventTime(current, candidate) {
|
|
3617
|
+
const currentTime = current ? parseProviderEventTime(current) : null;
|
|
3618
|
+
const candidateTime = parseProviderEventTime(candidate);
|
|
3619
|
+
if (currentTime === null || candidateTime === null) {
|
|
3620
|
+
return currentTime === null ? candidate : current;
|
|
3621
|
+
}
|
|
3622
|
+
return candidateTime > currentTime ? candidate : current;
|
|
3623
|
+
}
|
|
2424
3624
|
function isProviderEventOlderThanSubscription(subscription, occurredAt) {
|
|
2425
|
-
const
|
|
2426
|
-
const lastOccurredAt = metadata ? readString(metadata, PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY) : null;
|
|
3625
|
+
const lastOccurredAt = readProviderStateOccurredAt(subscription);
|
|
2427
3626
|
if (!lastOccurredAt) {
|
|
2428
3627
|
return false;
|
|
2429
3628
|
}
|
|
@@ -2431,6 +3630,94 @@ function isProviderEventOlderThanSubscription(subscription, occurredAt) {
|
|
|
2431
3630
|
const currentTime = parseProviderEventTime(lastOccurredAt);
|
|
2432
3631
|
return incomingTime !== null && currentTime !== null && incomingTime < currentTime;
|
|
2433
3632
|
}
|
|
3633
|
+
/**
|
|
3634
|
+
* Orders an event against the subscription's provider state. A newer event is
|
|
3635
|
+
* applied and an older one skipped; one at the same time is applied. An event
|
|
3636
|
+
* inside the open window of the committed provider mutation that set that
|
|
3637
|
+
* state (after the provider call started, up to and including when it
|
|
3638
|
+
* returned) cannot be ordered against the mutation and is never applied over
|
|
3639
|
+
* it: the subscription is reconciled with a provider read instead.
|
|
3640
|
+
*/
|
|
3641
|
+
function orderProviderEvent(subscription, occurredAt) {
|
|
3642
|
+
const startedAt = readMutationStartedAt(subscription);
|
|
3643
|
+
const stateAt = readProviderStateOccurredAt(subscription);
|
|
3644
|
+
const startTime = startedAt ? parseProviderEventTime(startedAt) : null;
|
|
3645
|
+
const stateTime = stateAt ? parseProviderEventTime(stateAt) : null;
|
|
3646
|
+
const incomingTime = parseProviderEventTime(occurredAt);
|
|
3647
|
+
if (startTime !== null && stateTime !== null && incomingTime !== null && incomingTime > startTime && incomingTime <= stateTime) {
|
|
3648
|
+
return 'reconcile';
|
|
3649
|
+
}
|
|
3650
|
+
return isProviderEventOlderThanSubscription(subscription, occurredAt) ? 'skip' : 'apply';
|
|
3651
|
+
}
|
|
3652
|
+
function readMutationStartedAt(subscription) {
|
|
3653
|
+
const metadata = asRecord(subscription.metadata);
|
|
3654
|
+
return metadata ? readString(metadata, PROVIDER_MUTATION_STARTED_AT_METADATA_KEY) : null;
|
|
3655
|
+
}
|
|
3656
|
+
const windowReadFailures = new WeakSet();
|
|
3657
|
+
/** No provider read succeeded for an event inside a committed mutation's window and no sync could be queued. */
|
|
3658
|
+
function windowReadFailed(cause) {
|
|
3659
|
+
const error = createBillFnError({
|
|
3660
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
3661
|
+
message: `Provider read for an event inside a committed mutation window failed: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
3662
|
+
});
|
|
3663
|
+
windowReadFailures.add(error);
|
|
3664
|
+
return error;
|
|
3665
|
+
}
|
|
3666
|
+
function isWindowReadFailure(error) {
|
|
3667
|
+
return typeof error === 'object' && error !== null && windowReadFailures.has(error);
|
|
3668
|
+
}
|
|
3669
|
+
function withoutMutationWindow(metadata) {
|
|
3670
|
+
if (!metadata || !(PROVIDER_MUTATION_STARTED_AT_METADATA_KEY in metadata)) {
|
|
3671
|
+
return metadata;
|
|
3672
|
+
}
|
|
3673
|
+
const { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: _closed, ...rest } = metadata;
|
|
3674
|
+
return rest;
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* Handles an event inside a committed mutation's window: links its missing
|
|
3678
|
+
* references and reconciles the subscription with an inline provider read
|
|
3679
|
+
* (`reconcileProviderState`). When the provider cannot be read, the committed
|
|
3680
|
+
* mutation's state stands. The event is never applied over the mutation.
|
|
3681
|
+
*/
|
|
3682
|
+
async function reconcileMutationWindowEvent(deps, subscription, input) {
|
|
3683
|
+
const linked = await linkSkippedEventReferences(deps, subscription, input.state);
|
|
3684
|
+
const readable = Boolean(deps.reconcileProviderState) && typeof deps.providers[linked.provider]?.fetchSubscription === 'function';
|
|
3685
|
+
deps.metrics.track(readable ? 'subscription.mutation.window_event' : 'subscription.mutation.window_event_unreconciled', {
|
|
3686
|
+
provider: linked.provider,
|
|
3687
|
+
subscriptionId: linked.id
|
|
3688
|
+
});
|
|
3689
|
+
if (readable) {
|
|
3690
|
+
await deps.reconcileProviderState?.(linked, {
|
|
3691
|
+
providerEventId: input.providerEventId,
|
|
3692
|
+
selection: { plan: input.plan, price: input.price }
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
/**
|
|
3697
|
+
* Closes the committed mutation window a reconcile read covered, once its
|
|
3698
|
+
* effects are recorded; a newer mutation's window is left open.
|
|
3699
|
+
*/
|
|
3700
|
+
async function closeMutationWindow(deps, subscriptionId, startedAt) {
|
|
3701
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
3702
|
+
const current = await getSubscriptionById(deps, subscriptionId);
|
|
3703
|
+
const metadata = asRecord(current?.metadata);
|
|
3704
|
+
if (!current || !metadata || !startedAt || readMutationStartedAt(current) !== startedAt) {
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3707
|
+
const closed = {
|
|
3708
|
+
...current,
|
|
3709
|
+
metadata: withoutMutationWindow(metadata),
|
|
3710
|
+
updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
|
|
3711
|
+
};
|
|
3712
|
+
if (await writeOrderedSubscription(deps, current, closed)) {
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
throw createBillFnError({
|
|
3717
|
+
code: 'BILLFN_CONFLICT',
|
|
3718
|
+
message: 'Unable to close the mutation window after multiple retries'
|
|
3719
|
+
});
|
|
3720
|
+
}
|
|
2434
3721
|
function nextSubscriptionUpdatedAt(now, previous) {
|
|
2435
3722
|
const currentTime = now.getTime();
|
|
2436
3723
|
const previousTime = previous ? Date.parse(previous) : Number.NaN;
|