@codelitdev/billing 0.3.0 → 0.4.1

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 (53) hide show
  1. package/README.md +44 -2
  2. package/dist/adapters/drizzle/store.d.ts.map +1 -1
  3. package/dist/adapters/drizzle/store.js +25 -2
  4. package/dist/adapters/drizzle/store.js.map +1 -1
  5. package/dist/core/subscription.d.ts +2 -0
  6. package/dist/core/subscription.d.ts.map +1 -1
  7. package/dist/core/subscription.js +7 -0
  8. package/dist/core/subscription.js.map +1 -1
  9. package/dist/operations/index.d.ts +2 -0
  10. package/dist/operations/index.d.ts.map +1 -1
  11. package/dist/operations/index.js +4 -0
  12. package/dist/operations/index.js.map +1 -1
  13. package/dist/persistence/memory.d.ts +4 -0
  14. package/dist/persistence/memory.d.ts.map +1 -1
  15. package/dist/persistence/memory.js +29 -1
  16. package/dist/persistence/memory.js.map +1 -1
  17. package/dist/persistence/store.d.ts +11 -0
  18. package/dist/persistence/store.d.ts.map +1 -1
  19. package/dist/providers/contract.d.ts +45 -0
  20. package/dist/providers/contract.d.ts.map +1 -1
  21. package/dist/providers/dodo/index.d.ts +2 -0
  22. package/dist/providers/dodo/index.d.ts.map +1 -1
  23. package/dist/providers/dodo/index.js +29 -3
  24. package/dist/providers/dodo/index.js.map +1 -1
  25. package/dist/providers/fake/index.d.ts +14 -2
  26. package/dist/providers/fake/index.d.ts.map +1 -1
  27. package/dist/providers/fake/index.js +68 -3
  28. package/dist/providers/fake/index.js.map +1 -1
  29. package/dist/providers/index.d.ts +1 -1
  30. package/dist/providers/index.d.ts.map +1 -1
  31. package/dist/providers/index.js.map +1 -1
  32. package/dist/providers/lemonsqueezy/index.d.ts +42 -0
  33. package/dist/providers/lemonsqueezy/index.d.ts.map +1 -0
  34. package/dist/providers/lemonsqueezy/index.js +385 -0
  35. package/dist/providers/lemonsqueezy/index.js.map +1 -0
  36. package/dist/providers/lemonsqueezy/normalize.d.ts +12 -0
  37. package/dist/providers/lemonsqueezy/normalize.d.ts.map +1 -0
  38. package/dist/providers/lemonsqueezy/normalize.js +72 -0
  39. package/dist/providers/lemonsqueezy/normalize.js.map +1 -0
  40. package/dist/testing/provider-contract.d.ts.map +1 -1
  41. package/dist/testing/provider-contract.js +6 -1
  42. package/dist/testing/provider-contract.js.map +1 -1
  43. package/dist/testing/workflow-harness.d.ts +48 -2
  44. package/dist/testing/workflow-harness.d.ts.map +1 -1
  45. package/dist/workflows/engine.d.ts +42 -1
  46. package/dist/workflows/engine.d.ts.map +1 -1
  47. package/dist/workflows/engine.js +418 -55
  48. package/dist/workflows/engine.js.map +1 -1
  49. package/dist/workflows/index.d.ts +1 -0
  50. package/dist/workflows/index.d.ts.map +1 -1
  51. package/dist/workflows/index.js.map +1 -1
  52. package/docs/architecture.md +3 -1
  53. package/package.json +6 -1
@@ -64,6 +64,20 @@ export function createBilling(options) {
64
64
  store.noteProviderCall();
65
65
  return fn();
66
66
  }
