@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.
- package/README.md +15 -15
- package/dist/adapters/drizzle/index.d.ts +2 -2
- package/dist/adapters/drizzle/index.d.ts.map +1 -1
- package/dist/adapters/drizzle/index.js.map +1 -1
- package/dist/adapters/drizzle/store.d.ts.map +1 -1
- package/dist/adapters/drizzle/store.js +39 -48
- package/dist/adapters/drizzle/store.js.map +1 -1
- package/dist/catalog/index.d.ts +3 -3
- package/dist/catalog/index.d.ts.map +1 -1
- package/dist/catalog/index.js +2 -2
- package/dist/catalog/index.js.map +1 -1
- package/dist/catalog/public-view.d.ts.map +1 -1
- package/dist/catalog/public-view.js.map +1 -1
- package/dist/catalog/revisions.d.ts.map +1 -1
- package/dist/catalog/revisions.js.map +1 -1
- package/dist/catalog/types.d.ts.map +1 -1
- package/dist/catalog/validate.d.ts.map +1 -1
- package/dist/catalog/validate.js +2 -3
- package/dist/catalog/validate.js.map +1 -1
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +2 -4
- package/dist/cli/generate.js.map +1 -1
- package/dist/config/define.d.ts.map +1 -1
- package/dist/config/define.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/validate.d.ts.map +1 -1
- package/dist/config/validate.js +6 -13
- package/dist/config/validate.js.map +1 -1
- package/dist/core/checkout-attempt.d.ts.map +1 -1
- package/dist/core/checkout-attempt.js +4 -1
- package/dist/core/checkout-attempt.js.map +1 -1
- package/dist/core/clock.d.ts.map +1 -1
- package/dist/core/clock.js.map +1 -1
- package/dist/core/customer.d.ts.map +1 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js.map +1 -1
- package/dist/core/ids.d.ts.map +1 -1
- package/dist/core/ids.js +1 -3
- package/dist/core/ids.js.map +1 -1
- package/dist/core/index.d.ts +12 -12
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +10 -10
- package/dist/core/index.js.map +1 -1
- package/dist/core/money.d.ts.map +1 -1
- package/dist/core/money.js +1 -2
- package/dist/core/money.js.map +1 -1
- package/dist/core/plan-change-attempt.d.ts.map +1 -1
- package/dist/core/plan-change-attempt.js +4 -1
- package/dist/core/plan-change-attempt.js.map +1 -1
- package/dist/core/projection-diff.d.ts.map +1 -1
- package/dist/core/projection-diff.js.map +1 -1
- package/dist/core/reconciliation-job.d.ts.map +1 -1
- package/dist/core/reconciliation-job.js.map +1 -1
- package/dist/core/subscription.d.ts.map +1 -1
- package/dist/core/subscription.js +1 -2
- package/dist/core/subscription.js.map +1 -1
- package/dist/core/transitions.d.ts +1 -1
- package/dist/core/transitions.d.ts.map +1 -1
- package/dist/core/transitions.js +4 -23
- package/dist/core/transitions.js.map +1 -1
- package/dist/core/webhook-inbox.d.ts.map +1 -1
- package/dist/core/webhook-inbox.js.map +1 -1
- package/dist/maintenance/health.d.ts +1 -1
- package/dist/maintenance/health.d.ts.map +1 -1
- package/dist/maintenance/health.js.map +1 -1
- package/dist/maintenance/retry.d.ts.map +1 -1
- package/dist/maintenance/retry.js.map +1 -1
- package/dist/operations/index.d.ts +1 -1
- package/dist/operations/index.d.ts.map +1 -1
- package/dist/operations/index.js +1 -1
- package/dist/operations/index.js.map +1 -1
- package/dist/persistence/memory.d.ts +2 -0
- package/dist/persistence/memory.d.ts.map +1 -1
- package/dist/persistence/memory.js +41 -39
- package/dist/persistence/memory.js.map +1 -1
- package/dist/persistence/store.d.ts +2 -0
- package/dist/persistence/store.d.ts.map +1 -1
- package/dist/ports/audit.d.ts.map +1 -1
- package/dist/ports/audit.js.map +1 -1
- package/dist/ports/authorization.d.ts.map +1 -1
- package/dist/ports/authorization.js.map +1 -1
- package/dist/ports/lifecycle.d.ts.map +1 -1
- package/dist/ports/sensitive-values.d.ts.map +1 -1
- package/dist/ports/sensitive-values.js.map +1 -1
- package/dist/ports/telemetry.d.ts.map +1 -1
- package/dist/ports/telemetry.js.map +1 -1
- package/dist/providers/contract.d.ts +7 -0
- package/dist/providers/contract.d.ts.map +1 -1
- package/dist/providers/dodo/index.d.ts +3 -2
- package/dist/providers/dodo/index.d.ts.map +1 -1
- package/dist/providers/dodo/index.js +31 -18
- package/dist/providers/dodo/index.js.map +1 -1
- package/dist/providers/dodo/normalize.d.ts.map +1 -1
- package/dist/providers/dodo/normalize.js +2 -5
- package/dist/providers/dodo/normalize.js.map +1 -1
- package/dist/providers/fake/index.d.ts +3 -1
- package/dist/providers/fake/index.d.ts.map +1 -1
- package/dist/providers/fake/index.js +47 -6
- package/dist/providers/fake/index.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/registry.d.ts.map +1 -1
- package/dist/providers/registry.js.map +1 -1
- package/dist/schema/canonical.d.ts.map +1 -1
- package/dist/schema/canonical.js.map +1 -1
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +1 -4
- package/dist/schema/generator.js.map +1 -1
- package/dist/testing/clocks.d.ts +1 -1
- package/dist/testing/clocks.d.ts.map +1 -1
- package/dist/testing/clocks.js +1 -1
- package/dist/testing/clocks.js.map +1 -1
- package/dist/testing/consumer-conformance.js.map +1 -1
- package/dist/testing/fixtures.d.ts +1 -1
- package/dist/testing/fixtures.d.ts.map +1 -1
- package/dist/testing/fixtures.js.map +1 -1
- package/dist/testing/index.d.ts +5 -5
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +5 -5
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/provider-contract.d.ts.map +1 -1
- package/dist/testing/provider-contract.js +15 -19
- package/dist/testing/provider-contract.js.map +1 -1
- package/dist/testing/state-machine.d.ts.map +1 -1
- package/dist/testing/state-machine.js.map +1 -1
- package/dist/testing/workflow-harness.d.ts +2 -2
- package/dist/testing/workflow-harness.d.ts.map +1 -1
- package/dist/testing/workflow-harness.js +1 -1
- package/dist/testing/workflow-harness.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -7
- package/dist/workflows/engine.d.ts.map +1 -1
- package/dist/workflows/engine.js +110 -77
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +6 -6
- package/dist/workflows/index.d.ts.map +1 -1
- package/dist/workflows/index.js +2 -2
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/lifecycle.d.ts.map +1 -1
- package/dist/workflows/lifecycle.js.map +1 -1
- package/docs/architecture.md +5 -5
- package/docs/runbooks.md +2 -2
- package/package.json +28 -27
package/dist/workflows/engine.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { checkoutIsAvailable, toPublicCatalog
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|