@codelitdev/billing 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
Files changed (148) hide show
  1. package/README.md +15 -15
  2. package/dist/adapters/drizzle/index.d.ts +2 -2
  3. package/dist/adapters/drizzle/index.d.ts.map +1 -1
  4. package/dist/adapters/drizzle/index.js.map +1 -1
  5. package/dist/adapters/drizzle/store.d.ts.map +1 -1
  6. package/dist/adapters/drizzle/store.js +39 -48
  7. package/dist/adapters/drizzle/store.js.map +1 -1
  8. package/dist/catalog/index.d.ts +3 -3
  9. package/dist/catalog/index.d.ts.map +1 -1
  10. package/dist/catalog/index.js +2 -2
  11. package/dist/catalog/index.js.map +1 -1
  12. package/dist/catalog/public-view.d.ts.map +1 -1
  13. package/dist/catalog/public-view.js.map +1 -1
  14. package/dist/catalog/revisions.d.ts.map +1 -1
  15. package/dist/catalog/revisions.js.map +1 -1
  16. package/dist/catalog/types.d.ts.map +1 -1
  17. package/dist/catalog/validate.d.ts.map +1 -1
  18. package/dist/catalog/validate.js +2 -3
  19. package/dist/catalog/validate.js.map +1 -1
  20. package/dist/cli/bin.js.map +1 -1
  21. package/dist/cli/generate.d.ts.map +1 -1
  22. package/dist/cli/generate.js +2 -4
  23. package/dist/cli/generate.js.map +1 -1
  24. package/dist/config/define.d.ts.map +1 -1
  25. package/dist/config/define.js.map +1 -1
  26. package/dist/config/index.d.ts +1 -1
  27. package/dist/config/index.d.ts.map +1 -1
  28. package/dist/config/index.js +1 -1
  29. package/dist/config/index.js.map +1 -1
  30. package/dist/config/validate.d.ts.map +1 -1
  31. package/dist/config/validate.js +6 -13
  32. package/dist/config/validate.js.map +1 -1
  33. package/dist/core/checkout-attempt.d.ts.map +1 -1
  34. package/dist/core/checkout-attempt.js +4 -1
  35. package/dist/core/checkout-attempt.js.map +1 -1
  36. package/dist/core/clock.d.ts.map +1 -1
  37. package/dist/core/clock.js.map +1 -1
  38. package/dist/core/customer.d.ts.map +1 -1
  39. package/dist/core/errors.d.ts.map +1 -1
  40. package/dist/core/errors.js.map +1 -1
  41. package/dist/core/ids.d.ts.map +1 -1
  42. package/dist/core/ids.js +1 -3
  43. package/dist/core/ids.js.map +1 -1
  44. package/dist/core/index.d.ts +12 -12
  45. package/dist/core/index.d.ts.map +1 -1
  46. package/dist/core/index.js +10 -10
  47. package/dist/core/index.js.map +1 -1
  48. package/dist/core/money.d.ts.map +1 -1
  49. package/dist/core/money.js +1 -2
  50. package/dist/core/money.js.map +1 -1
  51. package/dist/core/plan-change-attempt.d.ts.map +1 -1
  52. package/dist/core/plan-change-attempt.js +4 -1
  53. package/dist/core/plan-change-attempt.js.map +1 -1
  54. package/dist/core/projection-diff.d.ts.map +1 -1
  55. package/dist/core/projection-diff.js.map +1 -1
  56. package/dist/core/reconciliation-job.d.ts.map +1 -1
  57. package/dist/core/reconciliation-job.js.map +1 -1
  58. package/dist/core/subscription.d.ts.map +1 -1
  59. package/dist/core/subscription.js +1 -2
  60. package/dist/core/subscription.js.map +1 -1
  61. package/dist/core/transitions.d.ts +1 -1
  62. package/dist/core/transitions.d.ts.map +1 -1
  63. package/dist/core/transitions.js +4 -23
  64. package/dist/core/transitions.js.map +1 -1
  65. package/dist/core/webhook-inbox.d.ts.map +1 -1
  66. package/dist/core/webhook-inbox.js.map +1 -1
  67. package/dist/maintenance/health.d.ts +1 -1
  68. package/dist/maintenance/health.d.ts.map +1 -1
  69. package/dist/maintenance/health.js.map +1 -1
  70. package/dist/maintenance/retry.d.ts.map +1 -1
  71. package/dist/maintenance/retry.js.map +1 -1
  72. package/dist/operations/index.d.ts +1 -1
  73. package/dist/operations/index.d.ts.map +1 -1
  74. package/dist/operations/index.js +1 -1
  75. package/dist/operations/index.js.map +1 -1
  76. package/dist/persistence/memory.d.ts +2 -0
  77. package/dist/persistence/memory.d.ts.map +1 -1
  78. package/dist/persistence/memory.js +41 -39
  79. package/dist/persistence/memory.js.map +1 -1
  80. package/dist/persistence/store.d.ts +2 -0
  81. package/dist/persistence/store.d.ts.map +1 -1
  82. package/dist/ports/audit.d.ts.map +1 -1
  83. package/dist/ports/audit.js.map +1 -1
  84. package/dist/ports/authorization.d.ts.map +1 -1
  85. package/dist/ports/authorization.js.map +1 -1
  86. package/dist/ports/lifecycle.d.ts.map +1 -1
  87. package/dist/ports/sensitive-values.d.ts.map +1 -1
  88. package/dist/ports/sensitive-values.js.map +1 -1
  89. package/dist/ports/telemetry.d.ts.map +1 -1
  90. package/dist/ports/telemetry.js.map +1 -1
  91. package/dist/providers/contract.d.ts +7 -0
  92. package/dist/providers/contract.d.ts.map +1 -1
  93. package/dist/providers/dodo/index.d.ts +3 -2
  94. package/dist/providers/dodo/index.d.ts.map +1 -1
  95. package/dist/providers/dodo/index.js +31 -18
  96. package/dist/providers/dodo/index.js.map +1 -1
  97. package/dist/providers/dodo/normalize.d.ts.map +1 -1
  98. package/dist/providers/dodo/normalize.js +2 -5
  99. package/dist/providers/dodo/normalize.js.map +1 -1
  100. package/dist/providers/fake/index.d.ts +3 -1
  101. package/dist/providers/fake/index.d.ts.map +1 -1
  102. package/dist/providers/fake/index.js +47 -6
  103. package/dist/providers/fake/index.js.map +1 -1
  104. package/dist/providers/index.d.ts +2 -2
  105. package/dist/providers/index.d.ts.map +1 -1
  106. package/dist/providers/index.js +1 -1
  107. package/dist/providers/index.js.map +1 -1
  108. package/dist/providers/registry.d.ts.map +1 -1
  109. package/dist/providers/registry.js.map +1 -1
  110. package/dist/schema/canonical.d.ts.map +1 -1
  111. package/dist/schema/canonical.js.map +1 -1
  112. package/dist/schema/generator.d.ts.map +1 -1
  113. package/dist/schema/generator.js +1 -4
  114. package/dist/schema/generator.js.map +1 -1
  115. package/dist/testing/clocks.d.ts +1 -1
  116. package/dist/testing/clocks.d.ts.map +1 -1
  117. package/dist/testing/clocks.js +1 -1
  118. package/dist/testing/clocks.js.map +1 -1
  119. package/dist/testing/consumer-conformance.js.map +1 -1
  120. package/dist/testing/fixtures.d.ts +1 -1
  121. package/dist/testing/fixtures.d.ts.map +1 -1
  122. package/dist/testing/fixtures.js.map +1 -1
  123. package/dist/testing/index.d.ts +5 -5
  124. package/dist/testing/index.d.ts.map +1 -1
  125. package/dist/testing/index.js +5 -5
  126. package/dist/testing/index.js.map +1 -1
  127. package/dist/testing/provider-contract.d.ts.map +1 -1
  128. package/dist/testing/provider-contract.js +15 -19
  129. package/dist/testing/provider-contract.js.map +1 -1
  130. package/dist/testing/state-machine.d.ts.map +1 -1
  131. package/dist/testing/state-machine.js.map +1 -1
  132. package/dist/testing/workflow-harness.d.ts +2 -2
  133. package/dist/testing/workflow-harness.d.ts.map +1 -1
  134. package/dist/testing/workflow-harness.js +1 -1
  135. package/dist/testing/workflow-harness.js.map +1 -1
  136. package/dist/workflows/engine.d.ts +6 -7
  137. package/dist/workflows/engine.d.ts.map +1 -1
  138. package/dist/workflows/engine.js +110 -77
  139. package/dist/workflows/engine.js.map +1 -1
  140. package/dist/workflows/index.d.ts +6 -6
  141. package/dist/workflows/index.d.ts.map +1 -1
  142. package/dist/workflows/index.js +2 -2
  143. package/dist/workflows/index.js.map +1 -1
  144. package/dist/workflows/lifecycle.d.ts.map +1 -1
  145. package/dist/workflows/lifecycle.js.map +1 -1
  146. package/docs/architecture.md +5 -5
  147. package/docs/runbooks.md +2 -2
  148. package/package.json +28 -27