67
+ /**
68
+ * Applies the provider's current subscription after a mutation, so read
69
+ * models reflect it before the webhook arrives. A failure is ignored: the
70
+ * webhook and reconciliation project the same state later.
71
+ */
72
+ async function readBackSubscription(sub) {
73
+ try {
74
+ const snapshot = await callProvider(() => provider(sub.provider).retrieveSubscription(sub.providerSubscriptionId));
75
+ await projectSnapshot(snapshot, {});
76
+ }
77
+ catch {
78
+ telemetry.event("readback.failed", { provider: sub.provider });
79
+ }
80
+ }
67
81
  async function publicCatalog() {
68
82
  if (options.mode === "oss")
69
83
  return null;
@@ -109,6 +123,10 @@ export function createBilling(options) {
109
123
  return existing.revision;
110
124
  }
111
125
  const revisions = await store.listRevisions();
126
+ // Revision numbers are unique across providers.
127
+ if (revisions.some((revision) => revision.revision === options.requestedRevision)) {
128
+ throw new BillingCompositionError("catalog_revision_used_by_another_provider");
129
+ }
112
130
  const newer = revisions.find((revision) => revision.revision > options.requestedRevision);
113
131
  if (newer) {
114
132
  throw new BillingWorkflowError("catalog_changed", {
@@ -438,6 +456,35 @@ export function createBilling(options) {
438
456
  }
439
457
  return store.withTransaction(() => enqueueJobInTransaction(subject));
440
458
  }
459
+ /**
460
+ * Closes the payer's open checkout when they choose another offer. A
461
+ * subscription that still arrives for it marks it `conflicted`.
462
+ */
463
+ async function abandonSupersededCheckout(id) {
464
+ const latest = await store.findCheckoutById(id);
465
+ if (!latest)
466
+ return;
467
+ const decision = decideCheckoutTransition(latest.status, "abandoned", {
468
+ now: clock.now(),
469
+ expiresAt: latest.expiresAt,
470
+ });
471
+ if (!decision.allowed) {
472
+ throw new BillingWorkflowError("operation_conflicted");
473
+ }
474
+ const previous = latest.status;
475
+ latest.status = "abandoned";
476
+ latest.completedAt = clock.now();
477
+ latest.checkoutUrlEncrypted = null;
478
+ await store.saveCheckout(latest);
479
+ await requireAudit({
480
+ effectId: `checkout:${latest.id}:abandoned`,
481
+ actor: { kind: "user", id: latest.payerId },
482
+ previous: { status: previous },
483
+ next: { status: "abandoned" },
484
+ correlationIds: { attemptId: latest.attemptId },
485
+ });
486
+ telemetry.event("checkout.superseded", { attemptId: latest.attemptId });
487
+ }
441
488
  async function startCheckout(input) {
442
489
  if (options.mode === "oss") {
443
490
  throw new BillingWorkflowError("unsupported_operation");
@@ -473,8 +520,12 @@ export function createBilling(options) {
473
520
  if (existingSub && retainsPaidEntitlement(existingSub, clock.now())) {
474
521
  throw new BillingWorkflowError("active_subscription_exists");
475
522
  }
523
+ if (existingSub) {
524
+ // Paid access lapsed but the deadline batch has not run yet.
525
+ await releaseLapsedEntitlement(existingSub.id, "checkout");
526
+ }
476
527
  const adapter = provider(options.checkoutProvider);
477
- const checkoutIdempotencyKey = `checkout:${input.entity.id}:${input.offerKey}:${catalog.revision.revision}`;
528
+ let checkoutIdempotencyKey = `checkout:${input.entity.id}:${input.offerKey}:${catalog.revision.revision}`;
478
529
  const resumable = await store.findResumableCheckout(input.entity.id);
479
530
  if (resumable?.providerCheckoutSessionId && resumable.payerId === input.payer.id) {
480
531
  try {
@@ -498,21 +549,32 @@ export function createBilling(options) {
498
549
  throw error;
499
550
  }
500
551
  }
501
- const liveCheckout = await store.findLiveCheckout(input.entity.id);
552
+ let liveCheckout = await store.findLiveCheckout(input.entity.id);
502
553
  let resumeAttempt;
554
+ // The payer's open checkout for another offer, replaced by this one.
555
+ let superseded;
556
+ const sameRequest = (attempt) => attempt.payerId === input.payer.id &&
557
+ attempt.offerKey === input.offerKey &&
558
+ attempt.catalogRevision === catalog.revision.revision;
503
559
  if (liveCheckout && liveCheckout.expiresAt.getTime() <= clock.now().getTime()) {
504
560
  liveCheckout.status = "expired";
505
561
  liveCheckout.completedAt = clock.now();
506
562
  liveCheckout.checkoutUrlEncrypted = null;
507
563
  await store.saveCheckout(liveCheckout);
508
- resumeAttempt = liveCheckout;
564
+ if (sameRequest(liveCheckout))
565
+ resumeAttempt = liveCheckout;
566
+ liveCheckout = undefined;
509
567
  }
510
568
  else if (liveCheckout) {
511
- if (liveCheckout.payerId !== input.payer.id ||
512
- liveCheckout.offerKey !== input.offerKey ||
513
- liveCheckout.catalogRevision !== catalog.revision.revision) {
569
+ if (liveCheckout.payerId !== input.payer.id) {
514
570
  throw new BillingWorkflowError("checkout_pending");
515
571
  }
572
+ if (!sameRequest(liveCheckout)) {
573
+ superseded = liveCheckout;
574
+ liveCheckout = undefined;
575
+ }
576
+ }
577
+ if (liveCheckout) {
516
578
  if (liveCheckout.status === "open" &&
517
579
  liveCheckout.checkoutUrlEncrypted &&
518
580
  options.sensitiveValues) {
@@ -527,6 +589,11 @@ export function createBilling(options) {
527
589
  if (!resumeAttempt) {
528
590
  const existing = await store.findCheckoutByIdempotencyKey(checkoutIdempotencyKey);
529
591
  if (existing &&
592
+ (existing.status === "abandoned" || existing.status === "conflicted")) {
593
+ // A replaced checkout keeps its key, so the new attempt needs another.
594
+ checkoutIdempotencyKey = `${checkoutIdempotencyKey}:${newId()}`;
595
+ }
596
+ else if (existing &&
530
597
  existing.payerId === input.payer.id &&
531
598
  existing.offerKey === input.offerKey &&
532
599
  existing.catalogRevision === catalog.revision.revision) {
@@ -602,6 +669,7 @@ export function createBilling(options) {
602
669
  catalogKey: liveCheckout.offerKey,
603
670
  trialDays: input.trialDays ?? 0,
604
671
  idempotencyKey: liveCheckout.idempotencyKey,
672
+ expiresAt: liveCheckout.expiresAt,
605
673
  }));
606
674
  if (options.sensitiveValues) {
607
675
  const encrypted = await options.sensitiveValues.encrypt(remote.checkoutUrl);
@@ -634,6 +702,8 @@ export function createBilling(options) {
634
702
  else {
635
703
  try {
636
704
  attempt = await store.withTransaction(async () => {
705
+ if (superseded)
706
+ await abandonSupersededCheckout(superseded.id);
637
707
  const row = {
638
708
  id: newId(),
639
709
  attemptId: store.newAttemptId(),
@@ -667,23 +737,26 @@ export function createBilling(options) {
667
737
  catch (error) {
668
738
  const raced = (await store.findLiveCheckout(input.entity.id)) ??
669
739
  (await store.findCheckoutByIdempotencyKey(checkoutIdempotencyKey));
670
- if (!raced ||
671
- raced.payerId !== input.payer.id ||
672
- raced.offerKey !== input.offerKey ||
673
- raced.catalogRevision !== catalog.revision.revision) {
740
+ if (!raced || error instanceof BillingWorkflowError)
674
741
  throw error;
742
+ if (!sameRequest(raced)) {
743
+ throw new BillingWorkflowError("checkout_pending");
675
744
  }
676
745
  attempt = raced;
677
746
  }
678
747
  }
679
748
  const reopenExpiredOrUnpaidCompleted = attempt.status === "expired" || attempt.status === "completed";
680
749
  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);
750
+ await store.withTransaction(async () => {
751
+ if (superseded)
752
+ await abandonSupersededCheckout(superseded.id);
753
+ attempt.status = "creating";
754
+ attempt.completedAt = null;
755
+ attempt.checkoutUrlEncrypted = null;
756
+ attempt.lastError = null;
757
+ attempt.expiresAt = new Date(clock.now().getTime() + 60 * 60 * 1000);
758
+ await store.saveCheckout(attempt);
759
+ });
687
760
  }
688
761
  const providerCheckoutKey = reopenExpiredOrUnpaidCompleted
689
762
  ? `${attempt.idempotencyKey}:reopen:${attempt.id}`
@@ -713,6 +786,7 @@ export function createBilling(options) {
713
786
  catalogKey: item.offerKey,
714
787
  trialDays: input.trialDays ?? 0,
715
788
  idempotencyKey: providerCheckoutKey,
789
+ expiresAt: attempt.expiresAt,
716
790
  }));
717
791
  const encrypted = options.sensitiveValues
718
792
  ? await options.sensitiveValues.encrypt(remote.checkoutUrl)
@@ -848,6 +922,15 @@ export function createBilling(options) {
848
922
  !adapter.capabilities.proratedPlanChanges) {
849
923
  throw new BillingWorkflowError("plan_change_not_supported");
850
924
  }
925
+ if (input.effectiveAt === "next_billing_date" &&
926
+ adapter.capabilities.immediatePlanChangesOnly) {
927
+ throw new BillingWorkflowError("plan_change_not_supported");
928
+ }
929
+ if (input.prorationMode === "do_not_bill" &&
930
+ target.price.interval !== sub.interval &&
931
+ adapter.capabilities.intervalChangesBillImmediately) {
932
+ throw new BillingWorkflowError("plan_change_not_supported");
933
+ }
851
934
  let providerProduct;
852
935
  try {
853
936
  providerProduct = await callProvider(() => adapter.retrieveProduct(target.price.providerProductId));
@@ -886,15 +969,18 @@ export function createBilling(options) {
886
969
  let attempt;
887
970
  try {
888
971
  attempt = await store.withTransaction(async () => {
972
+ const changeId = store.newChangeId();
889
973
  const row = {
890
974
  id: newId(),
891
- changeId: store.newChangeId(),
975
+ changeId,
892
976
  billableEntityId: input.entity.id,
893
977
  subscriptionId: sub.id,
894
978
  actorId: input.payer.id,
895
979
  payerId: input.payer.id,
896
980
  provider: sub.provider,
897
- idempotencyKey: `plan-change:${sub.id}:${input.offerKey}:${catalog.revision.revision}`,
981
+ // One key per attempt: a later change back to the same offer is a
982
+ // new request, while retries of this one reuse the stored key.
983
+ idempotencyKey: `plan-change:${sub.id}:${input.offerKey}:${catalog.revision.revision}:${changeId}`,
898
984
  currentCatalogRevision: sub.catalogRevision,
899
985
  currentPriceEntryId: sub.priceEntryId,
900
986
  currentPlan: sub.plan,
@@ -934,7 +1020,7 @@ export function createBilling(options) {
934
1020
  const paymentUrl = remote.paymentUrl && options.sensitiveValues
935
1021
  ? await options.sensitiveValues.encrypt(remote.paymentUrl)
936
1022
  : null;
937
- return await store.withTransaction(async () => {
1023
+ const pending = await store.withTransaction(async () => {
938
1024
  const latest = (await store.findPlanChangeById(attempt.id)) ?? attempt;
939
1025
  const decision = decidePlanChangeTransition(latest.status, "pending");
940
1026
  if (!decision.allowed)
@@ -952,6 +1038,8 @@ export function createBilling(options) {
952
1038
  });
953
1039
  return latest;
954
1040
  });
1041
+ await readBackSubscription(sub);
1042
+ return (await store.findPlanChangeById(pending.id)) ?? pending;
955
1043
  }
956
1044
  catch (error) {
957
1045
  await enqueueJob({
@@ -981,7 +1069,7 @@ export function createBilling(options) {
981
1069
  if (sub.payerId !== input.payer.id) {
982
1070
  throw new BillingWorkflowError("payer_mismatch");
983
1071
  }
984
- if (sub.status === "cancelled") {
1072
+ if (sub.status === "cancelled" || sub.cancelAtPeriodEnd) {
985
1073
  return { accepted: true, subscriptionId: sub.id };
986
1074
  }
987
1075
  if (provider(sub.provider).capabilities.mutationRecovery.cancellation ===
@@ -990,7 +1078,7 @@ export function createBilling(options) {
990
1078
  }
991
1079
  await store.withTransaction(async () => {
992
1080
  await requireAudit({
993
- effectId: `cancellation:${sub.id}:requested`,
1081
+ effectId: `cancellation:${sub.id}:${sub.providerVersion ?? "0"}:requested`,
994
1082
  actor: { kind: "user", id: input.payer.id },
995
1083
  previous: { status: sub.status },
996
1084
  next: { requested: "provider_cancel" },
@@ -1003,7 +1091,78 @@ export function createBilling(options) {
1003
1091
  });
1004
1092
  });
1005
1093
  try {
1006
- await callProvider(() => provider(sub.provider).cancelSubscription(sub.providerSubscriptionId, `cancel:${sub.id}`));
1094
+ await callProvider(() => provider(sub.provider).cancelSubscription(sub.providerSubscriptionId, cancellationKey(sub)));
1095
+ }
1096
+ catch (error) {
1097
+ if (error instanceof BillingWorkflowError)
1098
+ throw error;
1099
+ throw new BillingWorkflowError("provider_unavailable", {
1100
+ retryable: true,
1101
+ });
1102
+ }
1103
+ await readBackSubscription(sub);
1104
+ return { accepted: true, subscriptionId: sub.id };
1105
+ }
1106
+ /**
1107
+ * Clears a cancellation scheduled for the end of the paid period. Uses the
1108
+ * `cancellation` grant, because it reverses that action.
1109
+ */
1110
+ async function resumeCancellation(input) {
1111
+ if (options.mode === "oss") {
1112
+ throw new BillingWorkflowError("unsupported_operation");
1113
+ }
1114
+ await consumeGrant(input.grant, "cancellation", {
1115
+ kind: input.entity.kind,
1116
+ id: input.entity.id,
1117
+ }, input.payer.id);
1118
+ const sub = await store.findEntitlementSubscription(input.entity.id);
1119
+ // Some providers, such as Lemon Squeezy, report a scheduled cancellation
1120
+ // as `cancelled` until the period ends; that one can still be resumed.
1121
+ if (!sub ||
1122
+ sub.status === "expired" ||
1123
+ (sub.status === "cancelled" && !sub.cancelAtPeriodEnd)) {
1124
+ throw new BillingWorkflowError("subscription_required");
1125
+ }
1126
+ if (sub.payerId !== input.payer.id) {
1127
+ throw new BillingWorkflowError("payer_mismatch");
1128
+ }
1129
+ if (!sub.cancelAtPeriodEnd) {
1130
+ return { accepted: true, subscriptionId: sub.id };
1131
+ }
1132
+ await store.withTransaction(async () => {
1133
+ // A worker already sending the cancellation would cancel again after
1134
+ // this resume, so the caller retries once that job has finished.
1135
+ const running = await store.findLiveJob({
1136
+ provider: sub.provider,
1137
+ subscriptionId: sub.id,
1138
+ });
1139
+ if (running?.operation === "cancellation" &&
1140
+ running.status === "processing" &&
1141
+ running.leaseExpiresAt !== null &&
1142
+ running.leaseExpiresAt.getTime() > clock.now().getTime()) {
1143
+ throw new BillingWorkflowError("operation_conflicted", { retryable: true });
1144
+ }
1145
+ await requireAudit({
1146
+ effectId: `resumption:${sub.id}:${sub.providerVersion ?? "0"}:requested`,
1147
+ actor: { kind: "user", id: input.payer.id },
1148
+ previous: { cancelAtPeriodEnd: true },
1149
+ next: { requested: "provider_resume" },
1150
+ correlationIds: { subscriptionId: sub.id },
1151
+ });
1152
+ // A pending cancellation retry must not cancel again after this. If the
1153
+ // resume call below fails, reconciling reads back the provider's state
1154
+ // and the caller can resume again.
1155
+ const job = await enqueueJobInTransaction({
1156
+ provider: sub.provider,
1157
+ subscriptionId: sub.id,
1158
+ });
1159
+ if (job.operation === "cancellation") {
1160
+ job.operation = "reconcile";
1161
+ await store.saveJob(job);
1162
+ }
1163
+ });
1164
+ try {
1165
+ await callProvider(() => provider(sub.provider).resumeSubscription(sub.providerSubscriptionId, `resume:${sub.id}:${sub.providerVersion ?? "0"}`));
1007
1166
  }
1008
1167
  catch (error) {
1009
1168
  if (error instanceof BillingWorkflowError)
@@ -1012,6 +1171,7 @@ export function createBilling(options) {
1012
1171
  retryable: true,
1013
1172
  });
1014
1173
  }
1174
+ await readBackSubscription(sub);
1015
1175
  return { accepted: true, subscriptionId: sub.id };
1016
1176
  }
1017
1177
  async function ingestWebhook(input) {
@@ -1061,13 +1221,16 @@ export function createBilling(options) {
1061
1221
  payloadEncrypted: encryptedPayload?.ciphertext ?? null,
1062
1222
  payloadKeyVersion: encryptedPayload?.keyVersion ?? null,
1063
1223
  verifiedKeyVersion: envelope.verifiedKeyVersion,
1064
- status: "pending",
1224
+ // Another product's event is kept for deduplication and audit, but is
1225
+ // never processed, retried, or quarantined.
1226
+ status: envelope.foreign ? "ignored" : "pending",
1065
1227
  processingAttempts: 0,
1066
1228
  lastError: null,
1067
1229
  availableAt: clock.now(),
1068
1230
  lockedAt: null,
1069
1231
  leaseExpiresAt: null,
1070
1232
  workerId: null,
1233
+ ...(envelope.foreign ? { processedAt: clock.now() } : {}),
1071
1234
  };
1072
1235
  try {
1073
1236
  await store.withTransaction(async () => store.insertWebhook(record));
@@ -1087,6 +1250,27 @@ export function createBilling(options) {
1087
1250
  async function projectSnapshot(snapshot, correlation, reconcile = false) {
1088
1251
  return store.withTransaction(() => projectSnapshotInTransaction(snapshot, correlation, reconcile));
1089
1252
  }
1253
+ /**
1254
+ * Points the payer's customer at the one the provider chose during checkout.
1255
+ * Only for a first subscription correlated to that payer's checkout.
1256
+ */
1257
+ async function relinkCheckoutCustomer(customerRowId, payerId, providerCustomerId) {
1258
+ const row = await store.findCustomerById(customerRowId);
1259
+ if (!row || row.payerId !== payerId || row.status !== "active")
1260
+ return undefined;
1261
+ const previous = row.providerCustomerId;
1262
+ row.providerCustomerId = providerCustomerId;
1263
+ await store.saveCustomer(row);
1264
+ await requireAudit({
1265
+ effectId: `customer:${row.id}:relinked:${providerCustomerId}`,
1266
+ actor: { kind: "system", id: "billing" },
1267
+ previous: { providerCustomerId: previous },
1268
+ next: { providerCustomerId },
1269
+ correlationIds: { customerId: row.id },
1270
+ });
1271
+ telemetry.event("customer.relinked", { customerId: row.id });
1272
+ return row;
1273
+ }
1090
1274
  async function projectSnapshotInTransaction(snapshot, correlation, reconcile = false) {
1091
1275
  if (!isBoundedOpaqueId(snapshot.provider) ||
1092
1276
  !isBoundedOpaqueId(snapshot.providerSubscriptionId) ||
@@ -1119,9 +1303,18 @@ export function createBilling(options) {
1119
1303
  ? await store.findCheckoutById(existing.originCheckoutAttemptId)
1120
1304
  : undefined;
1121
1305
  const attempt = correlatedAttempt ?? originAttempt;
1122
- const billableEntityId = existing?.billableEntityId ?? attempt?.billableEntityId;
1123
- const payerId = existing?.payerId ?? attempt?.payerId;
1124
- const customer = await store.findCustomerByProviderCustomerId(snapshot.provider, snapshot.providerCustomerId);
1306
+ const billableEntityId = existing?.billableEntityId ??
1307
+ attempt?.billableEntityId ??
1308
+ correlation.adopted?.billableEntityId;
1309
+ const payerId = existing?.payerId ?? attempt?.payerId ?? correlation.adopted?.payerId;
1310
+ let customer = await store.findCustomerByProviderCustomerId(snapshot.provider, snapshot.providerCustomerId);
1311
+ if (!customer &&
1312
+ !existing &&
1313
+ correlatedAttempt?.providerCustomerRowId &&
1314
+ correlatedAttempt.provider === snapshot.provider &&
1315
+ provider(snapshot.provider).capabilities.checkoutAssignsCustomer) {
1316
+ customer = await relinkCheckoutCustomer(correlatedAttempt.providerCustomerRowId, correlatedAttempt.payerId, snapshot.providerCustomerId);
1317
+ }
1125
1318
  if (!billableEntityId || !payerId || !customer) {
1126
1319
  throw new BillingWorkflowError("operation_quarantined");
1127
1320
  }
@@ -1176,7 +1369,10 @@ export function createBilling(options) {
1176
1369
  }
1177
1370
  const lineageRevision = pendingChange
1178
1371
  ? pendingChange.targetCatalogRevision
1179
- : (existing?.catalogRevision ?? attempt?.catalogRevision ?? 0);
1372
+ : (existing?.catalogRevision ??
1373
+ attempt?.catalogRevision ??
1374
+ correlation.adopted?.catalogRevision ??
1375
+ 0);
1180
1376
  const lineageOffer = pendingChange
1181
1377
  ? pendingChange.targetOfferKey
1182
1378
  : (existing?.offerKey ?? attempt?.offerKey ?? price.offerKey);
@@ -1377,10 +1573,18 @@ export function createBilling(options) {
1377
1573
  const limit = workLimit(input.limit);
1378
1574
  return store.claimClaimableJobs(clock.now(), limit, input.workerId);
1379
1575
  }
1576
+ /**
1577
+ * Processes queued reconciliation jobs. Unless `discover` is false, it first
1578
+ * queues stuck work (see `enqueueStuckWork`), so products need no sweep of
1579
+ * their own.
1580
+ */
1380
1581
  async function runReconciliationBatch(input) {
1381
1582
  const limit = workLimit(input.limit);
1583
+ if (input.discover !== false && options.mode === "cloud") {
1584
+ await enqueueStuckWork(limit);
1585
+ }
1382
1586
  const claimed = await claimReconciliationJobs({
1383
- ...input,
1587
+ workerId: input.workerId,
1384
1588
  limit,
1385
1589
  });
1386
1590
  for (const job of claimed) {
@@ -1443,29 +1647,11 @@ export function createBilling(options) {
1443
1647
  return processed;
1444
1648
  const subscriptions = await store.listDueSubscriptionDeadlines(now, remaining);
1445
1649
  for (const subscription of subscriptions) {
1446
- const state = await store.ensurePlanState(subscription.billableEntityId);
1447
- if (state.activeSubscriptionId !== subscription.id) {
1448
- throw new BillingWorkflowError("operation_conflicted");
1449
- }
1450
- const previous = { ...subscription };
1451
- subscription.isEntitlementSource = false;
1452
- state.activeSubscriptionId = null;
1453
- state.projectionVersion += 1;
1454
- await store.upsertSubscription(subscription);
1455
- await store.savePlanState(state);
1456
- const effectId = `projection:${subscription.id}:${state.projectionVersion}`;
1457
- await requireAudit({
1458
- effectId,
1459
- actor: { kind: "system", id: "deadline_worker" },
1460
- previous,
1461
- next: subscription,
1462
- correlationIds: { subscriptionId: subscription.id },
1463
- });
1464
- await lifecycle?.afterProjection?.({
1465
- material: true,
1466
- previous,
1467
- next: subscription,
1468
- planState: state,
1650
+ await releaseLapsedEntitlementInTransaction(subscription.id, "deadline_worker");
1651
+ // Confirm the final state with the provider in case its last event was missed.
1652
+ await enqueueJobInTransaction({
1653
+ provider: subscription.provider,
1654
+ subscriptionId: subscription.id,
1469
1655
  });
1470
1656
  processed += 1;
1471
1657
  }
@@ -1618,6 +1804,7 @@ export function createBilling(options) {
1618
1804
  catalogKey: attempt.offerKey,
1619
1805
  trialDays: 0,
1620
1806
  idempotencyKey: attempt.idempotencyKey,
1807
+ expiresAt: attempt.expiresAt,
1621
1808
  }));
1622
1809
  await store.withTransaction(async () => {
1623
1810
  const latest = (await store.findCheckoutById(attempt.id)) ?? attempt;
@@ -1665,7 +1852,7 @@ export function createBilling(options) {
1665
1852
  if (adapter.capabilities.mutationRecovery.cancellation === "unsupported") {
1666
1853
  throw new BillingWorkflowError("operation_quarantined");
1667
1854
  }
1668
- await callProvider(() => adapter.cancelSubscription(sub.providerSubscriptionId, `cancel:${sub.id}`));
1855
+ await callProvider(() => adapter.cancelSubscription(sub.providerSubscriptionId, cancellationKey(sub)));
1669
1856
  }
1670
1857
  const snapshot = await callProvider(() => adapter.retrieveSubscription(sub.providerSubscriptionId));
1671
1858
  await projectSnapshot(snapshot, {}, true);
@@ -1830,7 +2017,18 @@ export function createBilling(options) {
1830
2017
  },
1831
2018
  });
1832
2019
  }
1833
- async function commercialState(entityId) {
2020
+ /**
2021
+ * Reads the entity's paid state. Pass `transaction` to read inside the
2022
+ * caller's open transaction; the Drizzle store then locks the rows it reads
2023
+ * until that transaction ends, so quota checks can rely on it.
2024
+ */
2025
+ async function commercialState(entityId, readOptions = {}) {
2026
+ if (readOptions.transaction !== undefined) {
2027
+ return store.runInTransaction(readOptions.transaction, () => readCommercialState(entityId));
2028
+ }
2029
+ return readCommercialState(entityId);
2030
+ }
2031
+ async function readCommercialState(entityId) {
1834
2032
  const state = (await store.findPlanState(entityId)) ?? {
1835
2033
  billableEntityId: entityId,
1836
2034
  activeSubscriptionId: null,
@@ -1839,9 +2037,13 @@ export function createBilling(options) {
1839
2037
  const pointed = state.activeSubscriptionId
1840
2038
  ? await store.findSubscriptionById(state.activeSubscriptionId)
1841
2039
  : undefined;
1842
- const sub = pointed?.isEntitlementSource ? pointed : undefined;
2040
+ // Re-check the clock: paid access may have lapsed since the last projection.
2041
+ const sub = pointed?.isEntitlementSource && retainsPaidEntitlement(pointed, clock.now())
2042
+ ? pointed
2043
+ : undefined;
1843
2044
  return {
1844
2045
  activePaidPlan: sub?.plan ?? null,
2046
+ provider: sub?.provider ?? null,
1845
2047
  billingInterval: sub?.interval ?? null,
1846
2048
  subscriptionStatus: sub?.status ?? null,
1847
2049
  providerTrialEndsAt: sub?.trialEndsAt ?? null,
@@ -1853,6 +2055,148 @@ export function createBilling(options) {
1853
2055
  projectionVersion: state.projectionVersion,
1854
2056
  };
1855
2057
  }
2058
+ /** Stops a subscription whose paid access has lapsed from granting entitlement. */
2059
+ async function releaseLapsedEntitlement(subscriptionId, actorId) {
2060
+ await store.withTransaction(() => releaseLapsedEntitlementInTransaction(subscriptionId, actorId));
2061
+ }
2062
+ async function releaseLapsedEntitlementInTransaction(subscriptionId, actorId) {
2063
+ const subscription = await store.findSubscriptionById(subscriptionId);
2064
+ if (!subscription?.isEntitlementSource ||
2065
+ retainsPaidEntitlement(subscription, clock.now())) {
2066
+ return;
2067
+ }
2068
+ const state = await store.ensurePlanState(subscription.billableEntityId);
2069
+ if (state.activeSubscriptionId !== subscription.id) {
2070
+ throw new BillingWorkflowError("operation_conflicted");
2071
+ }
2072
+ const previous = { ...subscription };
2073
+ subscription.isEntitlementSource = false;
2074
+ state.activeSubscriptionId = null;
2075
+ state.projectionVersion += 1;
2076
+ await store.upsertSubscription(subscription);
2077
+ await store.savePlanState(state);
2078
+ await requireAudit({
2079
+ effectId: `projection:${subscription.id}:${state.projectionVersion}`,
2080
+ actor: { kind: "system", id: actorId },
2081
+ previous,
2082
+ next: subscription,
2083
+ correlationIds: { subscriptionId: subscription.id },
2084
+ });
2085
+ await lifecycle?.afterProjection?.({
2086
+ material: true,
2087
+ previous,
2088
+ next: subscription,
2089
+ planState: state,
2090
+ });
2091
+ }
2092
+ /**
2093
+ * Queues reconciliation for work that stalled without an event: checkouts
2094
+ * stuck while being created, plan changes interrupted by an error, and
2095
+ * subscriptions not checked with the provider recently.
2096
+ */
2097
+ async function enqueueStuckWork(limit) {
2098
+ const now = clock.now().getTime();
2099
+ const stale = options.reconciliation ?? {};
2100
+ let queued = 0;
2101
+ const checkouts = await store.listStuckCreatingCheckouts(new Date(now - (stale.checkoutStaleAfterMs ?? 60 * 1000)), clock.now(), limit);
2102
+ for (const attempt of checkouts) {
2103
+ await enqueueJob({ provider: attempt.provider, checkoutAttemptId: attempt.id });
2104
+ queued += 1;
2105
+ }
2106
+ const changes = await store.listStuckPlanChanges(new Date(now - (stale.planChangeStaleAfterMs ?? 60 * 60 * 1000)), Math.max(0, limit - queued));
2107
+ for (const change of changes) {
2108
+ await enqueueJob({ provider: change.provider, planChangeAttemptId: change.id });
2109
+ queued += 1;
2110
+ }
2111
+ const subscriptions = await store.listUnreconciledSubscriptions(new Date(now - (stale.subscriptionStaleAfterMs ?? 60 * 60 * 1000)), Math.max(0, limit - queued));
2112
+ for (const subscription of subscriptions) {
2113
+ await enqueueJob({
2114
+ provider: subscription.provider,
2115
+ subscriptionId: subscription.id,
2116
+ });
2117
+ queued += 1;
2118
+ }
2119
+ return queued;
2120
+ }
2121
+ /**
2122
+ * Takes over a subscription created outside this engine, for example one
2123
+ * started with the provider before the product used this package. Links the
2124
+ * provider's customer to the payer, then projects the subscription like any
2125
+ * other. Its product must be in the provider's active catalog. Repeating it
2126
+ * for the same entity is safe.
2127
+ */
2128
+ async function adoptProviderSubscription(context, input) {
2129
+ if (options.mode === "oss") {
2130
+ throw new BillingWorkflowError("unsupported_operation");
2131
+ }
2132
+ assertOperatorContext(context);
2133
+ if (!isBoundedOpaqueId(input.providerName) ||
2134
+ !isBoundedOpaqueId(input.providerSubscriptionId) ||
2135
+ !isBoundedOpaqueId(input.entity.id) ||
2136
+ !isBoundedOpaqueId(input.payer.id) ||
2137
+ !input.payer.email) {
2138
+ throw new BillingWorkflowError("operation_quarantined");
2139
+ }
2140
+ const adapter = provider(input.providerName);
2141
+ const catalog = await store.getActiveCatalog(input.providerName);
2142
+ if (!catalog)
2143
+ throw new BillingWorkflowError("catalog_unavailable");
2144
+ const snapshot = await callProvider(() => adapter.retrieveSubscription(input.providerSubscriptionId));
2145
+ // A product from a retired revision still has a price entry, so check the
2146
+ // active catalog itself.
2147
+ if (!catalog.items.some((item) => item.price.providerProductId === snapshot.providerProductId)) {
2148
+ throw new BillingWorkflowError("operation_quarantined");
2149
+ }
2150
+ return store.withTransaction(async () => {
2151
+ const known = await store.findSubscriptionByProviderIds(snapshot.provider, snapshot.providerSubscriptionId);
2152
+ if (known &&
2153
+ (known.billableEntityId !== input.entity.id || known.payerId !== input.payer.id)) {
2154
+ throw new BillingWorkflowError("operation_conflicted");
2155
+ }
2156
+ let customer = await store.findCustomerByProviderCustomerId(snapshot.provider, snapshot.providerCustomerId);
2157
+ if (customer && customer.payerId !== input.payer.id) {
2158
+ throw new BillingWorkflowError("payer_mismatch");
2159
+ }
2160
+ if (!customer) {
2161
+ const payerCustomer = await store.findCustomerByPayer(snapshot.provider, input.payer.id);
2162
+ if (payerCustomer?.providerCustomerId &&
2163
+ payerCustomer.providerCustomerId !== snapshot.providerCustomerId) {
2164
+ throw new BillingWorkflowError("operation_conflicted");
2165
+ }
2166
+ if (payerCustomer) {
2167
+ payerCustomer.providerCustomerId = snapshot.providerCustomerId;
2168
+ payerCustomer.status = "active";
2169
+ await store.saveCustomer(payerCustomer);
2170
+ customer = payerCustomer;
2171
+ }
2172
+ else {
2173
+ customer = {
2174
+ id: newId(),
2175
+ provider: snapshot.provider,
2176
+ payerId: input.payer.id,
2177
+ payerEmail: input.payer.email,
2178
+ providerCustomerId: snapshot.providerCustomerId,
2179
+ idempotencyKey: `adopt:${snapshot.provider}:${snapshot.providerCustomerId}`,
2180
+ status: "active",
2181
+ lastError: null,
2182
+ };
2183
+ await store.insertCustomer(customer);
2184
+ }
2185
+ }
2186
+ const write = await projectSnapshotInTransaction(snapshot, {
2187
+ adopted: {
2188
+ billableEntityId: input.entity.id,
2189
+ payerId: input.payer.id,
2190
+ catalogRevision: catalog.revision.revision,
2191
+ },
2192
+ });
2193
+ await recordOperatorAudit(context, `operator:adopt:${snapshot.provider}:${snapshot.providerSubscriptionId}`, known ?? null, write.subscription, {
2194
+ subscriptionId: write.subscription.id,
2195
+ billableEntityId: input.entity.id,
2196
+ });
2197
+ return write;
2198
+ });
2199
+ }
1856
2200
  async function operatorCancel(context, subscriptionId) {
1857
2201
  if (options.mode === "oss") {
1858
2202
  throw new BillingWorkflowError("unsupported_operation");
@@ -1863,6 +2207,8 @@ export function createBilling(options) {
1863
2207
  throw new BillingWorkflowError("subscription_required");
1864
2208
  if (sub.status === "cancelled" || sub.status === "expired")
1865
2209
  return;
2210
+ if (sub.cancelAtPeriodEnd)
2211
+ return;
1866
2212
  if (provider(sub.provider).capabilities.mutationRecovery.cancellation ===
1867
2213
  "unsupported") {
1868
2214
  throw new BillingWorkflowError("unsupported_operation");
@@ -1876,7 +2222,7 @@ export function createBilling(options) {
1876
2222
  });
1877
2223
  });
1878
2224
  try {
1879
- await callProvider(() => provider(sub.provider).cancelSubscription(sub.providerSubscriptionId, `operator-cancel:${sub.id}`));
2225
+ await callProvider(() => provider(sub.provider).cancelSubscription(sub.providerSubscriptionId, `operator-${cancellationKey(sub)}`));
1880
2226
  }
1881
2227
  catch (error) {
1882
2228
  if (error instanceof BillingWorkflowError)
@@ -1928,15 +2274,23 @@ export function createBilling(options) {
1928
2274
  });
1929
2275
  }
1930
2276
  return {
2277
+ /**
2278
+ * @internal The engine's own persistence. Products must not read or write
2279
+ * billing state through it: use the workflows and read models
2280
+ * (`commercialState`, `publicCatalog`, `health`) instead, so projections,
2281
+ * audit, and lifecycle hooks stay consistent.
2282
+ */
1931
2283
  store,
1932
2284
  startCheckout,
1933
2285
  startPortal,
1934
2286
  startPlanChange,
1935
2287
  cancel,
2288
+ resumeCancellation,
1936
2289
  ingestWebhook,
1937
2290
  processWebhookByEventId,
1938
2291
  runWebhookInboxBatch,
1939
2292
  runReconciliationBatch,
2293
+ enqueueStuckWork,
1940
2294
  runDeadlineBatch,
1941
2295
  purgeExpiredSensitiveValues,
1942
2296
  claimReconciliationJobs,
@@ -1950,11 +2304,20 @@ export function createBilling(options) {
1950
2304
  verifyRequestedCatalog,
1951
2305
  abandonRequestedCatalog,
1952
2306
  operatorCancel,
2307
+ adoptProviderSubscription,
1953
2308
  recordOperatorAudit,
1954
2309
  enqueueJob,
1955
2310
  resolveCustomer,
1956
2311
  };
1957
2312
  }
2313
+ /**
2314
+ * Provider idempotency key for a cancellation. It includes the subscription's
2315
+ * provider version so that cancelling again after a resume is a new request,
2316
+ * while retries of the same request reuse the key.
2317
+ */
2318
+ function cancellationKey(sub) {
2319
+ return `cancel:${sub.id}:${sub.providerVersion ?? "0"}`;
2320
+ }
1958
2321
  function catalogShapeIsComplete(catalog, requiredOfferKeys) {
1959
2322
  if (!catalog)
1960
2323
  return false;