@codelitdev/billing 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +339 -0
- package/dist/adapters/drizzle/index.d.ts +28 -0
- package/dist/adapters/drizzle/index.d.ts.map +1 -0
- package/dist/adapters/drizzle/index.js +75 -0
- package/dist/adapters/drizzle/index.js.map +1 -0
- package/dist/adapters/drizzle/store.d.ts +37 -0
- package/dist/adapters/drizzle/store.d.ts.map +1 -0
- package/dist/adapters/drizzle/store.js +889 -0
- package/dist/adapters/drizzle/store.js.map +1 -0
- package/dist/catalog/index.d.ts +5 -0
- package/dist/catalog/index.d.ts.map +1 -0
- package/dist/catalog/index.js +4 -0
- package/dist/catalog/index.js.map +1 -0
- package/dist/catalog/public-view.d.ts +7 -0
- package/dist/catalog/public-view.d.ts.map +1 -0
- package/dist/catalog/public-view.js +22 -0
- package/dist/catalog/public-view.js.map +1 -0
- package/dist/catalog/revisions.d.ts +8 -0
- package/dist/catalog/revisions.d.ts.map +1 -0
- package/dist/catalog/revisions.js +12 -0
- package/dist/catalog/revisions.js.map +1 -0
- package/dist/catalog/types.d.ts +28 -0
- package/dist/catalog/types.d.ts.map +1 -0
- package/dist/catalog/types.js +2 -0
- package/dist/catalog/types.js.map +1 -0
- package/dist/catalog/validate.d.ts +16 -0
- package/dist/catalog/validate.d.ts.map +1 -0
- package/dist/catalog/validate.js +66 -0
- package/dist/catalog/validate.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +35 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/generate.d.ts +15 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +68 -0
- package/dist/cli/generate.js.map +1 -0
- package/dist/config/define.d.ts +3 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/define.js +5 -0
- package/dist/config/define.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +3 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/validate.d.ts +32 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +227 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/core/checkout-attempt.d.ts +29 -0
- package/dist/core/checkout-attempt.d.ts.map +1 -0
- package/dist/core/checkout-attempt.js +10 -0
- package/dist/core/checkout-attempt.js.map +1 -0
- package/dist/core/clock.d.ts +10 -0
- package/dist/core/clock.d.ts.map +1 -0
- package/dist/core/clock.js +26 -0
- package/dist/core/clock.js.map +1 -0
- package/dist/core/customer.d.ts +13 -0
- package/dist/core/customer.d.ts.map +1 -0
- package/dist/core/customer.js +2 -0
- package/dist/core/customer.js.map +1 -0
- package/dist/core/errors.d.ts +28 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +43 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/ids.d.ts +15 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +29 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +14 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/money.d.ts +8 -0
- package/dist/core/money.d.ts.map +1 -0
- package/dist/core/money.js +23 -0
- package/dist/core/money.js.map +1 -0
- package/dist/core/plan-change-attempt.d.ts +30 -0
- package/dist/core/plan-change-attempt.d.ts.map +1 -0
- package/dist/core/plan-change-attempt.js +9 -0
- package/dist/core/plan-change-attempt.js.map +1 -0
- package/dist/core/projection-diff.d.ts +32 -0
- package/dist/core/projection-diff.d.ts.map +1 -0
- package/dist/core/projection-diff.js +32 -0
- package/dist/core/projection-diff.js.map +1 -0
- package/dist/core/reconciliation-job.d.ts +21 -0
- package/dist/core/reconciliation-job.d.ts.map +1 -0
- package/dist/core/reconciliation-job.js +26 -0
- package/dist/core/reconciliation-job.js.map +1 -0
- package/dist/core/subscription.d.ts +62 -0
- package/dist/core/subscription.d.ts.map +1 -0
- package/dist/core/subscription.js +14 -0
- package/dist/core/subscription.js.map +1 -0
- package/dist/core/transitions.d.ts +25 -0
- package/dist/core/transitions.d.ts.map +1 -0
- package/dist/core/transitions.js +161 -0
- package/dist/core/transitions.js.map +1 -0
- package/dist/core/webhook-inbox.d.ts +23 -0
- package/dist/core/webhook-inbox.d.ts.map +1 -0
- package/dist/core/webhook-inbox.js +9 -0
- package/dist/core/webhook-inbox.js.map +1 -0
- package/dist/maintenance/batches.d.ts +3 -0
- package/dist/maintenance/batches.d.ts.map +1 -0
- package/dist/maintenance/batches.js +3 -0
- package/dist/maintenance/batches.js.map +1 -0
- package/dist/maintenance/claims.d.ts +2 -0
- package/dist/maintenance/claims.d.ts.map +1 -0
- package/dist/maintenance/claims.js +2 -0
- package/dist/maintenance/claims.js.map +1 -0
- package/dist/maintenance/health.d.ts +13 -0
- package/dist/maintenance/health.d.ts.map +1 -0
- package/dist/maintenance/health.js +19 -0
- package/dist/maintenance/health.js.map +1 -0
- package/dist/maintenance/retry.d.ts +9 -0
- package/dist/maintenance/retry.d.ts.map +1 -0
- package/dist/maintenance/retry.js +19 -0
- package/dist/maintenance/retry.js.map +1 -0
- package/dist/operations/index.d.ts +28 -0
- package/dist/operations/index.d.ts.map +1 -0
- package/dist/operations/index.js +106 -0
- package/dist/operations/index.js.map +1 -0
- package/dist/persistence/memory.d.ts +104 -0
- package/dist/persistence/memory.d.ts.map +1 -0
- package/dist/persistence/memory.js +657 -0
- package/dist/persistence/memory.js.map +1 -0
- package/dist/persistence/store.d.ts +126 -0
- package/dist/persistence/store.d.ts.map +1 -0
- package/dist/persistence/store.js +2 -0
- package/dist/persistence/store.js.map +1 -0
- package/dist/ports/audit.d.ts +20 -0
- package/dist/ports/audit.d.ts.map +1 -0
- package/dist/ports/audit.js +9 -0
- package/dist/ports/audit.js.map +1 -0
- package/dist/ports/authorization.d.ts +28 -0
- package/dist/ports/authorization.d.ts.map +1 -0
- package/dist/ports/authorization.js +33 -0
- package/dist/ports/authorization.js.map +1 -0
- package/dist/ports/lifecycle.d.ts +23 -0
- package/dist/ports/lifecycle.d.ts.map +1 -0
- package/dist/ports/lifecycle.js +2 -0
- package/dist/ports/lifecycle.js.map +1 -0
- package/dist/ports/sensitive-values.d.ts +23 -0
- package/dist/ports/sensitive-values.d.ts.map +1 -0
- package/dist/ports/sensitive-values.js +19 -0
- package/dist/ports/sensitive-values.js.map +1 -0
- package/dist/ports/telemetry.d.ts +16 -0
- package/dist/ports/telemetry.d.ts.map +1 -0
- package/dist/ports/telemetry.js +48 -0
- package/dist/ports/telemetry.js.map +1 -0
- package/dist/providers/contract.d.ts +98 -0
- package/dist/providers/contract.d.ts.map +1 -0
- package/dist/providers/contract.js +2 -0
- package/dist/providers/contract.js.map +1 -0
- package/dist/providers/dodo/index.d.ts +25 -0
- package/dist/providers/dodo/index.d.ts.map +1 -0
- package/dist/providers/dodo/index.js +296 -0
- package/dist/providers/dodo/index.js.map +1 -0
- package/dist/providers/dodo/normalize.d.ts +7 -0
- package/dist/providers/dodo/normalize.d.ts.map +1 -0
- package/dist/providers/dodo/normalize.js +77 -0
- package/dist/providers/dodo/normalize.js.map +1 -0
- package/dist/providers/fake/index.d.ts +59 -0
- package/dist/providers/fake/index.d.ts.map +1 -0
- package/dist/providers/fake/index.js +312 -0
- package/dist/providers/fake/index.js.map +1 -0
- package/dist/providers/index.d.ts +4 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +3 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/registry.d.ts +10 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +32 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/schema/canonical.d.ts +7 -0
- package/dist/schema/canonical.d.ts.map +1 -0
- package/dist/schema/canonical.js +35 -0
- package/dist/schema/canonical.js.map +1 -0
- package/dist/schema/generator.d.ts +3 -0
- package/dist/schema/generator.d.ts.map +1 -0
- package/dist/schema/generator.js +496 -0
- package/dist/schema/generator.js.map +1 -0
- package/dist/schema/pglite-sql.d.ts +3 -0
- package/dist/schema/pglite-sql.d.ts.map +1 -0
- package/dist/schema/pglite-sql.js +226 -0
- package/dist/schema/pglite-sql.js.map +1 -0
- package/dist/testing/clocks.d.ts +2 -0
- package/dist/testing/clocks.d.ts.map +1 -0
- package/dist/testing/clocks.js +2 -0
- package/dist/testing/clocks.js.map +1 -0
- package/dist/testing/consumer-conformance.d.ts +12 -0
- package/dist/testing/consumer-conformance.d.ts.map +1 -0
- package/dist/testing/consumer-conformance.js +17 -0
- package/dist/testing/consumer-conformance.js.map +1 -0
- package/dist/testing/fixtures.d.ts +9 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +69 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/provider-contract.d.ts +14 -0
- package/dist/testing/provider-contract.d.ts.map +1 -0
- package/dist/testing/provider-contract.js +238 -0
- package/dist/testing/provider-contract.js.map +1 -0
- package/dist/testing/state-machine.d.ts +2 -0
- package/dist/testing/state-machine.d.ts.map +1 -0
- package/dist/testing/state-machine.js +2 -0
- package/dist/testing/state-machine.js.map +1 -0
- package/dist/testing/workflow-harness.d.ts +224 -0
- package/dist/testing/workflow-harness.d.ts.map +1 -0
- package/dist/testing/workflow-harness.js +65 -0
- package/dist/testing/workflow-harness.js.map +1 -0
- package/dist/workflows/engine.d.ts +157 -0
- package/dist/workflows/engine.d.ts.map +1 -0
- package/dist/workflows/engine.js +2015 -0
- package/dist/workflows/engine.js.map +1 -0
- package/dist/workflows/index.d.ts +10 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +6 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/lifecycle.d.ts +5 -0
- package/dist/workflows/lifecycle.d.ts.map +1 -0
- package/dist/workflows/lifecycle.js +7 -0
- package/dist/workflows/lifecycle.js.map +1 -0
- package/docs/architecture.md +1188 -0
- package/docs/decisions/0001-extraction-discrepancies.md +29 -0
- package/docs/runbooks.md +140 -0
- package/package.json +110 -0
|
@@ -0,0 +1,2015 @@
|
|
|
1
|
+
import { checkoutIsAvailable, toPublicCatalog, } from "../catalog/public-view.js";
|
|
2
|
+
import { catalogMatchesProviderSnapshot, validateCatalog, } from "../catalog/validate.js";
|
|
3
|
+
import { canActivateRevision } from "../catalog/revisions.js";
|
|
4
|
+
import { BillingProviderError, BillingCompositionError, BillingWorkflowError, providerErrorSummary, } from "../core/errors.js";
|
|
5
|
+
import { retainsPaidEntitlement } from "../core/subscription.js";
|
|
6
|
+
import { decideCheckoutTransition, decideCustomerTransition, decidePlanChangeTransition, decideSubscriptionTransition, decideWebhookInboxTransition, } from "../core/transitions.js";
|
|
7
|
+
import { noopTelemetry, safeTelemetry, } from "../ports/telemetry.js";
|
|
8
|
+
import { applySubscriptionProjection, MemoryBillingStore, newId, } from "../persistence/memory.js";
|
|
9
|
+
import { BillingProviderRegistry } from "../providers/registry.js";
|
|
10
|
+
import { billingWebhookRetry, DEFAULT_WEBHOOK_MAX_ATTEMPTS, } from "../maintenance/retry.js";
|
|
11
|
+
const LEASE_MS = 5 * 60 * 1000;
|
|
12
|
+
export function createBilling(options) {
|
|
13
|
+
if (options.mode === "cloud" && !options.hooks?.audit) {
|
|
14
|
+
throw new BillingCompositionError("audit_hook_required");
|
|
15
|
+
}
|
|
16
|
+
if (options.webhookRetrieveCurrent === false) {
|
|
17
|
+
throw new BillingCompositionError("webhook_current_retrieval_required");
|
|
18
|
+
}
|
|
19
|
+
if (options.mode === "cloud" &&
|
|
20
|
+
typeof options.returnUrlValidator !== "function") {
|
|
21
|
+
throw new BillingCompositionError("return_url_validator_required");
|
|
22
|
+
}
|
|
23
|
+
const store = options.database ?? new MemoryBillingStore();
|
|
24
|
+
const registry = options.providers instanceof BillingProviderRegistry
|
|
25
|
+
? options.providers
|
|
26
|
+
: new BillingProviderRegistry(options.providers);
|
|
27
|
+
if (options.mode === "oss" &&
|
|
28
|
+
(registry.list().length > 0 ||
|
|
29
|
+
Boolean(options.checkoutProvider) ||
|
|
30
|
+
options.requestedRevision !== null ||
|
|
31
|
+
Boolean(options.offers?.length) ||
|
|
32
|
+
(Array.isArray(options.requiredOfferKeys) &&
|
|
33
|
+
options.requiredOfferKeys.length > 0))) {
|
|
34
|
+
throw new BillingCompositionError("oss_billing_configuration_forbidden");
|
|
35
|
+
}
|
|
36
|
+
if (options.mode === "cloud" &&
|
|
37
|
+
(!options.checkoutProvider ||
|
|
38
|
+
options.requestedRevision === null ||
|
|
39
|
+
!Number.isSafeInteger(options.requestedRevision) ||
|
|
40
|
+
options.requestedRevision <= 0 ||
|
|
41
|
+
!Array.isArray(options.requiredOfferKeys) ||
|
|
42
|
+
options.requiredOfferKeys.length === 0 ||
|
|
43
|
+
!Array.isArray(options.offers) ||
|
|
44
|
+
new Set(options.requiredOfferKeys).size !==
|
|
45
|
+
options.requiredOfferKeys.length ||
|
|
46
|
+
options.requiredOfferKeys.some((key) => !isBoundedOpaqueId(key) ||
|
|
47
|
+
!/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(key)) ||
|
|
48
|
+
!registry.has(options.checkoutProvider))) {
|
|
49
|
+
throw new BillingCompositionError("cloud_billing_configuration_incomplete");
|
|
50
|
+
}
|
|
51
|
+
const requestedOffers = options.mode === "cloud"
|
|
52
|
+
? validateCatalog({
|
|
53
|
+
offers: options.offers,
|
|
54
|
+
requiredOfferKeys: options.requiredOfferKeys,
|
|
55
|
+
revision: options.requestedRevision,
|
|
56
|
+
checkoutProvider: options.checkoutProvider,
|
|
57
|
+
})
|
|
58
|
+
: [];
|
|
59
|
+
const telemetry = safeTelemetry(options.telemetry ?? noopTelemetry);
|
|
60
|
+
const audit = options.hooks?.audit;
|
|
61
|
+
const lifecycle = options.hooks?.lifecycle;
|
|
62
|
+
const clock = options.clock;
|
|
63
|
+
function provider(name) {
|
|
64
|
+
return registry.get(name);
|
|
65
|
+
}
|
|
66
|
+
async function callProvider(fn) {
|
|
67
|
+
store.noteProviderCall();
|
|
68
|
+
return fn();
|
|
69
|
+
}
|
|
70
|
+
async function publicCatalog() {
|
|
71
|
+
if (options.mode === "oss")
|
|
72
|
+
return null;
|
|
73
|
+
return publicCatalogFor(options.checkoutProvider);
|
|
74
|
+
}
|
|
75
|
+
async function publicCatalogFor(providerName) {
|
|
76
|
+
if (options.mode === "oss")
|
|
77
|
+
return null;
|
|
78
|
+
const active = await store.getActiveCatalog(providerName);
|
|
79
|
+
if (!active)
|
|
80
|
+
return null;
|
|
81
|
+
if (!catalogShapeIsComplete(active, options.requiredOfferKeys)) {
|
|
82
|
+
return toPublicCatalog(active.revision.revision, [], false);
|
|
83
|
+
}
|
|
84
|
+
const offers = active.items.map((item) => ({
|
|
85
|
+
key: item.price.offerKey,
|
|
86
|
+
revision: active.revision.revision,
|
|
87
|
+
plan: item.price.plan,
|
|
88
|
+
interval: item.price.interval,
|
|
89
|
+
currency: item.price.currency,
|
|
90
|
+
amountMinor: item.price.amountMinor,
|
|
91
|
+
providerTrialDays: item.price.providerTrialDays,
|
|
92
|
+
provider: item.price.provider,
|
|
93
|
+
providerProductId: item.price.providerProductId,
|
|
94
|
+
}));
|
|
95
|
+
return toPublicCatalog(active.revision.revision, offers, checkoutIsAvailable({
|
|
96
|
+
requestedRevision: providerName === options.checkoutProvider
|
|
97
|
+
? options.requestedRevision
|
|
98
|
+
: null,
|
|
99
|
+
activeRevision: active.revision.revision,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
/** Idempotently records configuration without contacting the provider. */
|
|
103
|
+
async function recordRequestedCatalog() {
|
|
104
|
+
if (options.mode === "oss")
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
return await store.withTransaction(async () => {
|
|
108
|
+
const existing = await store.getCatalogRevision(options.checkoutProvider, options.requestedRevision);
|
|
109
|
+
if (existing) {
|
|
110
|
+
if (existing.items.length > 0 &&
|
|
111
|
+
!catalogMatchesDeclaredOffers(existing, requestedOffers)) {
|
|
112
|
+
throw new BillingCompositionError("catalog_revision_reused_with_different_offers");
|
|
113
|
+
}
|
|
114
|
+
return existing.revision;
|
|
115
|
+
}
|
|
116
|
+
const revisions = await store.listRevisions();
|
|
117
|
+
const newer = revisions.find((revision) => revision.revision > options.requestedRevision);
|
|
118
|
+
if (newer) {
|
|
119
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
120
|
+
details: { currentRevision: newer.revision },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const requested = {
|
|
124
|
+
id: newId(),
|
|
125
|
+
revision: options.requestedRevision,
|
|
126
|
+
checkoutProvider: options.checkoutProvider,
|
|
127
|
+
status: "pending_verification",
|
|
128
|
+
verifiedAt: null,
|
|
129
|
+
activatedAt: null,
|
|
130
|
+
retiredAt: null,
|
|
131
|
+
};
|
|
132
|
+
await store.insertCatalogRevision(requested);
|
|
133
|
+
return requested;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
const raced = await store.getCatalogRevision(options.checkoutProvider, options.requestedRevision);
|
|
138
|
+
if (raced &&
|
|
139
|
+
!(error instanceof BillingCompositionError) &&
|
|
140
|
+
!(error instanceof BillingWorkflowError)) {
|
|
141
|
+
if (raced.items.length > 0 &&
|
|
142
|
+
!catalogMatchesDeclaredOffers(raced, requestedOffers)) {
|
|
143
|
+
throw new BillingCompositionError("catalog_revision_reused_with_different_offers");
|
|
144
|
+
}
|
|
145
|
+
return raced.revision;
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Explicit maintenance check; ordinary reads never contact a provider. */
|
|
151
|
+
async function verifyRequestedCatalog() {
|
|
152
|
+
if (options.mode === "oss") {
|
|
153
|
+
return { verified: true, revision: null, mismatches: [] };
|
|
154
|
+
}
|
|
155
|
+
const requested = await recordRequestedCatalog();
|
|
156
|
+
if (!requested || requested.status === "abandoned") {
|
|
157
|
+
return {
|
|
158
|
+
verified: false,
|
|
159
|
+
revision: requested?.revision ?? null,
|
|
160
|
+
mismatches: ["revision_abandoned"],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (requested.status === "invalid") {
|
|
164
|
+
return {
|
|
165
|
+
verified: false,
|
|
166
|
+
revision: requested.revision,
|
|
167
|
+
mismatches: ["revision_invalid"],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const mismatches = [];
|
|
171
|
+
let providerUnavailable = false;
|
|
172
|
+
const adapter = provider(options.checkoutProvider);
|
|
173
|
+
for (const offer of requestedOffers) {
|
|
174
|
+
try {
|
|
175
|
+
const remote = await callProvider(() => adapter.retrieveProduct(offer.providerProductId));
|
|
176
|
+
if (!catalogMatchesProviderSnapshot(offer, remote)) {
|
|
177
|
+
mismatches.push(offer.key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
providerUnavailable = true;
|
|
182
|
+
mismatches.push(offer.key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (mismatches.length > 0) {
|
|
186
|
+
if (!providerUnavailable) {
|
|
187
|
+
await store.withTransaction(async () => {
|
|
188
|
+
const latest = await store.getCatalogRevision(options.checkoutProvider, options.requestedRevision);
|
|
189
|
+
if (latest?.revision.status === "pending_verification") {
|
|
190
|
+
latest.revision.status = "invalid";
|
|
191
|
+
await store.saveCatalogRevision(latest.revision);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
verified: false,
|
|
197
|
+
revision: requested.revision,
|
|
198
|
+
mismatches,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const activated = await activateVerifiedCatalog();
|
|
202
|
+
return {
|
|
203
|
+
verified: activated,
|
|
204
|
+
revision: requested.revision,
|
|
205
|
+
mismatches: activated ? [] : ["revision_superseded"],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async function activateVerifiedCatalog() {
|
|
209
|
+
return store.withTransaction(async () => {
|
|
210
|
+
const candidate = await store.getCatalogRevision(options.checkoutProvider, options.requestedRevision);
|
|
211
|
+
if (!candidate) {
|
|
212
|
+
throw new BillingWorkflowError("catalog_unavailable");
|
|
213
|
+
}
|
|
214
|
+
if (candidate.revision.status === "active") {
|
|
215
|
+
if (!catalogMatchesDeclaredOffers(candidate, requestedOffers)) {
|
|
216
|
+
throw new BillingCompositionError("catalog_revision_reused_with_different_offers");
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
const active = await store.getActiveCatalog(options.checkoutProvider);
|
|
221
|
+
if (!canActivateRevision({
|
|
222
|
+
requested: candidate.revision.revision,
|
|
223
|
+
currentActive: active?.revision.revision ?? null,
|
|
224
|
+
requestedStatus: candidate.revision.status,
|
|
225
|
+
})) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const now = clock.now();
|
|
229
|
+
for (const offer of requestedOffers) {
|
|
230
|
+
const existingItem = candidate.items.find((item) => item.offerKey === offer.key);
|
|
231
|
+
if (existingItem) {
|
|
232
|
+
if (!priceMatchesDeclaredOffer(existingItem.price, offer)) {
|
|
233
|
+
candidate.revision.status = "invalid";
|
|
234
|
+
await store.saveCatalogRevision(candidate.revision);
|
|
235
|
+
throw new BillingCompositionError("catalog_revision_reused_with_different_offers");
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
let price = await store.findPriceByProviderProduct(offer.provider, offer.providerProductId);
|
|
240
|
+
if (price && !priceMatchesDeclaredOffer(price, offer)) {
|
|
241
|
+
candidate.revision.status = "invalid";
|
|
242
|
+
await store.saveCatalogRevision(candidate.revision);
|
|
243
|
+
throw new BillingCompositionError("provider_product_reused_with_different_price");
|
|
244
|
+
}
|
|
245
|
+
if (!price) {
|
|
246
|
+
price = {
|
|
247
|
+
id: newId(),
|
|
248
|
+
offerKey: offer.key,
|
|
249
|
+
plan: offer.plan,
|
|
250
|
+
interval: offer.interval,
|
|
251
|
+
currency: offer.currency,
|
|
252
|
+
amountMinor: offer.amountMinor,
|
|
253
|
+
providerTrialDays: offer.providerTrialDays,
|
|
254
|
+
provider: offer.provider,
|
|
255
|
+
providerProductId: offer.providerProductId,
|
|
256
|
+
verifiedAt: now,
|
|
257
|
+
};
|
|
258
|
+
await store.insertPriceEntry(price);
|
|
259
|
+
}
|
|
260
|
+
await store.insertCatalogRevisionItem({
|
|
261
|
+
id: newId(),
|
|
262
|
+
revisionId: candidate.revision.id,
|
|
263
|
+
offerKey: offer.key,
|
|
264
|
+
priceEntryId: price.id,
|
|
265
|
+
});
|
|
266
|
+
candidate.items.push({ offerKey: offer.key, price });
|
|
267
|
+
}
|
|
268
|
+
if (!catalogMatchesDeclaredOffers(candidate, requestedOffers)) {
|
|
269
|
+
candidate.revision.status = "invalid";
|
|
270
|
+
await store.saveCatalogRevision(candidate.revision);
|
|
271
|
+
throw new BillingCompositionError("catalog_revision_reused_with_different_offers");
|
|
272
|
+
}
|
|
273
|
+
if (active && active.revision.id !== candidate.revision.id) {
|
|
274
|
+
active.revision.status = "retired";
|
|
275
|
+
active.revision.retiredAt = now;
|
|
276
|
+
await store.saveCatalogRevision(active.revision);
|
|
277
|
+
}
|
|
278
|
+
candidate.revision.status = "active";
|
|
279
|
+
candidate.revision.verifiedAt = now;
|
|
280
|
+
candidate.revision.activatedAt = now;
|
|
281
|
+
candidate.revision.retiredAt = null;
|
|
282
|
+
await store.saveCatalogRevision(candidate.revision);
|
|
283
|
+
await requireAudit({
|
|
284
|
+
effectId: `catalog:${candidate.revision.revision}:activated`,
|
|
285
|
+
actor: { kind: "system", id: "catalog_verifier" },
|
|
286
|
+
previous: active
|
|
287
|
+
? { revision: active.revision.revision, status: "active" }
|
|
288
|
+
: null,
|
|
289
|
+
next: {
|
|
290
|
+
revision: candidate.revision.revision,
|
|
291
|
+
status: "active",
|
|
292
|
+
},
|
|
293
|
+
correlationIds: {
|
|
294
|
+
catalogRevisionId: candidate.revision.id,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
return true;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function consumeGrant(grant, action, target, payerId) {
|
|
301
|
+
if (!grant)
|
|
302
|
+
throw new BillingWorkflowError("grant_invalid");
|
|
303
|
+
if (grant.actorId !== payerId ||
|
|
304
|
+
grant.target.kind !== target.kind ||
|
|
305
|
+
grant.target.id !== target.id) {
|
|
306
|
+
throw new BillingWorkflowError("grant_invalid");
|
|
307
|
+
}
|
|
308
|
+
await options.authorization.consume(grant, action, target, clock.now());
|
|
309
|
+
}
|
|
310
|
+
async function requireAudit(record) {
|
|
311
|
+
if (!audit)
|
|
312
|
+
return;
|
|
313
|
+
await audit.record(record);
|
|
314
|
+
}
|
|
315
|
+
async function resolveCustomer(input) {
|
|
316
|
+
let customer;
|
|
317
|
+
try {
|
|
318
|
+
customer = await store.withTransaction(async () => {
|
|
319
|
+
const existing = await store.findCustomerByPayer(input.providerName, input.payer.id);
|
|
320
|
+
if (existing) {
|
|
321
|
+
existing.payerEmail = input.payer.email;
|
|
322
|
+
await store.saveCustomer(existing);
|
|
323
|
+
return existing;
|
|
324
|
+
}
|
|
325
|
+
const created = {
|
|
326
|
+
id: newId(),
|
|
327
|
+
provider: input.providerName,
|
|
328
|
+
payerId: input.payer.id,
|
|
329
|
+
payerEmail: input.payer.email,
|
|
330
|
+
providerCustomerId: null,
|
|
331
|
+
idempotencyKey: `customer:${input.providerName}:${input.payer.id}`,
|
|
332
|
+
status: "creating",
|
|
333
|
+
lastError: null,
|
|
334
|
+
};
|
|
335
|
+
await store.insertCustomer(created);
|
|
336
|
+
return created;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
const raced = await store.findCustomerByPayer(input.providerName, input.payer.id);
|
|
341
|
+
if (!raced)
|
|
342
|
+
throw error;
|
|
343
|
+
customer = raced;
|
|
344
|
+
}
|
|
345
|
+
if (customer.status === "active" && customer.providerCustomerId) {
|
|
346
|
+
return customer;
|
|
347
|
+
}
|
|
348
|
+
if (customer.status === "conflicted" ||
|
|
349
|
+
(customer.status === "active" && !customer.providerCustomerId)) {
|
|
350
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const remote = await callProvider(() => provider(input.providerName).createCustomer({
|
|
354
|
+
email: input.payer.email,
|
|
355
|
+
name: input.payer.name,
|
|
356
|
+
idempotencyKey: customer.idempotencyKey,
|
|
357
|
+
}));
|
|
358
|
+
return await store.withTransaction(async () => {
|
|
359
|
+
const latest = (await store.findCustomerById(customer.id)) ?? customer;
|
|
360
|
+
if (latest.provider !== customer.provider ||
|
|
361
|
+
latest.payerId !== customer.payerId) {
|
|
362
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
363
|
+
}
|
|
364
|
+
const decision = decideCustomerTransition(latest.status, "active");
|
|
365
|
+
if (!decision.allowed) {
|
|
366
|
+
latest.status = "conflicted";
|
|
367
|
+
await store.saveCustomer(latest);
|
|
368
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
369
|
+
}
|
|
370
|
+
if (latest.payerId !== input.payer.id) {
|
|
371
|
+
throw new BillingWorkflowError("payer_mismatch");
|
|
372
|
+
}
|
|
373
|
+
latest.providerCustomerId = remote.providerCustomerId;
|
|
374
|
+
latest.status = "active";
|
|
375
|
+
await store.saveCustomer(latest);
|
|
376
|
+
await requireAudit({
|
|
377
|
+
effectId: `customer:${latest.id}:active`,
|
|
378
|
+
actor: { kind: "user", id: input.payer.id },
|
|
379
|
+
previous: { status: customer.status },
|
|
380
|
+
next: { status: "active" },
|
|
381
|
+
correlationIds: { customerId: latest.id },
|
|
382
|
+
});
|
|
383
|
+
return latest;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
await enqueueJob({
|
|
388
|
+
provider: input.providerName,
|
|
389
|
+
providerCustomerId: customer.id,
|
|
390
|
+
});
|
|
391
|
+
if (error instanceof BillingWorkflowError)
|
|
392
|
+
throw error;
|
|
393
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
394
|
+
retryable: true,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function enqueueJobInTransaction(subject) {
|
|
399
|
+
const subjectCount = [
|
|
400
|
+
subject.checkoutAttemptId,
|
|
401
|
+
subject.planChangeAttemptId,
|
|
402
|
+
subject.subscriptionId,
|
|
403
|
+
subject.providerCustomerId,
|
|
404
|
+
].filter(Boolean).length;
|
|
405
|
+
if (!isBoundedOpaqueId(subject.provider) ||
|
|
406
|
+
subjectCount !== 1 ||
|
|
407
|
+
(subject.operation !== undefined &&
|
|
408
|
+
subject.operation !== "reconcile" &&
|
|
409
|
+
subject.operation !== "cancellation") ||
|
|
410
|
+
(subject.operation === "cancellation" && !subject.subscriptionId)) {
|
|
411
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
412
|
+
}
|
|
413
|
+
const live = await store.findLiveJob(subject);
|
|
414
|
+
if (live) {
|
|
415
|
+
if (subject.operation === "cancellation" &&
|
|
416
|
+
live.operation !== "cancellation") {
|
|
417
|
+
live.operation = "cancellation";
|
|
418
|
+
await store.saveJob(live);
|
|
419
|
+
}
|
|
420
|
+
return live;
|
|
421
|
+
}
|
|
422
|
+
const job = {
|
|
423
|
+
id: newId(),
|
|
424
|
+
provider: subject.provider,
|
|
425
|
+
checkoutAttemptId: subject.checkoutAttemptId ?? null,
|
|
426
|
+
planChangeAttemptId: subject.planChangeAttemptId ?? null,
|
|
427
|
+
subscriptionId: subject.subscriptionId ?? null,
|
|
428
|
+
providerCustomerId: subject.providerCustomerId ?? null,
|
|
429
|
+
status: "pending",
|
|
430
|
+
attemptCount: 0,
|
|
431
|
+
availableAt: clock.now(),
|
|
432
|
+
lockedAt: null,
|
|
433
|
+
leaseExpiresAt: null,
|
|
434
|
+
workerId: null,
|
|
435
|
+
lastError: null,
|
|
436
|
+
operation: subject.operation ?? "reconcile",
|
|
437
|
+
};
|
|
438
|
+
await store.insertJob(job);
|
|
439
|
+
return job;
|
|
440
|
+
}
|
|
441
|
+
async function enqueueJob(subject) {
|
|
442
|
+
if (store.isInTransaction?.() ?? store.transactionDepth > 0) {
|
|
443
|
+
return enqueueJobInTransaction(subject);
|
|
444
|
+
}
|
|
445
|
+
return store.withTransaction(() => enqueueJobInTransaction(subject));
|
|
446
|
+
}
|
|
447
|
+
async function startCheckout(input) {
|
|
448
|
+
if (options.mode === "oss") {
|
|
449
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
450
|
+
}
|
|
451
|
+
validateReturnUrl(input.returnUrl, options.returnUrlValidator);
|
|
452
|
+
await consumeGrant(input.grant, "checkout", {
|
|
453
|
+
kind: input.entity.kind,
|
|
454
|
+
id: input.entity.id,
|
|
455
|
+
}, input.payer.id);
|
|
456
|
+
const catalog = await store.getActiveCatalog(options.checkoutProvider);
|
|
457
|
+
const pub = await publicCatalog();
|
|
458
|
+
if (!catalog || !pub) {
|
|
459
|
+
throw new BillingWorkflowError("catalog_unavailable");
|
|
460
|
+
}
|
|
461
|
+
if (!catalogShapeIsComplete(catalog, options.requiredOfferKeys)) {
|
|
462
|
+
throw new BillingWorkflowError("catalog_unavailable");
|
|
463
|
+
}
|
|
464
|
+
if (input.catalogRevision !== catalog.revision.revision ||
|
|
465
|
+
!checkoutIsAvailable({
|
|
466
|
+
requestedRevision: options.requestedRevision,
|
|
467
|
+
activeRevision: catalog.revision.revision,
|
|
468
|
+
})) {
|
|
469
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
470
|
+
details: { publicCatalog: pub },
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
const item = catalog.items.find((row) => row.offerKey === input.offerKey);
|
|
474
|
+
if (!item)
|
|
475
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
476
|
+
details: { publicCatalog: pub },
|
|
477
|
+
});
|
|
478
|
+
const existingSub = await store.findEntitlementSubscription(input.entity.id);
|
|
479
|
+
if (existingSub && retainsPaidEntitlement(existingSub, clock.now())) {
|
|
480
|
+
throw new BillingWorkflowError("active_subscription_exists");
|
|
481
|
+
}
|
|
482
|
+
const liveCheckout = await store.findLiveCheckout(input.entity.id);
|
|
483
|
+
let resumeAttempt;
|
|
484
|
+
if (liveCheckout &&
|
|
485
|
+
liveCheckout.expiresAt.getTime() <= clock.now().getTime()) {
|
|
486
|
+
liveCheckout.status = "expired";
|
|
487
|
+
liveCheckout.completedAt = clock.now();
|
|
488
|
+
liveCheckout.checkoutUrlEncrypted = null;
|
|
489
|
+
await store.saveCheckout(liveCheckout);
|
|
490
|
+
}
|
|
491
|
+
else if (liveCheckout) {
|
|
492
|
+
if (liveCheckout.payerId !== input.payer.id ||
|
|
493
|
+
liveCheckout.offerKey !== input.offerKey ||
|
|
494
|
+
liveCheckout.catalogRevision !== catalog.revision.revision) {
|
|
495
|
+
throw new BillingWorkflowError("checkout_pending");
|
|
496
|
+
}
|
|
497
|
+
if (liveCheckout.status === "open" &&
|
|
498
|
+
liveCheckout.checkoutUrlEncrypted &&
|
|
499
|
+
options.sensitiveValues) {
|
|
500
|
+
const checkoutUrl = await options.sensitiveValues.decrypt(liveCheckout.checkoutUrlEncrypted, {
|
|
501
|
+
operatorActorId: input.payer.id,
|
|
502
|
+
reason: "resume_checkout",
|
|
503
|
+
});
|
|
504
|
+
return { attempt: liveCheckout, checkoutUrl };
|
|
505
|
+
}
|
|
506
|
+
resumeAttempt = liveCheckout;
|
|
507
|
+
}
|
|
508
|
+
const adapter = provider(options.checkoutProvider);
|
|
509
|
+
if (adapter.capabilities.mutationRecovery.createCustomer ===
|
|
510
|
+
"unsupported" ||
|
|
511
|
+
adapter.capabilities.mutationRecovery.createCheckout ===
|
|
512
|
+
"unsupported") {
|
|
513
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
514
|
+
}
|
|
515
|
+
let providerProduct;
|
|
516
|
+
try {
|
|
517
|
+
providerProduct = await callProvider(() => adapter.retrieveProduct(item.price.providerProductId));
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error instanceof BillingProviderError &&
|
|
521
|
+
error.code === "invalid") {
|
|
522
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
523
|
+
details: { publicCatalog: pub },
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
527
|
+
retryable: true,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
if (!catalogMatchesProviderSnapshot(item.price, providerProduct)) {
|
|
531
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
532
|
+
details: { publicCatalog: pub },
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
if (liveCheckout?.status === "open" &&
|
|
536
|
+
!liveCheckout.checkoutUrlEncrypted) {
|
|
537
|
+
let customer;
|
|
538
|
+
try {
|
|
539
|
+
customer = await resolveCustomer({
|
|
540
|
+
payer: input.payer,
|
|
541
|
+
providerName: options.checkoutProvider,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
await enqueueJob({
|
|
546
|
+
provider: options.checkoutProvider,
|
|
547
|
+
checkoutAttemptId: liveCheckout.id,
|
|
548
|
+
});
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const remote = await callProvider(() => adapter.createCheckout({
|
|
553
|
+
productId: item.price.providerProductId,
|
|
554
|
+
currency: item.price.currency,
|
|
555
|
+
customerId: customer.providerCustomerId,
|
|
556
|
+
payerEmail: input.payer.email,
|
|
557
|
+
returnUrl: liveCheckout.returnUrl,
|
|
558
|
+
attemptId: liveCheckout.attemptId,
|
|
559
|
+
catalogKey: liveCheckout.offerKey,
|
|
560
|
+
trialDays: input.trialDays ?? 0,
|
|
561
|
+
idempotencyKey: liveCheckout.idempotencyKey,
|
|
562
|
+
}));
|
|
563
|
+
if (options.sensitiveValues) {
|
|
564
|
+
const encrypted = await options.sensitiveValues.encrypt(remote.checkoutUrl);
|
|
565
|
+
liveCheckout.checkoutUrlEncrypted = encrypted.ciphertext;
|
|
566
|
+
liveCheckout.providerCheckoutSessionId =
|
|
567
|
+
remote.providerCheckoutSessionId;
|
|
568
|
+
liveCheckout.providerCustomerRowId = customer.id;
|
|
569
|
+
await store.saveCheckout(liveCheckout);
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
attempt: liveCheckout,
|
|
573
|
+
checkoutUrl: remote.checkoutUrl,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
await enqueueJob({
|
|
578
|
+
provider: options.checkoutProvider,
|
|
579
|
+
checkoutAttemptId: liveCheckout.id,
|
|
580
|
+
});
|
|
581
|
+
if (error instanceof BillingWorkflowError)
|
|
582
|
+
throw error;
|
|
583
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
584
|
+
retryable: true,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
let attempt;
|
|
589
|
+
if (resumeAttempt) {
|
|
590
|
+
attempt = resumeAttempt;
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
try {
|
|
594
|
+
attempt = await store.withTransaction(async () => {
|
|
595
|
+
const row = {
|
|
596
|
+
id: newId(),
|
|
597
|
+
attemptId: store.newAttemptId(),
|
|
598
|
+
billableEntityId: input.entity.id,
|
|
599
|
+
payerId: input.payer.id,
|
|
600
|
+
payerEmail: input.payer.email,
|
|
601
|
+
returnUrl: input.returnUrl,
|
|
602
|
+
provider: options.checkoutProvider,
|
|
603
|
+
catalogRevision: catalog.revision.revision,
|
|
604
|
+
offerKey: item.offerKey,
|
|
605
|
+
plan: item.price.plan,
|
|
606
|
+
interval: item.price.interval,
|
|
607
|
+
priceEntryId: item.price.id,
|
|
608
|
+
quotedAmountMinor: item.price.amountMinor,
|
|
609
|
+
quotedCurrency: item.price.currency,
|
|
610
|
+
providerCustomerRowId: null,
|
|
611
|
+
providerCheckoutSessionId: null,
|
|
612
|
+
checkoutUrlEncrypted: null,
|
|
613
|
+
idempotencyKey: `checkout:${input.entity.id}:${input.offerKey}:${catalog.revision.revision}`,
|
|
614
|
+
status: "creating",
|
|
615
|
+
expiresAt: new Date(clock.now().getTime() + 60 * 60 * 1000),
|
|
616
|
+
lastError: null,
|
|
617
|
+
completedAt: null,
|
|
618
|
+
applicationFields: input.applicationFields,
|
|
619
|
+
};
|
|
620
|
+
await store.insertCheckout(row);
|
|
621
|
+
await store.ensurePlanState(input.entity.id);
|
|
622
|
+
return row;
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
const raced = await store.findLiveCheckout(input.entity.id);
|
|
627
|
+
if (!raced ||
|
|
628
|
+
raced.payerId !== input.payer.id ||
|
|
629
|
+
raced.offerKey !== input.offerKey ||
|
|
630
|
+
raced.catalogRevision !== catalog.revision.revision) {
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
attempt = raced;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
let customer;
|
|
637
|
+
try {
|
|
638
|
+
customer = await resolveCustomer({
|
|
639
|
+
payer: input.payer,
|
|
640
|
+
providerName: options.checkoutProvider,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
await enqueueJob({
|
|
645
|
+
provider: options.checkoutProvider,
|
|
646
|
+
checkoutAttemptId: attempt.id,
|
|
647
|
+
});
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const remote = await callProvider(() => adapter.createCheckout({
|
|
652
|
+
productId: item.price.providerProductId,
|
|
653
|
+
currency: item.price.currency,
|
|
654
|
+
customerId: customer.providerCustomerId,
|
|
655
|
+
payerEmail: input.payer.email,
|
|
656
|
+
returnUrl: input.returnUrl,
|
|
657
|
+
attemptId: attempt.attemptId,
|
|
658
|
+
catalogKey: item.offerKey,
|
|
659
|
+
trialDays: input.trialDays ?? 0,
|
|
660
|
+
idempotencyKey: attempt.idempotencyKey,
|
|
661
|
+
}));
|
|
662
|
+
const encrypted = options.sensitiveValues
|
|
663
|
+
? await options.sensitiveValues.encrypt(remote.checkoutUrl)
|
|
664
|
+
: null;
|
|
665
|
+
const opened = await store.withTransaction(async () => {
|
|
666
|
+
const latest = (await store.findCheckoutById(attempt.id)) ?? attempt;
|
|
667
|
+
if (latest.payerId !== input.payer.id) {
|
|
668
|
+
throw new BillingWorkflowError("payer_mismatch");
|
|
669
|
+
}
|
|
670
|
+
const decision = decideCheckoutTransition(latest.status, "open", {
|
|
671
|
+
now: clock.now(),
|
|
672
|
+
expiresAt: latest.expiresAt,
|
|
673
|
+
});
|
|
674
|
+
if (!decision.allowed) {
|
|
675
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
676
|
+
}
|
|
677
|
+
latest.status = "open";
|
|
678
|
+
latest.providerCustomerRowId = customer.id;
|
|
679
|
+
latest.providerCheckoutSessionId =
|
|
680
|
+
remote.providerCheckoutSessionId;
|
|
681
|
+
latest.checkoutUrlEncrypted = encrypted?.ciphertext ?? null;
|
|
682
|
+
await store.saveCheckout(latest);
|
|
683
|
+
await requireAudit({
|
|
684
|
+
effectId: `checkout:${latest.id}:open`,
|
|
685
|
+
actor: { kind: "user", id: input.payer.id },
|
|
686
|
+
previous: { status: "creating" },
|
|
687
|
+
next: { status: "open" },
|
|
688
|
+
correlationIds: { attemptId: latest.attemptId },
|
|
689
|
+
});
|
|
690
|
+
telemetry.event("checkout.open", {
|
|
691
|
+
attemptId: latest.attemptId,
|
|
692
|
+
});
|
|
693
|
+
const opened = {
|
|
694
|
+
attempt: latest,
|
|
695
|
+
checkoutUrl: remote.checkoutUrl,
|
|
696
|
+
};
|
|
697
|
+
await lifecycle?.afterCheckoutOpen?.(opened);
|
|
698
|
+
return opened;
|
|
699
|
+
});
|
|
700
|
+
return opened;
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
await enqueueJob({
|
|
704
|
+
provider: options.checkoutProvider,
|
|
705
|
+
checkoutAttemptId: attempt.id,
|
|
706
|
+
});
|
|
707
|
+
if (error instanceof BillingWorkflowError)
|
|
708
|
+
throw error;
|
|
709
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
710
|
+
retryable: true,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
async function startPortal(input) {
|
|
715
|
+
if (options.mode === "oss") {
|
|
716
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
717
|
+
}
|
|
718
|
+
validateReturnUrl(input.returnUrl, options.returnUrlValidator);
|
|
719
|
+
await consumeGrant(input.grant, "portal", {
|
|
720
|
+
kind: input.entity.kind,
|
|
721
|
+
id: input.entity.id,
|
|
722
|
+
}, input.payer.id);
|
|
723
|
+
const sub = await store.findEntitlementSubscription(input.entity.id);
|
|
724
|
+
if (!sub)
|
|
725
|
+
throw new BillingWorkflowError("subscription_required");
|
|
726
|
+
if (sub.payerId !== input.payer.id) {
|
|
727
|
+
throw new BillingWorkflowError("payer_mismatch");
|
|
728
|
+
}
|
|
729
|
+
const customer = (await store.findCustomerById(sub.providerCustomerId)) ??
|
|
730
|
+
(await store.findCustomerByPayer(sub.provider, sub.payerId));
|
|
731
|
+
if (!customer ||
|
|
732
|
+
customer.provider !== sub.provider ||
|
|
733
|
+
customer.payerId !== sub.payerId ||
|
|
734
|
+
!customer.providerCustomerId) {
|
|
735
|
+
throw new BillingWorkflowError("subscription_required");
|
|
736
|
+
}
|
|
737
|
+
const session = await callProvider(() => provider(sub.provider).createPortalSession({
|
|
738
|
+
customerId: customer.providerCustomerId,
|
|
739
|
+
returnUrl: input.returnUrl,
|
|
740
|
+
})).catch((error) => {
|
|
741
|
+
if (error instanceof BillingWorkflowError)
|
|
742
|
+
throw error;
|
|
743
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
744
|
+
retryable: true,
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
return session;
|
|
748
|
+
}
|
|
749
|
+
async function startPlanChange(input) {
|
|
750
|
+
if (options.mode === "oss") {
|
|
751
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
752
|
+
}
|
|
753
|
+
await consumeGrant(input.grant, "plan_change", {
|
|
754
|
+
kind: input.entity.kind,
|
|
755
|
+
id: input.entity.id,
|
|
756
|
+
}, input.payer.id);
|
|
757
|
+
const sub = await store.findEntitlementSubscription(input.entity.id);
|
|
758
|
+
if (!sub)
|
|
759
|
+
throw new BillingWorkflowError("subscription_required");
|
|
760
|
+
if (sub.payerId !== input.payer.id) {
|
|
761
|
+
throw new BillingWorkflowError("payer_mismatch");
|
|
762
|
+
}
|
|
763
|
+
if (["cancelled", "expired"].includes(sub.status)) {
|
|
764
|
+
throw new BillingWorkflowError("subscription_not_changeable");
|
|
765
|
+
}
|
|
766
|
+
const catalog = await store.getActiveCatalog(sub.provider);
|
|
767
|
+
if (!catalog ||
|
|
768
|
+
!catalogShapeIsComplete(catalog, options.requiredOfferKeys) ||
|
|
769
|
+
input.catalogRevision !== catalog.revision.revision) {
|
|
770
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
771
|
+
details: {
|
|
772
|
+
publicCatalog: await publicCatalogFor(sub.provider),
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
const target = catalog.items.find((row) => row.offerKey === input.offerKey);
|
|
777
|
+
if (!target)
|
|
778
|
+
throw new BillingWorkflowError("catalog_changed");
|
|
779
|
+
if (sub.offerKey === input.offerKey) {
|
|
780
|
+
throw new BillingWorkflowError("same_offer");
|
|
781
|
+
}
|
|
782
|
+
const adapter = provider(sub.provider);
|
|
783
|
+
if (!adapter.capabilities.planChanges) {
|
|
784
|
+
throw new BillingWorkflowError("plan_change_not_supported");
|
|
785
|
+
}
|
|
786
|
+
if (adapter.capabilities.mutationRecovery.planChange === "unsupported") {
|
|
787
|
+
throw new BillingWorkflowError("plan_change_not_supported");
|
|
788
|
+
}
|
|
789
|
+
if (target.price.interval !== sub.interval &&
|
|
790
|
+
!adapter.capabilities.intervalChanges) {
|
|
791
|
+
throw new BillingWorkflowError("plan_change_not_supported");
|
|
792
|
+
}
|
|
793
|
+
if (input.prorationMode === "prorated_immediately" &&
|
|
794
|
+
!adapter.capabilities.proratedPlanChanges) {
|
|
795
|
+
throw new BillingWorkflowError("plan_change_not_supported");
|
|
796
|
+
}
|
|
797
|
+
let providerProduct;
|
|
798
|
+
try {
|
|
799
|
+
providerProduct = await callProvider(() => adapter.retrieveProduct(target.price.providerProductId));
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
if (error instanceof BillingProviderError &&
|
|
803
|
+
error.code === "invalid") {
|
|
804
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
805
|
+
details: {
|
|
806
|
+
publicCatalog: await publicCatalogFor(sub.provider),
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
811
|
+
retryable: true,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
if (!catalogMatchesProviderSnapshot(target.price, providerProduct)) {
|
|
815
|
+
throw new BillingWorkflowError("catalog_changed", {
|
|
816
|
+
details: {
|
|
817
|
+
publicCatalog: await publicCatalogFor(sub.provider),
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
const livePlanChange = await store.findLivePlanChange(input.entity.id);
|
|
822
|
+
if (livePlanChange) {
|
|
823
|
+
if (livePlanChange.subscriptionId === sub.id &&
|
|
824
|
+
livePlanChange.payerId === input.payer.id &&
|
|
825
|
+
livePlanChange.targetOfferKey === input.offerKey &&
|
|
826
|
+
livePlanChange.targetCatalogRevision ===
|
|
827
|
+
catalog.revision.revision &&
|
|
828
|
+
livePlanChange.effectiveAt === input.effectiveAt &&
|
|
829
|
+
livePlanChange.prorationMode === input.prorationMode) {
|
|
830
|
+
return livePlanChange;
|
|
831
|
+
}
|
|
832
|
+
throw new BillingWorkflowError("plan_change_pending");
|
|
833
|
+
}
|
|
834
|
+
let attempt;
|
|
835
|
+
try {
|
|
836
|
+
attempt = await store.withTransaction(async () => {
|
|
837
|
+
const row = {
|
|
838
|
+
id: newId(),
|
|
839
|
+
changeId: store.newChangeId(),
|
|
840
|
+
billableEntityId: input.entity.id,
|
|
841
|
+
subscriptionId: sub.id,
|
|
842
|
+
actorId: input.payer.id,
|
|
843
|
+
payerId: input.payer.id,
|
|
844
|
+
provider: sub.provider,
|
|
845
|
+
idempotencyKey: `plan-change:${sub.id}:${input.offerKey}:${catalog.revision.revision}`,
|
|
846
|
+
currentCatalogRevision: sub.catalogRevision,
|
|
847
|
+
currentPriceEntryId: sub.priceEntryId,
|
|
848
|
+
currentPlan: sub.plan,
|
|
849
|
+
currentInterval: sub.interval,
|
|
850
|
+
targetCatalogRevision: catalog.revision.revision,
|
|
851
|
+
targetPriceEntryId: target.price.id,
|
|
852
|
+
targetPlan: target.price.plan,
|
|
853
|
+
targetInterval: target.price.interval,
|
|
854
|
+
targetOfferKey: target.offerKey,
|
|
855
|
+
effectiveAt: input.effectiveAt,
|
|
856
|
+
prorationMode: input.prorationMode,
|
|
857
|
+
status: "creating",
|
|
858
|
+
lastError: null,
|
|
859
|
+
};
|
|
860
|
+
await store.insertPlanChange(row);
|
|
861
|
+
return row;
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
const raced = await store.findLivePlanChange(input.entity.id);
|
|
866
|
+
if (!raced ||
|
|
867
|
+
raced.subscriptionId !== sub.id ||
|
|
868
|
+
raced.payerId !== input.payer.id ||
|
|
869
|
+
raced.targetOfferKey !== input.offerKey) {
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
attempt = raced;
|
|
873
|
+
}
|
|
874
|
+
try {
|
|
875
|
+
const remote = await callProvider(() => adapter.changeSubscriptionPlan({
|
|
876
|
+
providerSubscriptionId: sub.providerSubscriptionId,
|
|
877
|
+
targetProviderProductId: target.price.providerProductId,
|
|
878
|
+
effectiveAt: input.effectiveAt,
|
|
879
|
+
prorationMode: input.prorationMode,
|
|
880
|
+
idempotencyKey: attempt.idempotencyKey,
|
|
881
|
+
}));
|
|
882
|
+
const paymentUrl = remote.paymentUrl && options.sensitiveValues
|
|
883
|
+
? await options.sensitiveValues.encrypt(remote.paymentUrl)
|
|
884
|
+
: null;
|
|
885
|
+
return await store.withTransaction(async () => {
|
|
886
|
+
const latest = (await store.findPlanChangeById(attempt.id)) ?? attempt;
|
|
887
|
+
const decision = decidePlanChangeTransition(latest.status, "pending");
|
|
888
|
+
if (!decision.allowed)
|
|
889
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
890
|
+
latest.status = "pending";
|
|
891
|
+
latest.providerPaymentId = remote.providerPaymentId;
|
|
892
|
+
latest.paymentUrlEncrypted = paymentUrl?.ciphertext ?? null;
|
|
893
|
+
await store.savePlanChange(latest);
|
|
894
|
+
await requireAudit({
|
|
895
|
+
effectId: `plan_change:${latest.id}:pending`,
|
|
896
|
+
actor: { kind: "user", id: input.payer.id },
|
|
897
|
+
previous: { status: "creating" },
|
|
898
|
+
next: { status: "pending" },
|
|
899
|
+
correlationIds: { changeId: latest.changeId },
|
|
900
|
+
});
|
|
901
|
+
return latest;
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
await enqueueJob({
|
|
906
|
+
provider: sub.provider,
|
|
907
|
+
planChangeAttemptId: attempt.id,
|
|
908
|
+
});
|
|
909
|
+
if (error instanceof BillingWorkflowError)
|
|
910
|
+
throw error;
|
|
911
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
912
|
+
retryable: true,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async function cancel(input) {
|
|
917
|
+
if (options.mode === "oss") {
|
|
918
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
919
|
+
}
|
|
920
|
+
await consumeGrant(input.grant, "cancellation", {
|
|
921
|
+
kind: input.entity.kind,
|
|
922
|
+
id: input.entity.id,
|
|
923
|
+
}, input.payer.id);
|
|
924
|
+
const source = await store.findEntitlementSubscription(input.entity.id);
|
|
925
|
+
const sub = source ??
|
|
926
|
+
(await store.listSubscriptionsByEntity(input.entity.id)).find((row) => row.payerId === input.payer.id &&
|
|
927
|
+
row.status === "cancelled");
|
|
928
|
+
if (!sub)
|
|
929
|
+
throw new BillingWorkflowError("subscription_required");
|
|
930
|
+
if (sub.payerId !== input.payer.id) {
|
|
931
|
+
throw new BillingWorkflowError("payer_mismatch");
|
|
932
|
+
}
|
|
933
|
+
if (sub.status === "cancelled") {
|
|
934
|
+
return { accepted: true, subscriptionId: sub.id };
|
|
935
|
+
}
|
|
936
|
+
if (provider(sub.provider).capabilities.mutationRecovery
|
|
937
|
+
.cancellation === "unsupported") {
|
|
938
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
939
|
+
}
|
|
940
|
+
await store.withTransaction(async () => {
|
|
941
|
+
await requireAudit({
|
|
942
|
+
effectId: `cancellation:${sub.id}:requested`,
|
|
943
|
+
actor: { kind: "user", id: input.payer.id },
|
|
944
|
+
previous: { status: sub.status },
|
|
945
|
+
next: { requested: "provider_cancel" },
|
|
946
|
+
correlationIds: { subscriptionId: sub.id },
|
|
947
|
+
});
|
|
948
|
+
await enqueueJobInTransaction({
|
|
949
|
+
provider: sub.provider,
|
|
950
|
+
subscriptionId: sub.id,
|
|
951
|
+
operation: "cancellation",
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
try {
|
|
955
|
+
await callProvider(() => provider(sub.provider).cancelSubscription(sub.providerSubscriptionId, `cancel:${sub.id}`));
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
if (error instanceof BillingWorkflowError)
|
|
959
|
+
throw error;
|
|
960
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
961
|
+
retryable: true,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
return { accepted: true, subscriptionId: sub.id };
|
|
965
|
+
}
|
|
966
|
+
async function ingestWebhook(input) {
|
|
967
|
+
if (!isBoundedOpaqueId(input.provider) ||
|
|
968
|
+
typeof input.raw?.body !== "string" ||
|
|
969
|
+
input.raw.body.length > 1_000_000 ||
|
|
970
|
+
!input.raw.headers ||
|
|
971
|
+
typeof input.raw.headers !== "object") {
|
|
972
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
973
|
+
}
|
|
974
|
+
const envelope = await provider(input.provider).parseWebhook(input.raw);
|
|
975
|
+
if (envelope.provider !== input.provider ||
|
|
976
|
+
!isBoundedOpaqueId(envelope.providerEventId) ||
|
|
977
|
+
!isBoundedOpaqueId(envelope.eventType) ||
|
|
978
|
+
(envelope.subscriptionId !== null &&
|
|
979
|
+
!isBoundedOpaqueId(envelope.subscriptionId)) ||
|
|
980
|
+
(envelope.verifiedKeyVersion !== null &&
|
|
981
|
+
!isBoundedOpaqueId(envelope.verifiedKeyVersion)) ||
|
|
982
|
+
(envelope.correlationMetadata.checkoutAttemptId !== undefined &&
|
|
983
|
+
!isBoundedOpaqueId(envelope.correlationMetadata.checkoutAttemptId))) {
|
|
984
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
985
|
+
}
|
|
986
|
+
const existing = await store.findWebhook(envelope.provider, envelope.providerEventId);
|
|
987
|
+
if (existing) {
|
|
988
|
+
telemetry.event("webhook.duplicate", {
|
|
989
|
+
providerEventId: envelope.providerEventId,
|
|
990
|
+
});
|
|
991
|
+
return { duplicate: true, envelope };
|
|
992
|
+
}
|
|
993
|
+
let encryptedPayload = null;
|
|
994
|
+
if (options.sensitiveValues) {
|
|
995
|
+
try {
|
|
996
|
+
encryptedPayload = await options.sensitiveValues.encrypt(input.raw.body);
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
throw new BillingWorkflowError("composition_invalid");
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const record = {
|
|
1003
|
+
id: newId(),
|
|
1004
|
+
provider: envelope.provider,
|
|
1005
|
+
providerEventId: envelope.providerEventId,
|
|
1006
|
+
eventType: envelope.eventType,
|
|
1007
|
+
occurredAt: envelope.occurredAt,
|
|
1008
|
+
subscriptionId: envelope.subscriptionId,
|
|
1009
|
+
checkoutAttemptId: envelope.correlationMetadata.checkoutAttemptId ?? null,
|
|
1010
|
+
payloadEncrypted: encryptedPayload?.ciphertext ?? null,
|
|
1011
|
+
payloadKeyVersion: encryptedPayload?.keyVersion ?? null,
|
|
1012
|
+
verifiedKeyVersion: envelope.verifiedKeyVersion,
|
|
1013
|
+
status: "pending",
|
|
1014
|
+
processingAttempts: 0,
|
|
1015
|
+
lastError: null,
|
|
1016
|
+
availableAt: clock.now(),
|
|
1017
|
+
lockedAt: null,
|
|
1018
|
+
leaseExpiresAt: null,
|
|
1019
|
+
workerId: null,
|
|
1020
|
+
};
|
|
1021
|
+
try {
|
|
1022
|
+
await store.withTransaction(async () => store.insertWebhook(record));
|
|
1023
|
+
return { duplicate: false, envelope };
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
const raced = await store.findWebhook(envelope.provider, envelope.providerEventId);
|
|
1027
|
+
if (raced) {
|
|
1028
|
+
telemetry.event("webhook.duplicate", {
|
|
1029
|
+
providerEventId: envelope.providerEventId,
|
|
1030
|
+
});
|
|
1031
|
+
return { duplicate: true, envelope };
|
|
1032
|
+
}
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
async function projectSnapshot(snapshot, correlation, reconcile = false) {
|
|
1037
|
+
return store.withTransaction(() => projectSnapshotInTransaction(snapshot, correlation, reconcile));
|
|
1038
|
+
}
|
|
1039
|
+
async function projectSnapshotInTransaction(snapshot, correlation, reconcile = false) {
|
|
1040
|
+
if (!isBoundedOpaqueId(snapshot.provider) ||
|
|
1041
|
+
!isBoundedOpaqueId(snapshot.providerSubscriptionId) ||
|
|
1042
|
+
!isBoundedOpaqueId(snapshot.providerCustomerId) ||
|
|
1043
|
+
!isBoundedOpaqueId(snapshot.providerProductId) ||
|
|
1044
|
+
!(snapshot.observedAt instanceof Date) ||
|
|
1045
|
+
Number.isNaN(snapshot.observedAt.getTime()) ||
|
|
1046
|
+
!isCanonicalSubscriptionStatus(snapshot.status) ||
|
|
1047
|
+
typeof snapshot.cancelAtPeriodEnd !== "boolean" ||
|
|
1048
|
+
!isOptionalDate(snapshot.currentPeriodStartsAt) ||
|
|
1049
|
+
!isOptionalDate(snapshot.currentPeriodEndsAt) ||
|
|
1050
|
+
!isOptionalDate(snapshot.paidThroughAt) ||
|
|
1051
|
+
!isOptionalDate(snapshot.trialEndsAt) ||
|
|
1052
|
+
!isOptionalDate(snapshot.providerOccurredAt) ||
|
|
1053
|
+
(snapshot.providerVersion !== null &&
|
|
1054
|
+
!isBoundedOpaqueId(snapshot.providerVersion)) ||
|
|
1055
|
+
(correlation.checkoutAttemptId !== undefined &&
|
|
1056
|
+
!isBoundedOpaqueId(correlation.checkoutAttemptId))) {
|
|
1057
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1058
|
+
}
|
|
1059
|
+
const price = await store.findPriceByProviderProduct(snapshot.provider, snapshot.providerProductId);
|
|
1060
|
+
if (!price) {
|
|
1061
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1062
|
+
}
|
|
1063
|
+
const existing = await store.findSubscriptionByProviderIds(snapshot.provider, snapshot.providerSubscriptionId);
|
|
1064
|
+
const correlatedAttempt = correlation.checkoutAttemptId
|
|
1065
|
+
? await store.findCheckoutByAttemptId(correlation.checkoutAttemptId)
|
|
1066
|
+
: undefined;
|
|
1067
|
+
const originAttempt = !correlatedAttempt && existing?.originCheckoutAttemptId
|
|
1068
|
+
? await store.findCheckoutById(existing.originCheckoutAttemptId)
|
|
1069
|
+
: undefined;
|
|
1070
|
+
const attempt = correlatedAttempt ?? originAttempt;
|
|
1071
|
+
const billableEntityId = existing?.billableEntityId ?? attempt?.billableEntityId;
|
|
1072
|
+
const payerId = existing?.payerId ?? attempt?.payerId;
|
|
1073
|
+
const customer = await store.findCustomerByProviderCustomerId(snapshot.provider, snapshot.providerCustomerId);
|
|
1074
|
+
if (!billableEntityId || !payerId || !customer) {
|
|
1075
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1076
|
+
}
|
|
1077
|
+
if (existing && existing.providerCustomerId !== customer.id) {
|
|
1078
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1079
|
+
}
|
|
1080
|
+
if (correlatedAttempt &&
|
|
1081
|
+
(correlatedAttempt.billableEntityId !== billableEntityId ||
|
|
1082
|
+
correlatedAttempt.payerId !== payerId)) {
|
|
1083
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1084
|
+
}
|
|
1085
|
+
if (customer.payerId !== payerId) {
|
|
1086
|
+
throw new BillingWorkflowError("payer_mismatch");
|
|
1087
|
+
}
|
|
1088
|
+
if (correlatedAttempt &&
|
|
1089
|
+
(correlatedAttempt.provider !== snapshot.provider ||
|
|
1090
|
+
(correlatedAttempt.providerCustomerRowId !== null &&
|
|
1091
|
+
correlatedAttempt.providerCustomerRowId !== customer.id) ||
|
|
1092
|
+
// Origin checkout price applies to first activation only.
|
|
1093
|
+
// Later snapshots keep that checkout id in provider metadata
|
|
1094
|
+
// (and webhook correlation) even after a plan change.
|
|
1095
|
+
(!existing && correlatedAttempt.priceEntryId !== price.id))) {
|
|
1096
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1097
|
+
}
|
|
1098
|
+
if (existing && isOlderProviderSnapshot(existing, snapshot)) {
|
|
1099
|
+
telemetry.event("projection.stale", {
|
|
1100
|
+
subscriptionId: existing.id,
|
|
1101
|
+
});
|
|
1102
|
+
return {
|
|
1103
|
+
subscription: existing,
|
|
1104
|
+
planState: await store.ensurePlanState(existing.billableEntityId),
|
|
1105
|
+
material: false,
|
|
1106
|
+
effectId: null,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
if (existing) {
|
|
1110
|
+
const allowed = decideSubscriptionTransition(existing.status, snapshot.status);
|
|
1111
|
+
if (!allowed.allowed) {
|
|
1112
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const pendingChange = existing
|
|
1116
|
+
? await store.findPlanChangeBySubscriptionOffer(existing.id, price.offerKey)
|
|
1117
|
+
: undefined;
|
|
1118
|
+
if (existing &&
|
|
1119
|
+
snapshot.providerProductId !== existing.providerProductId &&
|
|
1120
|
+
!pendingChange) {
|
|
1121
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1122
|
+
}
|
|
1123
|
+
if (pendingChange && pendingChange.targetPriceEntryId !== price.id) {
|
|
1124
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1125
|
+
}
|
|
1126
|
+
const lineageRevision = pendingChange
|
|
1127
|
+
? pendingChange.targetCatalogRevision
|
|
1128
|
+
: (existing?.catalogRevision ?? attempt?.catalogRevision ?? 0);
|
|
1129
|
+
const lineageOffer = pendingChange
|
|
1130
|
+
? pendingChange.targetOfferKey
|
|
1131
|
+
: (existing?.offerKey ?? attempt?.offerKey ?? price.offerKey);
|
|
1132
|
+
const now = clock.now();
|
|
1133
|
+
const paid = retainsPaidEntitlement(snapshot, now);
|
|
1134
|
+
const currentSource = await store.findEntitlementSubscription(billableEntityId);
|
|
1135
|
+
if (paid && currentSource && currentSource.id !== existing?.id) {
|
|
1136
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1137
|
+
}
|
|
1138
|
+
const next = {
|
|
1139
|
+
id: existing?.id ?? newId(),
|
|
1140
|
+
billableEntityId,
|
|
1141
|
+
payerId,
|
|
1142
|
+
provider: snapshot.provider,
|
|
1143
|
+
providerCustomerId: customer.id,
|
|
1144
|
+
providerSubscriptionId: snapshot.providerSubscriptionId,
|
|
1145
|
+
providerProductId: snapshot.providerProductId,
|
|
1146
|
+
catalogRevision: lineageRevision,
|
|
1147
|
+
offerKey: lineageOffer,
|
|
1148
|
+
plan: pendingChange?.targetPlan ??
|
|
1149
|
+
existing?.plan ??
|
|
1150
|
+
attempt?.plan ??
|
|
1151
|
+
price.plan,
|
|
1152
|
+
interval: pendingChange?.targetInterval ??
|
|
1153
|
+
existing?.interval ??
|
|
1154
|
+
attempt?.interval ??
|
|
1155
|
+
price.interval,
|
|
1156
|
+
priceEntryId: pendingChange?.targetPriceEntryId ?? price.id,
|
|
1157
|
+
status: snapshot.status,
|
|
1158
|
+
currentPeriodStartsAt: snapshot.currentPeriodStartsAt,
|
|
1159
|
+
currentPeriodEndsAt: snapshot.currentPeriodEndsAt,
|
|
1160
|
+
paidThroughAt: snapshot.paidThroughAt,
|
|
1161
|
+
trialEndsAt: snapshot.trialEndsAt,
|
|
1162
|
+
cancelAtPeriodEnd: snapshot.cancelAtPeriodEnd,
|
|
1163
|
+
isEntitlementSource: paid,
|
|
1164
|
+
originCheckoutAttemptId: existing?.originCheckoutAttemptId ?? attempt?.id ?? null,
|
|
1165
|
+
providerOccurredAt: snapshot.providerOccurredAt,
|
|
1166
|
+
providerVersion: snapshot.providerVersion,
|
|
1167
|
+
lastObservedAt: now,
|
|
1168
|
+
lastReconciledAt: reconcile
|
|
1169
|
+
? now
|
|
1170
|
+
: (existing?.lastReconciledAt ?? null),
|
|
1171
|
+
};
|
|
1172
|
+
const write = await applySubscriptionProjection(store, next, now, {
|
|
1173
|
+
reconcile,
|
|
1174
|
+
});
|
|
1175
|
+
if (write.material && write.effectId) {
|
|
1176
|
+
await requireAudit({
|
|
1177
|
+
effectId: write.effectId,
|
|
1178
|
+
actor: { kind: "system", id: "billing" },
|
|
1179
|
+
previous: existing ?? null,
|
|
1180
|
+
next: write.subscription,
|
|
1181
|
+
correlationIds: {
|
|
1182
|
+
subscriptionId: write.subscription.id,
|
|
1183
|
+
providerSubscriptionId: snapshot.providerSubscriptionId,
|
|
1184
|
+
},
|
|
1185
|
+
});
|
|
1186
|
+
telemetry.event("projection.material", {
|
|
1187
|
+
subscriptionId: write.subscription.id,
|
|
1188
|
+
projectionVersion: write.planState.projectionVersion,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
else {
|
|
1192
|
+
telemetry.event("projection.equivalent", {
|
|
1193
|
+
subscriptionId: write.subscription.id,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
if (attempt) {
|
|
1197
|
+
if (attempt.status === "abandoned") {
|
|
1198
|
+
attempt.status = "conflicted";
|
|
1199
|
+
}
|
|
1200
|
+
else if (attempt.status === "creating" ||
|
|
1201
|
+
attempt.status === "open" ||
|
|
1202
|
+
attempt.status === "expired") {
|
|
1203
|
+
const decision = decideCheckoutTransition(attempt.status, "completed", {
|
|
1204
|
+
now,
|
|
1205
|
+
expiresAt: attempt.expiresAt,
|
|
1206
|
+
subscriptionObservedAt: snapshot.observedAt,
|
|
1207
|
+
entityUnchanged: attempt.billableEntityId === billableEntityId,
|
|
1208
|
+
payerUnchanged: attempt.payerId === payerId,
|
|
1209
|
+
});
|
|
1210
|
+
if (decision.allowed) {
|
|
1211
|
+
attempt.status = "completed";
|
|
1212
|
+
attempt.completedAt = now;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
await store.saveCheckout(attempt);
|
|
1216
|
+
}
|
|
1217
|
+
if (pendingChange) {
|
|
1218
|
+
const decision = decidePlanChangeTransition(pendingChange.status, "succeeded");
|
|
1219
|
+
if (decision.allowed) {
|
|
1220
|
+
pendingChange.status = "succeeded";
|
|
1221
|
+
pendingChange.completedAt = now;
|
|
1222
|
+
await store.savePlanChange(pendingChange);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
await lifecycle?.afterProjection?.({
|
|
1226
|
+
material: write.material,
|
|
1227
|
+
previous: existing ?? null,
|
|
1228
|
+
next: write.subscription,
|
|
1229
|
+
planState: write.planState,
|
|
1230
|
+
});
|
|
1231
|
+
return write;
|
|
1232
|
+
}
|
|
1233
|
+
async function processWebhookRecord(record, workerId) {
|
|
1234
|
+
const now = clock.now();
|
|
1235
|
+
const alreadyClaimed = record.status === "processing" && record.workerId === workerId;
|
|
1236
|
+
if (!alreadyClaimed) {
|
|
1237
|
+
if (!decideWebhookInboxTransition(record.status, "processing")
|
|
1238
|
+
.allowed) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
record.status = "processing";
|
|
1242
|
+
record.workerId = workerId;
|
|
1243
|
+
record.lockedAt = now;
|
|
1244
|
+
record.leaseExpiresAt = new Date(now.getTime() + LEASE_MS);
|
|
1245
|
+
record.processingAttempts += 1;
|
|
1246
|
+
await store.saveWebhook(record);
|
|
1247
|
+
}
|
|
1248
|
+
try {
|
|
1249
|
+
if (!record.subscriptionId) {
|
|
1250
|
+
record.status = "ignored";
|
|
1251
|
+
record.processedAt = clock.now();
|
|
1252
|
+
if (!(await store.saveWebhookIfOwned(record, workerId)))
|
|
1253
|
+
return;
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const adapter = provider(record.provider);
|
|
1257
|
+
const snapshot = await callProvider(() => adapter.retrieveSubscription(record.subscriptionId));
|
|
1258
|
+
await projectSnapshot(snapshot, {
|
|
1259
|
+
checkoutAttemptId: record.checkoutAttemptId ??
|
|
1260
|
+
snapshot.metadata.checkoutAttemptId ??
|
|
1261
|
+
undefined,
|
|
1262
|
+
});
|
|
1263
|
+
record.status = "processed";
|
|
1264
|
+
record.processedAt = clock.now();
|
|
1265
|
+
await store.saveWebhookIfOwned(record, workerId);
|
|
1266
|
+
}
|
|
1267
|
+
catch (error) {
|
|
1268
|
+
const retry = billingWebhookRetry(record.processingAttempts);
|
|
1269
|
+
record.lastError =
|
|
1270
|
+
error instanceof BillingWorkflowError
|
|
1271
|
+
? error.code
|
|
1272
|
+
: providerErrorSummary(error);
|
|
1273
|
+
if (error instanceof BillingWorkflowError &&
|
|
1274
|
+
error.code === "operation_quarantined") {
|
|
1275
|
+
record.status = "quarantined";
|
|
1276
|
+
}
|
|
1277
|
+
else if (error instanceof BillingCompositionError) {
|
|
1278
|
+
record.lastError = "billing_configuration_error";
|
|
1279
|
+
record.status = "quarantined";
|
|
1280
|
+
}
|
|
1281
|
+
else if (error instanceof BillingProviderError &&
|
|
1282
|
+
["invalid", "unauthorized", "misconfigured"].includes(error.code)) {
|
|
1283
|
+
record.status = "quarantined";
|
|
1284
|
+
}
|
|
1285
|
+
else if (retry.status === "quarantined") {
|
|
1286
|
+
record.status = "quarantined";
|
|
1287
|
+
}
|
|
1288
|
+
else {
|
|
1289
|
+
record.status = "failed";
|
|
1290
|
+
record.availableAt = new Date(clock.now().getTime() + retry.delayMs);
|
|
1291
|
+
}
|
|
1292
|
+
await store.saveWebhookIfOwned(record, workerId);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
async function processWebhookByEventId(providerName, providerEventId, subscriptionId, _checkoutAttemptId) {
|
|
1296
|
+
if (!isBoundedOpaqueId(providerName) ||
|
|
1297
|
+
!isBoundedOpaqueId(providerEventId) ||
|
|
1298
|
+
!isBoundedOpaqueId(subscriptionId)) {
|
|
1299
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1300
|
+
}
|
|
1301
|
+
const record = await store.findWebhook(providerName, providerEventId);
|
|
1302
|
+
if (!record)
|
|
1303
|
+
return;
|
|
1304
|
+
if (record.status === "processed" || record.status === "ignored")
|
|
1305
|
+
return;
|
|
1306
|
+
if (record.subscriptionId !== subscriptionId) {
|
|
1307
|
+
record.status = "quarantined";
|
|
1308
|
+
record.lastError = "subscription_correlation_mismatch";
|
|
1309
|
+
await store.saveWebhook(record);
|
|
1310
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1311
|
+
}
|
|
1312
|
+
const workerId = `event:${providerName}:${providerEventId}`;
|
|
1313
|
+
const claimed = await store.claimDueWebhooks(clock.now(), 1, workerId);
|
|
1314
|
+
const claimedRecord = claimed.find((row) => row.id === record.id);
|
|
1315
|
+
if (!claimedRecord)
|
|
1316
|
+
return;
|
|
1317
|
+
await processWebhookRecord(claimedRecord, workerId);
|
|
1318
|
+
}
|
|
1319
|
+
async function runWebhookInboxBatch(input) {
|
|
1320
|
+
if (!isBoundedOpaqueId(input.workerId)) {
|
|
1321
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
1322
|
+
}
|
|
1323
|
+
const limit = workLimit(input.limit);
|
|
1324
|
+
const due = await store.claimDueWebhooks(clock.now(), limit, input.workerId);
|
|
1325
|
+
for (const row of due) {
|
|
1326
|
+
await processWebhookRecord(row, input.workerId);
|
|
1327
|
+
}
|
|
1328
|
+
return due.length;
|
|
1329
|
+
}
|
|
1330
|
+
async function claimReconciliationJobs(input) {
|
|
1331
|
+
if (!isBoundedOpaqueId(input.workerId)) {
|
|
1332
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
1333
|
+
}
|
|
1334
|
+
const limit = workLimit(input.limit);
|
|
1335
|
+
return store.claimClaimableJobs(clock.now(), limit, input.workerId);
|
|
1336
|
+
}
|
|
1337
|
+
async function runReconciliationBatch(input) {
|
|
1338
|
+
const limit = workLimit(input.limit);
|
|
1339
|
+
const claimed = await claimReconciliationJobs({
|
|
1340
|
+
...input,
|
|
1341
|
+
limit,
|
|
1342
|
+
});
|
|
1343
|
+
for (const job of claimed) {
|
|
1344
|
+
try {
|
|
1345
|
+
await reconcileJob(job);
|
|
1346
|
+
job.status = "completed";
|
|
1347
|
+
}
|
|
1348
|
+
catch (error) {
|
|
1349
|
+
job.lastError =
|
|
1350
|
+
error instanceof BillingWorkflowError
|
|
1351
|
+
? error.code
|
|
1352
|
+
: providerErrorSummary(error);
|
|
1353
|
+
if (error instanceof BillingWorkflowError &&
|
|
1354
|
+
error.code === "operation_quarantined") {
|
|
1355
|
+
job.status = "quarantined";
|
|
1356
|
+
}
|
|
1357
|
+
else if (error instanceof BillingCompositionError) {
|
|
1358
|
+
job.lastError = "billing_configuration_error";
|
|
1359
|
+
job.status = "quarantined";
|
|
1360
|
+
}
|
|
1361
|
+
else if (error instanceof BillingProviderError &&
|
|
1362
|
+
["invalid", "unauthorized", "misconfigured"].includes(error.code)) {
|
|
1363
|
+
job.status = "quarantined";
|
|
1364
|
+
}
|
|
1365
|
+
else if (job.attemptCount >= DEFAULT_WEBHOOK_MAX_ATTEMPTS) {
|
|
1366
|
+
job.status = "quarantined";
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
job.status = "failed";
|
|
1370
|
+
job.availableAt = new Date(clock.now().getTime() + 60_000);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
await store.saveJobIfOwned(job, input.workerId);
|
|
1374
|
+
}
|
|
1375
|
+
return claimed.length;
|
|
1376
|
+
}
|
|
1377
|
+
async function runDeadlineBatch(input = {}) {
|
|
1378
|
+
const limit = workLimit(input.limit);
|
|
1379
|
+
const now = clock.now();
|
|
1380
|
+
return store.withTransaction(async () => {
|
|
1381
|
+
let processed = 0;
|
|
1382
|
+
const checkouts = await store.listExpiredCheckouts(now, limit);
|
|
1383
|
+
for (const attempt of checkouts) {
|
|
1384
|
+
const previousStatus = attempt.status;
|
|
1385
|
+
attempt.status = "expired";
|
|
1386
|
+
attempt.completedAt = now;
|
|
1387
|
+
attempt.checkoutUrlEncrypted = null;
|
|
1388
|
+
await store.saveCheckout(attempt);
|
|
1389
|
+
await requireAudit({
|
|
1390
|
+
effectId: `checkout:${attempt.id}:expired`,
|
|
1391
|
+
actor: { kind: "system", id: "deadline_worker" },
|
|
1392
|
+
previous: { status: previousStatus },
|
|
1393
|
+
next: { status: "expired" },
|
|
1394
|
+
correlationIds: { checkoutAttemptId: attempt.id },
|
|
1395
|
+
});
|
|
1396
|
+
processed += 1;
|
|
1397
|
+
}
|
|
1398
|
+
const remaining = limit - processed;
|
|
1399
|
+
if (remaining <= 0)
|
|
1400
|
+
return processed;
|
|
1401
|
+
const subscriptions = await store.listDueSubscriptionDeadlines(now, remaining);
|
|
1402
|
+
for (const subscription of subscriptions) {
|
|
1403
|
+
const state = await store.ensurePlanState(subscription.billableEntityId);
|
|
1404
|
+
if (state.activeSubscriptionId !== subscription.id) {
|
|
1405
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
1406
|
+
}
|
|
1407
|
+
const previous = { ...subscription };
|
|
1408
|
+
subscription.isEntitlementSource = false;
|
|
1409
|
+
state.activeSubscriptionId = null;
|
|
1410
|
+
state.projectionVersion += 1;
|
|
1411
|
+
await store.upsertSubscription(subscription);
|
|
1412
|
+
await store.savePlanState(state);
|
|
1413
|
+
const effectId = `projection:${subscription.id}:${state.projectionVersion}`;
|
|
1414
|
+
await requireAudit({
|
|
1415
|
+
effectId,
|
|
1416
|
+
actor: { kind: "system", id: "deadline_worker" },
|
|
1417
|
+
previous,
|
|
1418
|
+
next: subscription,
|
|
1419
|
+
correlationIds: { subscriptionId: subscription.id },
|
|
1420
|
+
});
|
|
1421
|
+
await lifecycle?.afterProjection?.({
|
|
1422
|
+
material: true,
|
|
1423
|
+
previous,
|
|
1424
|
+
next: subscription,
|
|
1425
|
+
planState: state,
|
|
1426
|
+
});
|
|
1427
|
+
processed += 1;
|
|
1428
|
+
}
|
|
1429
|
+
return processed;
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
async function purgeExpiredSensitiveValues(input) {
|
|
1433
|
+
if (!(input.before instanceof Date) ||
|
|
1434
|
+
Number.isNaN(input.before.getTime()) ||
|
|
1435
|
+
input.before.getTime() > clock.now().getTime()) {
|
|
1436
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
1437
|
+
}
|
|
1438
|
+
const limit = workLimit(input.limit);
|
|
1439
|
+
return store.withTransaction(async () => {
|
|
1440
|
+
const purged = {
|
|
1441
|
+
checkoutUrls: 0,
|
|
1442
|
+
planChangeUrls: 0,
|
|
1443
|
+
webhookPayloads: 0,
|
|
1444
|
+
};
|
|
1445
|
+
const checkouts = await store.listPurgeableCheckouts(input.before, limit);
|
|
1446
|
+
for (const attempt of checkouts) {
|
|
1447
|
+
attempt.checkoutUrlEncrypted = null;
|
|
1448
|
+
await store.saveCheckout(attempt);
|
|
1449
|
+
await requireAudit({
|
|
1450
|
+
effectId: `purge:checkout-url:${attempt.id}`,
|
|
1451
|
+
actor: { kind: "system", id: "retention_worker" },
|
|
1452
|
+
previous: { retained: true },
|
|
1453
|
+
next: { retained: false },
|
|
1454
|
+
correlationIds: { checkoutAttemptId: attempt.id },
|
|
1455
|
+
});
|
|
1456
|
+
purged.checkoutUrls += 1;
|
|
1457
|
+
}
|
|
1458
|
+
let remaining = limit - purged.checkoutUrls;
|
|
1459
|
+
if (remaining > 0) {
|
|
1460
|
+
const changes = await store.listPurgeablePlanChanges(input.before, remaining);
|
|
1461
|
+
for (const attempt of changes) {
|
|
1462
|
+
attempt.paymentUrlEncrypted = null;
|
|
1463
|
+
await store.savePlanChange(attempt);
|
|
1464
|
+
await requireAudit({
|
|
1465
|
+
effectId: `purge:plan-change-url:${attempt.id}`,
|
|
1466
|
+
actor: { kind: "system", id: "retention_worker" },
|
|
1467
|
+
previous: { retained: true },
|
|
1468
|
+
next: { retained: false },
|
|
1469
|
+
correlationIds: { planChangeAttemptId: attempt.id },
|
|
1470
|
+
});
|
|
1471
|
+
purged.planChangeUrls += 1;
|
|
1472
|
+
}
|
|
1473
|
+
remaining -= changes.length;
|
|
1474
|
+
}
|
|
1475
|
+
if (remaining > 0) {
|
|
1476
|
+
const webhooks = await store.listPurgeableWebhooks(input.before, remaining);
|
|
1477
|
+
for (const record of webhooks) {
|
|
1478
|
+
record.payloadEncrypted = null;
|
|
1479
|
+
record.payloadKeyVersion = null;
|
|
1480
|
+
await store.saveWebhook(record);
|
|
1481
|
+
await requireAudit({
|
|
1482
|
+
effectId: `purge:webhook-payload:${record.id}`,
|
|
1483
|
+
actor: { kind: "system", id: "retention_worker" },
|
|
1484
|
+
previous: { retained: true },
|
|
1485
|
+
next: { retained: false },
|
|
1486
|
+
correlationIds: {
|
|
1487
|
+
providerEventId: record.providerEventId,
|
|
1488
|
+
},
|
|
1489
|
+
});
|
|
1490
|
+
purged.webhookPayloads += 1;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return purged;
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
async function reconcileJob(job) {
|
|
1497
|
+
const adapter = provider(job.provider);
|
|
1498
|
+
if (job.checkoutAttemptId) {
|
|
1499
|
+
const attempt = await store.findCheckoutById(job.checkoutAttemptId);
|
|
1500
|
+
if (!attempt) {
|
|
1501
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1502
|
+
}
|
|
1503
|
+
if (attempt.provider !== job.provider) {
|
|
1504
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1505
|
+
}
|
|
1506
|
+
if (adapter.capabilities.mutationRecovery.createCustomer ===
|
|
1507
|
+
"unsupported" ||
|
|
1508
|
+
adapter.capabilities.mutationRecovery.createCheckout ===
|
|
1509
|
+
"unsupported") {
|
|
1510
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1511
|
+
}
|
|
1512
|
+
if (attempt.providerCheckoutSessionId) {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (attempt.status === "completed" ||
|
|
1516
|
+
attempt.status === "conflicted" ||
|
|
1517
|
+
attempt.status === "expired" ||
|
|
1518
|
+
attempt.status === "abandoned") {
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
let customer = await store.findCustomerByPayer(attempt.provider, attempt.payerId);
|
|
1522
|
+
if (!customer) {
|
|
1523
|
+
customer = {
|
|
1524
|
+
id: newId(),
|
|
1525
|
+
provider: attempt.provider,
|
|
1526
|
+
payerId: attempt.payerId,
|
|
1527
|
+
payerEmail: attempt.payerEmail,
|
|
1528
|
+
providerCustomerId: null,
|
|
1529
|
+
idempotencyKey: `customer:${attempt.provider}:${attempt.payerId}`,
|
|
1530
|
+
status: "creating",
|
|
1531
|
+
lastError: null,
|
|
1532
|
+
};
|
|
1533
|
+
await store.withTransaction(async () => {
|
|
1534
|
+
await store.insertCustomer(customer);
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
const payerEmail = customer.payerEmail || attempt.payerEmail;
|
|
1538
|
+
if (!customer.providerCustomerId) {
|
|
1539
|
+
const remote = await callProvider(() => adapter.createCustomer({
|
|
1540
|
+
email: payerEmail,
|
|
1541
|
+
idempotencyKey: customer.idempotencyKey,
|
|
1542
|
+
}));
|
|
1543
|
+
await store.withTransaction(async () => {
|
|
1544
|
+
const latest = (await store.findCustomerById(customer.id)) ??
|
|
1545
|
+
customer;
|
|
1546
|
+
const decision = decideCustomerTransition(latest.status, "active");
|
|
1547
|
+
if (!decision.allowed) {
|
|
1548
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1549
|
+
}
|
|
1550
|
+
latest.providerCustomerId = remote.providerCustomerId;
|
|
1551
|
+
latest.status = "active";
|
|
1552
|
+
await store.saveCustomer(latest);
|
|
1553
|
+
await requireAudit({
|
|
1554
|
+
effectId: `customer:${latest.id}:active`,
|
|
1555
|
+
actor: { kind: "system", id: "billing" },
|
|
1556
|
+
previous: { status: customer.status },
|
|
1557
|
+
next: { status: "active" },
|
|
1558
|
+
correlationIds: { customerId: latest.id },
|
|
1559
|
+
});
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
const price = await store.findPriceById(attempt.priceEntryId);
|
|
1563
|
+
if (!price) {
|
|
1564
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1565
|
+
}
|
|
1566
|
+
const ready = await store.findCustomerByPayer(attempt.provider, attempt.payerId);
|
|
1567
|
+
const providerCustomerId = ready?.providerCustomerId;
|
|
1568
|
+
if (!providerCustomerId) {
|
|
1569
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
1570
|
+
}
|
|
1571
|
+
const remote = await callProvider(() => adapter.createCheckout({
|
|
1572
|
+
productId: price.providerProductId,
|
|
1573
|
+
currency: price.currency,
|
|
1574
|
+
customerId: providerCustomerId,
|
|
1575
|
+
payerEmail,
|
|
1576
|
+
returnUrl: attempt.returnUrl,
|
|
1577
|
+
attemptId: attempt.attemptId,
|
|
1578
|
+
catalogKey: attempt.offerKey,
|
|
1579
|
+
trialDays: 0,
|
|
1580
|
+
idempotencyKey: attempt.idempotencyKey,
|
|
1581
|
+
}));
|
|
1582
|
+
await store.withTransaction(async () => {
|
|
1583
|
+
const latest = (await store.findCheckoutById(attempt.id)) ?? attempt;
|
|
1584
|
+
if (latest.provider !== attempt.provider ||
|
|
1585
|
+
latest.payerId !== attempt.payerId ||
|
|
1586
|
+
latest.billableEntityId !== attempt.billableEntityId) {
|
|
1587
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1588
|
+
}
|
|
1589
|
+
if (latest.status === "completed" ||
|
|
1590
|
+
latest.status === "conflicted" ||
|
|
1591
|
+
latest.status === "expired" ||
|
|
1592
|
+
latest.status === "abandoned") {
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const wasCreating = latest.status === "creating";
|
|
1596
|
+
latest.providerCheckoutSessionId =
|
|
1597
|
+
remote.providerCheckoutSessionId;
|
|
1598
|
+
if (wasCreating)
|
|
1599
|
+
latest.status = "open";
|
|
1600
|
+
await store.saveCheckout(latest);
|
|
1601
|
+
if (wasCreating) {
|
|
1602
|
+
await requireAudit({
|
|
1603
|
+
effectId: `checkout:${latest.id}:open`,
|
|
1604
|
+
actor: { kind: "system", id: "billing" },
|
|
1605
|
+
previous: { status: "creating" },
|
|
1606
|
+
next: { status: "open" },
|
|
1607
|
+
correlationIds: { attemptId: latest.attemptId },
|
|
1608
|
+
});
|
|
1609
|
+
await lifecycle?.afterCheckoutOpen?.({
|
|
1610
|
+
attempt: latest,
|
|
1611
|
+
checkoutUrl: remote.checkoutUrl,
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (job.subscriptionId) {
|
|
1618
|
+
const sub = await store.findSubscriptionById(job.subscriptionId);
|
|
1619
|
+
if (!sub) {
|
|
1620
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1621
|
+
}
|
|
1622
|
+
if (sub.provider !== job.provider) {
|
|
1623
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1624
|
+
}
|
|
1625
|
+
if (job.operation === "cancellation") {
|
|
1626
|
+
if (adapter.capabilities.mutationRecovery.cancellation ===
|
|
1627
|
+
"unsupported") {
|
|
1628
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1629
|
+
}
|
|
1630
|
+
await callProvider(() => adapter.cancelSubscription(sub.providerSubscriptionId, `cancel:${sub.id}`));
|
|
1631
|
+
}
|
|
1632
|
+
const snapshot = await callProvider(() => adapter.retrieveSubscription(sub.providerSubscriptionId));
|
|
1633
|
+
await projectSnapshot(snapshot, {}, true);
|
|
1634
|
+
}
|
|
1635
|
+
if (job.providerCustomerId) {
|
|
1636
|
+
const customer = await store.findCustomerById(job.providerCustomerId);
|
|
1637
|
+
if (!customer) {
|
|
1638
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1639
|
+
}
|
|
1640
|
+
if (customer.provider !== job.provider) {
|
|
1641
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1642
|
+
}
|
|
1643
|
+
if (customer.status === "active" && customer.providerCustomerId) {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (customer.status === "conflicted" ||
|
|
1647
|
+
(customer.status === "active" && !customer.providerCustomerId)) {
|
|
1648
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1649
|
+
}
|
|
1650
|
+
if (adapter.capabilities.mutationRecovery.createCustomer ===
|
|
1651
|
+
"unsupported") {
|
|
1652
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1653
|
+
}
|
|
1654
|
+
const remote = await callProvider(() => adapter.createCustomer({
|
|
1655
|
+
email: customer.payerEmail,
|
|
1656
|
+
idempotencyKey: customer.idempotencyKey,
|
|
1657
|
+
}));
|
|
1658
|
+
await store.withTransaction(async () => {
|
|
1659
|
+
const latest = (await store.findCustomerById(customer.id)) ?? customer;
|
|
1660
|
+
if (latest.provider !== customer.provider ||
|
|
1661
|
+
latest.payerId !== customer.payerId) {
|
|
1662
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1663
|
+
}
|
|
1664
|
+
const decision = decideCustomerTransition(latest.status, "active");
|
|
1665
|
+
if (!decision.allowed) {
|
|
1666
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1667
|
+
}
|
|
1668
|
+
latest.providerCustomerId = remote.providerCustomerId;
|
|
1669
|
+
latest.status = "active";
|
|
1670
|
+
await store.saveCustomer(latest);
|
|
1671
|
+
await requireAudit({
|
|
1672
|
+
effectId: `customer:${latest.id}:active`,
|
|
1673
|
+
actor: { kind: "system", id: "billing" },
|
|
1674
|
+
previous: { status: customer.status },
|
|
1675
|
+
next: { status: "active" },
|
|
1676
|
+
correlationIds: { customerId: latest.id },
|
|
1677
|
+
});
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
if (job.planChangeAttemptId) {
|
|
1681
|
+
const attempt = await store.findPlanChangeById(job.planChangeAttemptId);
|
|
1682
|
+
if (!attempt) {
|
|
1683
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1684
|
+
}
|
|
1685
|
+
const sub = await store.findSubscriptionById(attempt.subscriptionId);
|
|
1686
|
+
if (!sub) {
|
|
1687
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1688
|
+
}
|
|
1689
|
+
if (sub.provider !== attempt.provider) {
|
|
1690
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1691
|
+
}
|
|
1692
|
+
if (attempt.status === "succeeded" ||
|
|
1693
|
+
attempt.status === "failed" ||
|
|
1694
|
+
attempt.status === "conflicted") {
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const target = await store.findPriceById(attempt.targetPriceEntryId);
|
|
1698
|
+
if (!target) {
|
|
1699
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1700
|
+
}
|
|
1701
|
+
if (target.provider !== attempt.provider) {
|
|
1702
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1703
|
+
}
|
|
1704
|
+
if (adapter.capabilities.mutationRecovery.planChange ===
|
|
1705
|
+
"unsupported") {
|
|
1706
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1707
|
+
}
|
|
1708
|
+
const remote = await callProvider(() => adapter.changeSubscriptionPlan({
|
|
1709
|
+
providerSubscriptionId: sub.providerSubscriptionId,
|
|
1710
|
+
targetProviderProductId: target.providerProductId,
|
|
1711
|
+
effectiveAt: attempt.effectiveAt,
|
|
1712
|
+
prorationMode: attempt.prorationMode,
|
|
1713
|
+
idempotencyKey: attempt.idempotencyKey,
|
|
1714
|
+
}));
|
|
1715
|
+
const encryptedPaymentUrl = remote.paymentUrl && options.sensitiveValues
|
|
1716
|
+
? await options.sensitiveValues.encrypt(remote.paymentUrl)
|
|
1717
|
+
: null;
|
|
1718
|
+
await store.withTransaction(async () => {
|
|
1719
|
+
const latest = (await store.findPlanChangeById(attempt.id)) ?? attempt;
|
|
1720
|
+
if (latest.provider !== attempt.provider ||
|
|
1721
|
+
latest.subscriptionId !== attempt.subscriptionId ||
|
|
1722
|
+
latest.payerId !== attempt.payerId) {
|
|
1723
|
+
throw new BillingWorkflowError("operation_quarantined");
|
|
1724
|
+
}
|
|
1725
|
+
const wasCreating = latest.status === "creating";
|
|
1726
|
+
if (latest.status === "succeeded" ||
|
|
1727
|
+
latest.status === "failed" ||
|
|
1728
|
+
latest.status === "conflicted") {
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
if (wasCreating)
|
|
1732
|
+
latest.status = "pending";
|
|
1733
|
+
latest.providerPaymentId = remote.providerPaymentId;
|
|
1734
|
+
latest.paymentUrlEncrypted = encryptedPaymentUrl
|
|
1735
|
+
? encryptedPaymentUrl.ciphertext
|
|
1736
|
+
: latest.paymentUrlEncrypted;
|
|
1737
|
+
await store.savePlanChange(latest);
|
|
1738
|
+
if (wasCreating) {
|
|
1739
|
+
await requireAudit({
|
|
1740
|
+
effectId: `plan_change:${latest.id}:pending`,
|
|
1741
|
+
actor: { kind: "system", id: "billing" },
|
|
1742
|
+
previous: { status: "creating" },
|
|
1743
|
+
next: { status: "pending" },
|
|
1744
|
+
correlationIds: { changeId: latest.changeId },
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
async function getBillableEntityBillingBlockers(entity, now) {
|
|
1751
|
+
const blockers = [];
|
|
1752
|
+
const subscriptions = await store.listSubscriptionsByEntity(entity.id);
|
|
1753
|
+
if (subscriptions.some((sub) => !["cancelled", "expired"].includes(sub.status))) {
|
|
1754
|
+
blockers.push("nonterminal_subscription");
|
|
1755
|
+
}
|
|
1756
|
+
if (subscriptions.some((sub) => retainsPaidEntitlement(sub, now))) {
|
|
1757
|
+
blockers.push("future_paid_entitlement");
|
|
1758
|
+
}
|
|
1759
|
+
if (await store.findLiveCheckout(entity.id)) {
|
|
1760
|
+
blockers.push("live_checkout");
|
|
1761
|
+
}
|
|
1762
|
+
if (await store.findLivePlanChange(entity.id)) {
|
|
1763
|
+
blockers.push("pending_plan_change");
|
|
1764
|
+
}
|
|
1765
|
+
return blockers;
|
|
1766
|
+
}
|
|
1767
|
+
async function getPayerBillingResponsibilities(payer, now) {
|
|
1768
|
+
const customers = await store.listCustomersByPayer(payer.id);
|
|
1769
|
+
const subscriptions = await store.listSubscriptionsByPayer(payer.id);
|
|
1770
|
+
return customers.map((customer) => ({
|
|
1771
|
+
provider: customer.provider,
|
|
1772
|
+
providerCustomerId: customer.providerCustomerId,
|
|
1773
|
+
subscriptionIds: subscriptions
|
|
1774
|
+
.filter((row) => {
|
|
1775
|
+
if (row.provider !== customer.provider)
|
|
1776
|
+
return false;
|
|
1777
|
+
return (!["cancelled", "expired"].includes(row.status) ||
|
|
1778
|
+
retainsPaidEntitlement(row, now));
|
|
1779
|
+
})
|
|
1780
|
+
.map((row) => row.id),
|
|
1781
|
+
}));
|
|
1782
|
+
}
|
|
1783
|
+
async function recordOperatorAudit(context, effectId, previous, next, correlationIds = {}) {
|
|
1784
|
+
assertOperatorContext(context);
|
|
1785
|
+
await requireAudit({
|
|
1786
|
+
effectId,
|
|
1787
|
+
actor: { kind: "operator", id: context.actorId },
|
|
1788
|
+
reason: context.reason,
|
|
1789
|
+
previous,
|
|
1790
|
+
next,
|
|
1791
|
+
correlationIds: {
|
|
1792
|
+
...correlationIds,
|
|
1793
|
+
...(context.ticket ? { ticket: context.ticket } : {}),
|
|
1794
|
+
},
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
async function commercialState(entityId) {
|
|
1798
|
+
const state = (await store.findPlanState(entityId)) ?? {
|
|
1799
|
+
billableEntityId: entityId,
|
|
1800
|
+
activeSubscriptionId: null,
|
|
1801
|
+
projectionVersion: 0,
|
|
1802
|
+
};
|
|
1803
|
+
const pointed = state.activeSubscriptionId
|
|
1804
|
+
? await store.findSubscriptionById(state.activeSubscriptionId)
|
|
1805
|
+
: undefined;
|
|
1806
|
+
const sub = pointed?.isEntitlementSource ? pointed : undefined;
|
|
1807
|
+
return {
|
|
1808
|
+
activePaidPlan: sub?.plan ?? null,
|
|
1809
|
+
billingInterval: sub?.interval ?? null,
|
|
1810
|
+
subscriptionStatus: sub?.status ?? null,
|
|
1811
|
+
providerTrialEndsAt: sub?.trialEndsAt ?? null,
|
|
1812
|
+
currentPeriodEndsAt: sub?.currentPeriodEndsAt ?? null,
|
|
1813
|
+
paidThroughAt: sub?.paidThroughAt ?? null,
|
|
1814
|
+
cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false,
|
|
1815
|
+
pendingCheckout: Boolean(await store.findLiveCheckout(entityId)),
|
|
1816
|
+
pendingPlanChange: Boolean(await store.findLivePlanChange(entityId)),
|
|
1817
|
+
projectionVersion: state.projectionVersion,
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
async function operatorCancel(context, subscriptionId) {
|
|
1821
|
+
if (options.mode === "oss") {
|
|
1822
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
1823
|
+
}
|
|
1824
|
+
assertOperatorContext(context);
|
|
1825
|
+
const sub = await store.findSubscriptionById(subscriptionId);
|
|
1826
|
+
if (!sub)
|
|
1827
|
+
throw new BillingWorkflowError("subscription_required");
|
|
1828
|
+
if (sub.status === "cancelled" || sub.status === "expired")
|
|
1829
|
+
return;
|
|
1830
|
+
if (provider(sub.provider).capabilities.mutationRecovery
|
|
1831
|
+
.cancellation === "unsupported") {
|
|
1832
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
1833
|
+
}
|
|
1834
|
+
await store.withTransaction(async () => {
|
|
1835
|
+
await recordOperatorAudit(context, `operator:cancel:${sub.id}:${context.actorId}`, { status: sub.status }, { requested: "provider_cancel" }, { subscriptionId: sub.id });
|
|
1836
|
+
await enqueueJobInTransaction({
|
|
1837
|
+
provider: sub.provider,
|
|
1838
|
+
subscriptionId: sub.id,
|
|
1839
|
+
operation: "cancellation",
|
|
1840
|
+
});
|
|
1841
|
+
});
|
|
1842
|
+
try {
|
|
1843
|
+
await callProvider(() => provider(sub.provider).cancelSubscription(sub.providerSubscriptionId, `operator-cancel:${sub.id}`));
|
|
1844
|
+
}
|
|
1845
|
+
catch (error) {
|
|
1846
|
+
if (error instanceof BillingWorkflowError)
|
|
1847
|
+
throw error;
|
|
1848
|
+
throw new BillingWorkflowError("provider_unavailable", {
|
|
1849
|
+
retryable: true,
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
async function abandonRequestedCatalog(context) {
|
|
1854
|
+
if (options.mode === "oss") {
|
|
1855
|
+
throw new BillingWorkflowError("unsupported_operation");
|
|
1856
|
+
}
|
|
1857
|
+
assertOperatorContext(context);
|
|
1858
|
+
return store.withTransaction(async () => {
|
|
1859
|
+
const catalog = await store.getCatalogRevision(options.checkoutProvider, options.requestedRevision);
|
|
1860
|
+
if (!catalog) {
|
|
1861
|
+
throw new BillingWorkflowError("catalog_unavailable");
|
|
1862
|
+
}
|
|
1863
|
+
if (catalog.revision.status === "abandoned") {
|
|
1864
|
+
return catalog.revision;
|
|
1865
|
+
}
|
|
1866
|
+
if (catalog.revision.status !== "pending_verification" &&
|
|
1867
|
+
catalog.revision.status !== "invalid") {
|
|
1868
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
1869
|
+
}
|
|
1870
|
+
const previous = { ...catalog.revision };
|
|
1871
|
+
catalog.revision.status = "abandoned";
|
|
1872
|
+
await store.saveCatalogRevision(catalog.revision);
|
|
1873
|
+
await recordOperatorAudit(context, `operator:abandon-catalog:${catalog.revision.id}`, previous, catalog.revision, { catalogRevisionId: catalog.revision.id });
|
|
1874
|
+
return catalog.revision;
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
return {
|
|
1878
|
+
store,
|
|
1879
|
+
startCheckout,
|
|
1880
|
+
startPortal,
|
|
1881
|
+
startPlanChange,
|
|
1882
|
+
cancel,
|
|
1883
|
+
ingestWebhook,
|
|
1884
|
+
processWebhookByEventId,
|
|
1885
|
+
runWebhookInboxBatch,
|
|
1886
|
+
runReconciliationBatch,
|
|
1887
|
+
runDeadlineBatch,
|
|
1888
|
+
purgeExpiredSensitiveValues,
|
|
1889
|
+
claimReconciliationJobs,
|
|
1890
|
+
projectSnapshot,
|
|
1891
|
+
getBillableEntityBillingBlockers,
|
|
1892
|
+
getPayerBillingResponsibilities,
|
|
1893
|
+
commercialState,
|
|
1894
|
+
publicCatalog,
|
|
1895
|
+
recordRequestedCatalog,
|
|
1896
|
+
verifyRequestedCatalog,
|
|
1897
|
+
abandonRequestedCatalog,
|
|
1898
|
+
operatorCancel,
|
|
1899
|
+
recordOperatorAudit,
|
|
1900
|
+
enqueueJob,
|
|
1901
|
+
resolveCustomer,
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
function catalogShapeIsComplete(catalog, requiredOfferKeys) {
|
|
1905
|
+
if (!catalog)
|
|
1906
|
+
return false;
|
|
1907
|
+
const keys = catalog.items.map((item) => item.offerKey);
|
|
1908
|
+
if (new Set(keys).size !== keys.length)
|
|
1909
|
+
return false;
|
|
1910
|
+
if (keys.length !== requiredOfferKeys.length ||
|
|
1911
|
+
new Set(requiredOfferKeys).size !== requiredOfferKeys.length) {
|
|
1912
|
+
return false;
|
|
1913
|
+
}
|
|
1914
|
+
const required = new Set(requiredOfferKeys);
|
|
1915
|
+
return catalog.items.every((item) => required.has(item.offerKey) &&
|
|
1916
|
+
item.price.provider === catalog.revision.checkoutProvider &&
|
|
1917
|
+
isBoundedOpaqueId(item.offerKey) &&
|
|
1918
|
+
isBoundedOpaqueId(item.price.id) &&
|
|
1919
|
+
isBoundedOpaqueId(item.price.providerProductId) &&
|
|
1920
|
+
Number.isSafeInteger(item.price.amountMinor) &&
|
|
1921
|
+
item.price.amountMinor > 0 &&
|
|
1922
|
+
Number.isSafeInteger(item.price.providerTrialDays) &&
|
|
1923
|
+
item.price.providerTrialDays >= 0 &&
|
|
1924
|
+
/^[A-Z]{3}$/.test(item.price.currency) &&
|
|
1925
|
+
(item.price.interval === "month" || item.price.interval === "year"));
|
|
1926
|
+
}
|
|
1927
|
+
function priceMatchesDeclaredOffer(price, offer) {
|
|
1928
|
+
return (price.offerKey === offer.key &&
|
|
1929
|
+
price.plan === offer.plan &&
|
|
1930
|
+
price.interval === offer.interval &&
|
|
1931
|
+
price.currency === offer.currency &&
|
|
1932
|
+
price.amountMinor === offer.amountMinor &&
|
|
1933
|
+
price.providerTrialDays === offer.providerTrialDays &&
|
|
1934
|
+
price.provider === offer.provider &&
|
|
1935
|
+
price.providerProductId === offer.providerProductId);
|
|
1936
|
+
}
|
|
1937
|
+
function catalogMatchesDeclaredOffers(catalog, offers) {
|
|
1938
|
+
if (catalog.items.length !== offers.length)
|
|
1939
|
+
return false;
|
|
1940
|
+
return offers.every((offer) => {
|
|
1941
|
+
const item = catalog.items.find((candidate) => candidate.offerKey === offer.key);
|
|
1942
|
+
return Boolean(item && priceMatchesDeclaredOffer(item.price, offer));
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
function validateReturnUrl(value, validator) {
|
|
1946
|
+
try {
|
|
1947
|
+
const url = new URL(value);
|
|
1948
|
+
const localHttp = url.protocol === "http:" &&
|
|
1949
|
+
(url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
1950
|
+
if ((url.protocol !== "https:" && !localHttp) ||
|
|
1951
|
+
!url.hostname ||
|
|
1952
|
+
url.username ||
|
|
1953
|
+
url.password ||
|
|
1954
|
+
(validator && !validator(value))) {
|
|
1955
|
+
throw new Error("return_url_not_allowed");
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
catch {
|
|
1959
|
+
throw new BillingWorkflowError("invalid_return_url");
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
function assertOperatorContext(context) {
|
|
1963
|
+
if (!context.actorId.trim() ||
|
|
1964
|
+
!context.reason.trim() ||
|
|
1965
|
+
context.reason.length > 500 ||
|
|
1966
|
+
(context.ticket !== undefined && context.ticket.length > 256)) {
|
|
1967
|
+
throw new BillingWorkflowError("grant_invalid");
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
function isBoundedOpaqueId(value) {
|
|
1971
|
+
return (typeof value === "string" &&
|
|
1972
|
+
value.length > 0 &&
|
|
1973
|
+
value.length <= 256 &&
|
|
1974
|
+
value === value.trim() &&
|
|
1975
|
+
!/\s/.test(value));
|
|
1976
|
+
}
|
|
1977
|
+
function workLimit(value) {
|
|
1978
|
+
if (value === undefined)
|
|
1979
|
+
return 10;
|
|
1980
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1981
|
+
throw new BillingWorkflowError("operation_conflicted");
|
|
1982
|
+
}
|
|
1983
|
+
return Math.min(value, 1_000);
|
|
1984
|
+
}
|
|
1985
|
+
function isOptionalDate(value) {
|
|
1986
|
+
return (value === null ||
|
|
1987
|
+
(value instanceof Date && !Number.isNaN(value.getTime())));
|
|
1988
|
+
}
|
|
1989
|
+
function isCanonicalSubscriptionStatus(value) {
|
|
1990
|
+
return (value === "pending" ||
|
|
1991
|
+
value === "trialing" ||
|
|
1992
|
+
value === "active" ||
|
|
1993
|
+
value === "past_due" ||
|
|
1994
|
+
value === "cancelled" ||
|
|
1995
|
+
value === "expired");
|
|
1996
|
+
}
|
|
1997
|
+
function isOlderProviderSnapshot(existing, incoming) {
|
|
1998
|
+
if (existing.providerVersion && incoming.providerVersion) {
|
|
1999
|
+
const existingNumber = Number(existing.providerVersion);
|
|
2000
|
+
const incomingNumber = Number(incoming.providerVersion);
|
|
2001
|
+
if (Number.isSafeInteger(existingNumber) &&
|
|
2002
|
+
Number.isSafeInteger(incomingNumber)) {
|
|
2003
|
+
if (incomingNumber < existingNumber)
|
|
2004
|
+
return true;
|
|
2005
|
+
if (incomingNumber > existingNumber)
|
|
2006
|
+
return false;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
if (existing.providerOccurredAt && incoming.providerOccurredAt) {
|
|
2010
|
+
return (incoming.providerOccurredAt.getTime() <
|
|
2011
|
+
existing.providerOccurredAt.getTime());
|
|
2012
|
+
}
|
|
2013
|
+
return false;
|
|
2014
|
+
}
|
|
2015
|
+
//# sourceMappingURL=engine.js.map
|