@@ -1,13 +1,13 @@
1
- import { checkoutIsAvailable, toPublicCatalog, } from "../catalog/public-view.js";
2
- import { catalogMatchesProviderSnapshot, validateCatalog, } from "../catalog/validate.js";
1
+ import { checkoutIsAvailable, toPublicCatalog } from "../catalog/public-view.js";
3
2
  import { canActivateRevision } from "../catalog/revisions.js";
4
- import { BillingProviderError, BillingCompositionError, BillingWorkflowError, providerErrorSummary, } from "../core/errors.js";
3
+ import { catalogMatchesProviderSnapshot, validateCatalog, } from "../catalog/validate.js";
4
+ import { BillingCompositionError, BillingProviderError, BillingWorkflowError, providerErrorSummary, } from "../core/errors.js";
5
5
  import { retainsPaidEntitlement } from "../core/subscription.js";
6
6
  import { decideCheckoutTransition, decideCustomerTransition, decidePlanChangeTransition, decideSubscriptionTransition, decideWebhookInboxTransition, } from "../core/transitions.js";
7
- import { noopTelemetry, safeTelemetry, } from "../ports/telemetry.js";
7
+ import { billingWebhookRetry, DEFAULT_WEBHOOK_MAX_ATTEMPTS, } from "../maintenance/retry.js";
8
8
  import { applySubscriptionProjection, MemoryBillingStore, newId, } from "../persistence/memory.js";
