@billfn/core 0.1.0 → 0.3.0

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