@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/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
- async function syncSubscriptionById(subscriptionId) {
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 plan = requirePlan(deps.catalog, subscription.planKey);
164
- const price = requirePriceById(plan, subscription.priceId);
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 applyVerificationState(deps, {
178
- billingAccount,
179
- plan,
180
- price,
181
- subscription,
182
- state: verification
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 applyVerificationState(deps, {
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 subject = requireSubject(input.subject);
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 projection = operation.billingState
476
- ? await applyVerificationState(deps, {
477
- billingAccount,
478
- plan,
479
- price,
480
- subscription,
481
- state: operation.billingState
482
- })
483
- : {
484
- subscription,
485
- entitlements: (await findEntitlementSnapshot(deps, billingAccount.id)) ?? await upsertEntitlements(deps, billingAccount.id, plan, subscription, toIsoString(deps.now()))
486
- };
487
- await recordBillingEvent(deps, {
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 subject = requireSubject(input.subject);
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 projection = operation.billingState
560
- ? await applyVerificationState(deps, {
561
- billingAccount,
562
- plan: appliesTargetImmediately ? target.plan : currentPlan,
563
- price: appliesTargetImmediately ? target.price : currentPrice,
564
- subscription,
565
- state: operation.billingState
566
- })
567
- : {
568
- subscription,
569
- entitlements: (await findEntitlementSnapshot(deps, billingAccount.id)) ?? await upsertEntitlements(deps, billingAccount.id, currentPlan, subscription, timestamp)
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 subject = requireSubject(input.subject);
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 projection = operation.billingState
608
- ? await applyVerificationState(deps, {
609
- billingAccount,
610
- plan,
611
- price,
612
- subscription,
613
- state: operation.billingState
614
- })
615
- : {
616
- subscription,
617
- entitlements: (await findEntitlementSnapshot(deps, billingAccount.id)) ?? await upsertEntitlements(deps, billingAccount.id, plan, subscription, toIsoString(deps.now()))
618
- };
619
- await recordBillingEvent(deps, {
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 applyVerificationState(deps, {
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 applyVerificationState(deps, {
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 applyVerificationState(deps, {
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 (isReconciliationLeaseConflict(error) &&
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
- await syncSubscriptionByIdForJob(deps, job.subscriptionId, assertLease);
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 plan = requirePlan(deps.catalog, subscription.planKey);
1346
- const price = requirePriceById(plan, subscription.priceId);
1347
- const provider = requireProvider(deps, subscription.provider, 'fetchSubscription');
1348
- const verification = await provider.fetchSubscription({
1349
- subscription,
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 applyVerificationState(deps, {
1358
- billingAccount,
1359
- plan,
1360
- price,
1361
- subscription,
1362
- state: verification
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
- if (baseSubscription &&
1880
- input.occurredAt &&
1881
- isProviderEventOlderThanSubscription(baseSubscription, input.occurredAt)) {
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
- const mergedMetadata = mergeJson(baseSubscription?.metadata, input.state.raw);
1886
- const metadata = input.occurredAt
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]: input.occurredAt
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
- if (input.occurredAt) {
1915
- const updated = await deps.db.updateMany({
1916
- model: TABLES.subscriptions,
1917
- where: [
1918
- { field: 'id', operator: 'eq', value: baseSubscription.id },
1919
- { field: 'updatedAt', operator: 'eq', value: baseSubscription.updatedAt }
1920
- ],
1921
- data: record,
1922
- namespace: deps.namespace
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: [{ field: 'id', operator: 'eq', value: baseSubscription.id }],
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
- break;
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
- id: deps.generateId('evt'),
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
- async function upsertEntitlements(deps, billingAccountId, plan, subscription, timestamp) {
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 existing = await findEntitlementSnapshot(deps, billingAccountId);
2007
- if (existing && isEntitlementFromNewerSubscription(existing, subscription)) {
2008
- return existing;
2239
+ const data = await linkableMissingReferences(deps, current, state);
2240
+ if (Object.keys(data).length === 0) {
2241
+ return current;
2009
2242
  }
2010
- const snapshot = {
2011
- id: existing?.id ?? `ent_${billingAccountId}`,
2012
- billingAccountId,
2013
- planKey: plan.planKey,
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 (existing) {
2024
- const updated = await deps.db.updateMany({
2025
- model: TABLES.entitlementSnapshots,
2026
- where: [
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
- if (updated === 1) {
2036
- return snapshot;
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
- await deps.db.create({
2062
- model: TABLES.billingEvents,
2063
- data: event,
2064
- namespace: deps.namespace
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
- if (!event.billingState) {
2574
+ const state = event.billingState;
2575
+ if (!state) {
2254
2576
  return null;
2255
2577
  }
2256
- const projection = await resolveProjectionContextForProviderState(deps, {
2578
+ let projection = await resolveProjectionContextForProviderState(deps, {
2257
2579
  provider: providerName,
2258
- state: event.billingState,
2580
+ state,
2259
2581
  checkoutReferenceId: event.checkoutReferenceId
2260
2582
  });
2261
2583
  if (!projection) {
2262
- throw createBillFnError({
2263
- code: 'BILLFN_NOT_FOUND',
2264
- message: `Webhook event could not be linked to a billing account: ${event.providerEventId}`
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
- isProviderEventOlderThanSubscription(projection.subscription, event.occurredAt)) {
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
- raw ? readString(raw, 'product_id') : null,
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
- for (const reference of references) {
2309
- if (!reference) {
2310
- continue;
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
- function findCatalogPriceReference(catalog, provider, reference) {
2335
- for (const plan of catalog.plans) {
2336
- const price = plan.prices.find((candidate) => candidate.provider === provider &&
2337
- (candidate.priceId === reference || candidate.providerProductId === reference));
2338
- if (price) {
2339
- return { plan, price };
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 metadata = asRecord(subscription.metadata);
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;