9
+ import { noopTelemetry, safeTelemetry, } from "../ports/telemetry.js";
9
10
  import { BillingProviderRegistry } from "../providers/registry.js";
10
- import { billingWebhookRetry, DEFAULT_WEBHOOK_MAX_ATTEMPTS, } from "../maintenance/retry.js";
11
11
  const LEASE_MS = 5 * 60 * 1000;
12
12
  export function createBilling(options) {
13
13
  if (options.mode === "cloud" && !options.hooks?.audit) {
@@ -16,8 +16,7 @@ export function createBilling(options) {
16
16
  if (options.webhookRetrieveCurrent === false) {
17
17
  throw new BillingCompositionError("webhook_current_retrieval_required");
18
18
  }
19
- if (options.mode === "cloud" &&
20
- typeof options.returnUrlValidator !== "function") {
19
+ if (options.mode === "cloud" && typeof options.returnUrlValidator !== "function") {
21
20
  throw new BillingCompositionError("return_url_validator_required");
22
21
  }
23
22
  const store = options.database ?? new MemoryBillingStore();
@@ -41,10 +40,8 @@ export function createBilling(options) {
41
40
  !Array.isArray(options.requiredOfferKeys) ||
42
41
  options.requiredOfferKeys.length === 0 ||
43
42
  !Array.isArray(options.offers) ||
44
- new Set(options.requiredOfferKeys).size !==
45
- options.requiredOfferKeys.length ||
46
- options.requiredOfferKeys.some((key) => !isBoundedOpaqueId(key) ||
47
- !/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(key)) ||
43
+ new Set(options.requiredOfferKeys).size !== options.requiredOfferKeys.length ||
44
+ options.requiredOfferKeys.some((key) => !isBoundedOpaqueId(key) || !/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(key)) ||
48
45
  !registry.has(options.checkoutProvider))) {
49
46
  throw new BillingCompositionError("cloud_billing_configuration_incomplete");
50
47
  }
@@ -93,9 +90,7 @@ export function createBilling(options) {
93
90
  providerProductId: item.price.providerProductId,
94
91
  }));
95
92
  return toPublicCatalog(active.revision.revision, offers, checkoutIsAvailable({
96
- requestedRevision: providerName === options.checkoutProvider
97
- ? options.requestedRevision
98
- : null,
93
+ requestedRevision: providerName === options.checkoutProvider ? options.requestedRevision : null,
99
94
  activeRevision: active.revision.revision,
100
95
  }));
101
96
  }
