@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.
- package/README.md +44 -2
- package/dist/adapters/drizzle/store.d.ts.map +1 -1
- package/dist/adapters/drizzle/store.js +25 -2
- package/dist/adapters/drizzle/store.js.map +1 -1
- package/dist/core/subscription.d.ts +2 -0
- package/dist/core/subscription.d.ts.map +1 -1
- package/dist/core/subscription.js +7 -0
- package/dist/core/subscription.js.map +1 -1
- package/dist/operations/index.d.ts +2 -0
- package/dist/operations/index.d.ts.map +1 -1
- package/dist/operations/index.js +4 -0
- package/dist/operations/index.js.map +1 -1
- package/dist/persistence/memory.d.ts +4 -0
- package/dist/persistence/memory.d.ts.map +1 -1
- package/dist/persistence/memory.js +29 -1
- package/dist/persistence/memory.js.map +1 -1
- package/dist/persistence/store.d.ts +11 -0
- package/dist/persistence/store.d.ts.map +1 -1
- package/dist/providers/contract.d.ts +45 -0
- package/dist/providers/contract.d.ts.map +1 -1
- package/dist/providers/dodo/index.d.ts +2 -0
- package/dist/providers/dodo/index.d.ts.map +1 -1
- package/dist/providers/dodo/index.js +29 -3
- package/dist/providers/dodo/index.js.map +1 -1
- package/dist/providers/fake/index.d.ts +14 -2
- package/dist/providers/fake/index.d.ts.map +1 -1
- package/dist/providers/fake/index.js +68 -3
- package/dist/providers/fake/index.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/lemonsqueezy/index.d.ts +42 -0
- package/dist/providers/lemonsqueezy/index.d.ts.map +1 -0
- package/dist/providers/lemonsqueezy/index.js +385 -0
- package/dist/providers/lemonsqueezy/index.js.map +1 -0
- package/dist/providers/lemonsqueezy/normalize.d.ts +12 -0
- package/dist/providers/lemonsqueezy/normalize.d.ts.map +1 -0
- package/dist/providers/lemonsqueezy/normalize.js +72 -0
- package/dist/providers/lemonsqueezy/normalize.js.map +1 -0
- package/dist/testing/provider-contract.d.ts.map +1 -1
- package/dist/testing/provider-contract.js +6 -1
- package/dist/testing/provider-contract.js.map +1 -1
- package/dist/testing/workflow-harness.d.ts +48 -2
- package/dist/testing/workflow-harness.d.ts.map +1 -1
- package/dist/workflows/engine.d.ts +42 -1
- package/dist/workflows/engine.d.ts.map +1 -1
- package/dist/workflows/engine.js +418 -55
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -0
- package/dist/workflows/index.d.ts.map +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/docs/architecture.md +3 -1
- package/package.json +6 -1
package/dist/workflows/engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 ??
|
|
1123
|
-
|
|
1124
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|