@@ -412,8 +407,7 @@ export function createBilling(options) {
412
407
  }
413
408
  const live = await store.findLiveJob(subject);
414
409
  if (live) {
415
- if (subject.operation === "cancellation" &&
416
- live.operation !== "cancellation") {
410
+ if (subject.operation === "cancellation" && live.operation !== "cancellation") {
417
411
  live.operation = "cancellation";
418
412
  await store.saveJob(live);
419
413
  }
@@ -479,14 +473,39 @@ export function createBilling(options) {
479
473
  if (existingSub && retainsPaidEntitlement(existingSub, clock.now())) {
480
474
  throw new BillingWorkflowError("active_subscription_exists");
481
475
  }
476
+ const adapter = provider(options.checkoutProvider);
477
+ const checkoutIdempotencyKey = `checkout:${input.entity.id}:${input.offerKey}:${catalog.revision.revision}`;
478
+ const resumable = await store.findResumableCheckout(input.entity.id);
479
+ if (resumable?.providerCheckoutSessionId && resumable.payerId === input.payer.id) {
480
+ try {
481
+ const session = await callProvider(() => adapter.retrieveCheckoutSession(resumable.providerCheckoutSessionId));
482
+ if (session.subscriptionId) {
483
+ const snapshot = await callProvider(() => adapter.retrieveSubscription(session.subscriptionId));
484
+ if (retainsPaidEntitlement(snapshot, clock.now())) {
485
+ await projectSnapshot(snapshot, {
486
+ checkoutAttemptId: resumable.attemptId,
487
+ });
488
+ const latest = (await store.findCheckoutById(resumable.id)) ?? resumable;
489
+ return {
490
+ attempt: latest,
491
+ checkoutUrl: latest.returnUrl,
492
+ };
493
+ }
494
+ }
495
+ }
496
+ catch (error) {
497
+ if (error instanceof BillingWorkflowError)
498
+ throw error;
499
+ }
500
+ }
482
501
  const liveCheckout = await store.findLiveCheckout(input.entity.id);
483
502
  let resumeAttempt;
484
- if (liveCheckout &&
485
- liveCheckout.expiresAt.getTime() <= clock.now().getTime()) {
503
+ if (liveCheckout && liveCheckout.expiresAt.getTime() <= clock.now().getTime()) {
486
504
  liveCheckout.status = "expired";
487
505
  liveCheckout.completedAt = clock.now();
488
506
  liveCheckout.checkoutUrlEncrypted = null;
489
507
  await store.saveCheckout(liveCheckout);
508
+ resumeAttempt = liveCheckout;
490
509
  }
491
510
  else if (liveCheckout) {
492
511
  if (liveCheckout.payerId !== input.payer.id ||
@@ -505,11 +524,37 @@ export function createBilling(options) {
505
524
  }
506
525
  resumeAttempt = liveCheckout;
507
526
  }
508
- const adapter = provider(options.checkoutProvider);
509
- if (adapter.capabilities.mutationRecovery.createCustomer ===
510
- "unsupported" ||
511
- adapter.capabilities.mutationRecovery.createCheckout ===
512
- "unsupported") {
527
+ if (!resumeAttempt) {
528
+ const existing = await store.findCheckoutByIdempotencyKey(checkoutIdempotencyKey);
529
+ if (existing &&
530
+ existing.payerId === input.payer.id &&
531
+ existing.offerKey === input.offerKey &&
532
+ existing.catalogRevision === catalog.revision.revision) {
533
+ if (existing.status === "open" &&
534
+ existing.checkoutUrlEncrypted &&
535
+ existing.expiresAt.getTime() > clock.now().getTime() &&
536
+ options.sensitiveValues) {
537
+ const checkoutUrl = await options.sensitiveValues.decrypt(existing.checkoutUrlEncrypted, {
538
+ operatorActorId: input.payer.id,
539
+ reason: "resume_checkout",
540
+ });
541
+ return { attempt: existing, checkoutUrl };
542
+ }
543
+ if (existing.status === "completed" &&
544
+ existing.checkoutUrlEncrypted &&
545
+ existing.expiresAt.getTime() > clock.now().getTime() &&
546
+ options.sensitiveValues) {
547
+ const checkoutUrl = await options.sensitiveValues.decrypt(existing.checkoutUrlEncrypted, {
548
+ operatorActorId: input.payer.id,
549
+ reason: "resume_checkout",
550
+ });
551
+ return { attempt: existing, checkoutUrl };
552
+ }
553
+ resumeAttempt = existing;
554
+ }
555
+ }
556
+ if (adapter.capabilities.mutationRecovery.createCustomer === "unsupported" ||
557
+ adapter.capabilities.mutationRecovery.createCheckout === "unsupported") {
513
558
  throw new BillingWorkflowError("unsupported_operation");
514
559
  }
515
560
  let providerProduct;
@@ -517,8 +562,7 @@ export function createBilling(options) {
517
562
  providerProduct = await callProvider(() => adapter.retrieveProduct(item.price.providerProductId));
518
563
  }
519
564
  catch (error) {
520
- if (error instanceof BillingProviderError &&
521
- error.code === "invalid") {
565
+ if (error instanceof BillingProviderError && error.code === "invalid") {
522
566
  throw new BillingWorkflowError("catalog_changed", {
523
567
  details: { publicCatalog: pub },
524
568
  });
@@ -532,8 +576,7 @@ export function createBilling(options) {
532
576
  details: { publicCatalog: pub },
533
577
  });
534
578
  }
535
- if (liveCheckout?.status === "open" &&
536
- !liveCheckout.checkoutUrlEncrypted) {
579
+ if (liveCheckout?.status === "open" && !liveCheckout.checkoutUrlEncrypted) {
537
580
  let customer;
538
581
  try {
539
582
  customer = await resolveCustomer({
@@ -563,8 +606,7 @@ export function createBilling(options) {
563
606
  if (options.sensitiveValues) {
564
607
  const encrypted = await options.sensitiveValues.encrypt(remote.checkoutUrl);
565
608
  liveCheckout.checkoutUrlEncrypted = encrypted.ciphertext;
566
- liveCheckout.providerCheckoutSessionId =
567
- remote.providerCheckoutSessionId;
609
+ liveCheckout.providerCheckoutSessionId = remote.providerCheckoutSessionId;
568
610
  liveCheckout.providerCustomerRowId = customer.id;
569
611
  await store.saveCheckout(liveCheckout);
570
612
  }
@@ -610,7 +652,7 @@ export function createBilling(options) {
610
652
  providerCustomerRowId: null,
611
653
  providerCheckoutSessionId: null,
612
654
  checkoutUrlEncrypted: null,
613
- idempotencyKey: `checkout:${input.entity.id}:${input.offerKey}:${catalog.revision.revision}`,
655
+ idempotencyKey: checkoutIdempotencyKey,
614
656
  status: "creating",
615
657
  expiresAt: new Date(clock.now().getTime() + 60 * 60 * 1000),
616
658
  lastError: null,
@@ -623,7 +665,8 @@ export function createBilling(options) {
623
665
  });
624
666
  }
625
667
  catch (error) {
626
- const raced = await store.findLiveCheckout(input.entity.id);
668
+ const raced = (await store.findLiveCheckout(input.entity.id)) ??
669
+ (await store.findCheckoutByIdempotencyKey(checkoutIdempotencyKey));
627
670
  if (!raced ||
628
671
  raced.payerId !== input.payer.id ||
629
672
  raced.offerKey !== input.offerKey ||
@@ -633,6 +676,18 @@ export function createBilling(options) {
633
676
  attempt = raced;
634
677
  }
635
678
  }
679
+ const reopenExpiredOrUnpaidCompleted = attempt.status === "expired" || attempt.status === "completed";
680
+ if (reopenExpiredOrUnpaidCompleted) {
681
+ attempt.status = "creating";
682
+ attempt.completedAt = null;
683
+ attempt.checkoutUrlEncrypted = null;
684
+ attempt.lastError = null;
685
+ attempt.expiresAt = new Date(clock.now().getTime() + 60 * 60 * 1000);
686
+ await store.saveCheckout(attempt);
687
+ }
688
+ const providerCheckoutKey = reopenExpiredOrUnpaidCompleted
689
+ ? `${attempt.idempotencyKey}:reopen:${attempt.id}`
690
+ : attempt.idempotencyKey;
636
691
  let customer;
637
692
  try {
638
693
  customer = await resolveCustomer({
@@ -657,7 +712,7 @@ export function createBilling(options) {
657
712
  attemptId: attempt.attemptId,
658
713
  catalogKey: item.offerKey,
659
714
  trialDays: input.trialDays ?? 0,
660
- idempotencyKey: attempt.idempotencyKey,
715
+ idempotencyKey: providerCheckoutKey,
661
716
  }));
662
717
  const encrypted = options.sensitiveValues
663
718
  ? await options.sensitiveValues.encrypt(remote.checkoutUrl)
@@ -676,8 +731,7 @@ export function createBilling(options) {
676
731
  }
677
732
  latest.status = "open";
678
733
  latest.providerCustomerRowId = customer.id;
679
- latest.providerCheckoutSessionId =
680
- remote.providerCheckoutSessionId;
734
+ latest.providerCheckoutSessionId = remote.providerCheckoutSessionId;
681
735
  latest.checkoutUrlEncrypted = encrypted?.ciphertext ?? null;
682
736
  await store.saveCheckout(latest);
683
737
  await requireAudit({
@@ -799,8 +853,7 @@ export function createBilling(options) {
799
853
  providerProduct = await callProvider(() => adapter.retrieveProduct(target.price.providerProductId));
800
854
  }
801
855
  catch (error) {
802
- if (error instanceof BillingProviderError &&
803
- error.code === "invalid") {
856
+ if (error instanceof BillingProviderError && error.code === "invalid") {
804
857
  throw new BillingWorkflowError("catalog_changed", {
805
858
  details: {
806
859
  publicCatalog: await publicCatalogFor(sub.provider),
@@ -823,8 +876,7 @@ export function createBilling(options) {
823
876
  if (livePlanChange.subscriptionId === sub.id &&
824
877
  livePlanChange.payerId === input.payer.id &&
825
878
  livePlanChange.targetOfferKey === input.offerKey &&
826
- livePlanChange.targetCatalogRevision ===
827
- catalog.revision.revision &&
879
+ livePlanChange.targetCatalogRevision === catalog.revision.revision &&
828
880
  livePlanChange.effectiveAt === input.effectiveAt &&
829
881
  livePlanChange.prorationMode === input.prorationMode) {
830
882
  return livePlanChange;
@@ -923,8 +975,7 @@ export function createBilling(options) {
923
975
  }, input.payer.id);
924
976
  const source = await store.findEntitlementSubscription(input.entity.id);
925
977
  const sub = source ??
926
- (await store.listSubscriptionsByEntity(input.entity.id)).find((row) => row.payerId === input.payer.id &&
927
- row.status === "cancelled");
978
+ (await store.listSubscriptionsByEntity(input.entity.id)).find((row) => row.payerId === input.payer.id && row.status === "cancelled");
928
979
  if (!sub)
929
980
  throw new BillingWorkflowError("subscription_required");
930
981
  if (sub.payerId !== input.payer.id) {
@@ -933,8 +984,8 @@ export function createBilling(options) {
933
984
  if (sub.status === "cancelled") {
934
985
  return { accepted: true, subscriptionId: sub.id };
935
986
  }
936
- if (provider(sub.provider).capabilities.mutationRecovery
937
- .cancellation === "unsupported") {
987
+ if (provider(sub.provider).capabilities.mutationRecovery.cancellation ===
988
+ "unsupported") {
938
989
  throw new BillingWorkflowError("unsupported_operation");
939
990
  }
940
991
  await store.withTransaction(async () => {
@@ -1145,10 +1196,7 @@ export function createBilling(options) {
1145
1196
  providerProductId: snapshot.providerProductId,
1146
1197
  catalogRevision: lineageRevision,
1147
1198
  offerKey: lineageOffer,
1148
- plan: pendingChange?.targetPlan ??
1149
- existing?.plan ??
1150
- attempt?.plan ??
1151
- price.plan,
1199
+ plan: pendingChange?.targetPlan ?? existing?.plan ?? attempt?.plan ?? price.plan,
1152
1200
  interval: pendingChange?.targetInterval ??
1153
1201
  existing?.interval ??
1154
1202
  attempt?.interval ??
@@ -1165,9 +1213,7 @@ export function createBilling(options) {
1165
1213
  providerOccurredAt: snapshot.providerOccurredAt,
1166
1214
  providerVersion: snapshot.providerVersion,
1167
1215
  lastObservedAt: now,
1168
- lastReconciledAt: reconcile
1169
- ? now
1170
- : (existing?.lastReconciledAt ?? null),
1216
+ lastReconciledAt: reconcile ? now : (existing?.lastReconciledAt ?? null),
1171
1217
  };
1172
1218
  const write = await applySubscriptionProjection(store, next, now, {
1173
1219
  reconcile,
@@ -1207,7 +1253,7 @@ export function createBilling(options) {
1207
1253
  entityUnchanged: attempt.billableEntityId === billableEntityId,
1208
1254
  payerUnchanged: attempt.payerId === payerId,
1209
1255
  });
1210
- if (decision.allowed) {
1256
+ if (decision.allowed && paid) {
1211
1257
  attempt.status = "completed";
1212
1258
  attempt.completedAt = now;
1213
1259
  }
@@ -1234,8 +1280,7 @@ export function createBilling(options) {
1234
1280
  const now = clock.now();
1235
1281
  const alreadyClaimed = record.status === "processing" && record.workerId === workerId;
1236
1282
  if (!alreadyClaimed) {
1237
- if (!decideWebhookInboxTransition(record.status, "processing")
1238
- .allowed) {
1283
+ if (!decideWebhookInboxTransition(record.status, "processing").allowed) {
1239
1284
  return;
1240
1285
  }
1241
1286
  record.status = "processing";
@@ -1256,9 +1301,7 @@ export function createBilling(options) {
1256
1301
  const adapter = provider(record.provider);
1257
1302
  const snapshot = await callProvider(() => adapter.retrieveSubscription(record.subscriptionId));
1258
1303
  await projectSnapshot(snapshot, {
1259
- checkoutAttemptId: record.checkoutAttemptId ??
1260
- snapshot.metadata.checkoutAttemptId ??
1261
- undefined,
1304
+ checkoutAttemptId: record.checkoutAttemptId ?? snapshot.metadata.checkoutAttemptId ?? undefined,
1262
1305
  });
1263
1306
  record.status = "processed";
1264
1307
  record.processedAt = clock.now();
@@ -1503,10 +1546,8 @@ export function createBilling(options) {
1503
1546
  if (attempt.provider !== job.provider) {
1504
1547
  throw new BillingWorkflowError("operation_quarantined");
1505
1548
  }
1506
- if (adapter.capabilities.mutationRecovery.createCustomer ===
1507
- "unsupported" ||
1508
- adapter.capabilities.mutationRecovery.createCheckout ===
1509
- "unsupported") {
1549
+ if (adapter.capabilities.mutationRecovery.createCustomer === "unsupported" ||
1550
+ adapter.capabilities.mutationRecovery.createCheckout === "unsupported") {
1510
1551
  throw new BillingWorkflowError("operation_quarantined");
1511
1552
  }
1512
1553
  if (attempt.providerCheckoutSessionId) {
@@ -1541,8 +1582,7 @@ export function createBilling(options) {
1541
1582
  idempotencyKey: customer.idempotencyKey,
1542
1583
  }));
1543
1584
  await store.withTransaction(async () => {
1544
- const latest = (await store.findCustomerById(customer.id)) ??
1545
- customer;
1585
+ const latest = (await store.findCustomerById(customer.id)) ?? customer;
1546
1586
  const decision = decideCustomerTransition(latest.status, "active");
1547
1587
  if (!decision.allowed) {
1548
1588
  throw new BillingWorkflowError("operation_quarantined");
@@ -1593,8 +1633,7 @@ export function createBilling(options) {
1593
1633
  return;
1594
1634
  }
1595
1635
  const wasCreating = latest.status === "creating";
1596
- latest.providerCheckoutSessionId =
1597
- remote.providerCheckoutSessionId;
1636
+ latest.providerCheckoutSessionId = remote.providerCheckoutSessionId;
1598
1637
  if (wasCreating)
1599
1638
  latest.status = "open";
1600
1639
  await store.saveCheckout(latest);
@@ -1623,8 +1662,7 @@ export function createBilling(options) {
1623
1662
  throw new BillingWorkflowError("operation_quarantined");
1624
1663
  }
1625
1664
  if (job.operation === "cancellation") {
1626
- if (adapter.capabilities.mutationRecovery.cancellation ===
1627
- "unsupported") {
1665
+ if (adapter.capabilities.mutationRecovery.cancellation === "unsupported") {
1628
1666
  throw new BillingWorkflowError("operation_quarantined");
1629
1667
  }
1630
1668
  await callProvider(() => adapter.cancelSubscription(sub.providerSubscriptionId, `cancel:${sub.id}`));
@@ -1647,8 +1685,7 @@ export function createBilling(options) {
1647
1685
  (customer.status === "active" && !customer.providerCustomerId)) {
1648
1686
  throw new BillingWorkflowError("operation_quarantined");
1649
1687
  }
1650
- if (adapter.capabilities.mutationRecovery.createCustomer ===
1651
- "unsupported") {
1688
+ if (adapter.capabilities.mutationRecovery.createCustomer === "unsupported") {
1652
1689
  throw new BillingWorkflowError("operation_quarantined");
1653
1690
  }
1654
1691
  const remote = await callProvider(() => adapter.createCustomer({
@@ -1701,8 +1738,7 @@ export function createBilling(options) {
1701
1738
  if (target.provider !== attempt.provider) {
1702
1739
  throw new BillingWorkflowError("operation_quarantined");
1703
1740
  }
1704
- if (adapter.capabilities.mutationRecovery.planChange ===
1705
- "unsupported") {
1741
+ if (adapter.capabilities.mutationRecovery.planChange === "unsupported") {
1706
1742
  throw new BillingWorkflowError("operation_quarantined");
1707
1743
  }
1708
1744
  const remote = await callProvider(() => adapter.changeSubscriptionPlan({
@@ -1827,8 +1863,8 @@ export function createBilling(options) {
1827
1863
  throw new BillingWorkflowError("subscription_required");
1828
1864
  if (sub.status === "cancelled" || sub.status === "expired")
1829
1865
  return;
1830
- if (provider(sub.provider).capabilities.mutationRecovery
1831
- .cancellation === "unsupported") {
1866
+ if (provider(sub.provider).capabilities.mutationRecovery.cancellation ===
1867
+ "unsupported") {
1832
1868
  throw new BillingWorkflowError("unsupported_operation");
1833
1869
  }
1834
1870
  await store.withTransaction(async () => {
@@ -2001,8 +2037,7 @@ function workLimit(value) {
2001
2037
  return Math.min(value, 1_000);
2002
2038
  }
2003
2039
  function isOptionalDate(value) {
2004
- return (value === null ||
2005
- (value instanceof Date && !Number.isNaN(value.getTime())));
2040
+ return value === null || (value instanceof Date && !Number.isNaN(value.getTime()));
2006
2041
  }
2007
2042
  function isCanonicalSubscriptionStatus(value) {
2008
2043
  return (value === "pending" ||
@@ -2016,8 +2051,7 @@ function isOlderProviderSnapshot(existing, incoming) {
2016
2051
  if (existing.providerVersion && incoming.providerVersion) {
2017
2052
  const existingNumber = Number(existing.providerVersion);
2018
2053
  const incomingNumber = Number(incoming.providerVersion);
2019
- if (Number.isSafeInteger(existingNumber) &&
2020
- Number.isSafeInteger(incomingNumber)) {
2054
+ if (Number.isSafeInteger(existingNumber) && Number.isSafeInteger(incomingNumber)) {
2021
2055
  if (incomingNumber < existingNumber)
2022
2056
  return true;
2023
2057
  if (incomingNumber > existingNumber)
@@ -2025,8 +2059,7 @@ function isOlderProviderSnapshot(existing, incoming) {
2025
2059
  }
2026
2060
  }
2027
2061
  if (existing.providerOccurredAt && incoming.providerOccurredAt) {
2028
- return (incoming.providerOccurredAt.getTime() <
2029
- existing.providerOccurredAt.getTime());
2062
+ return (incoming.providerOccurredAt.getTime() < existing.providerOccurredAt.getTime());
2030
2063
  }
2031
2064
  return false;
2032
2065
  }