@atlasauth/emulator-core 0.2.0 → 0.3.0
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/dist/custom-html.d.ts +57 -0
- package/dist/custom-html.js +491 -0
- package/dist/custom-html.js.map +1 -0
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +4 -0
- package/dist/engine.js.map +1 -1
- package/dist/error-catalog.js +8 -0
- package/dist/error-catalog.js.map +1 -1
- package/dist/event-gate.js +60 -2
- package/dist/event-gate.js.map +1 -1
- package/dist/generic.js +5 -1
- package/dist/generic.js.map +1 -1
- package/dist/handlers/access-signals.d.ts +48 -0
- package/dist/handlers/access-signals.js +76 -0
- package/dist/handlers/access-signals.js.map +1 -0
- package/dist/handlers/approvals.js +54 -1
- package/dist/handlers/approvals.js.map +1 -1
- package/dist/handlers/authz-data.d.ts +2 -0
- package/dist/handlers/authz-data.js +627 -0
- package/dist/handlers/authz-data.js.map +1 -0
- package/dist/handlers/authz-layers.d.ts +229 -0
- package/dist/handlers/authz-layers.js +786 -0
- package/dist/handlers/authz-layers.js.map +1 -0
- package/dist/handlers/authz-state.d.ts +80 -0
- package/dist/handlers/authz-state.js +175 -0
- package/dist/handlers/authz-state.js.map +1 -0
- package/dist/handlers/authz.js +240 -177
- package/dist/handlers/authz.js.map +1 -1
- package/dist/handlers/billing.d.ts +99 -1
- package/dist/handlers/billing.js +556 -77
- package/dist/handlers/billing.js.map +1 -1
- package/dist/handlers/cedar-ext.d.ts +35 -0
- package/dist/handlers/cedar-ext.js +174 -0
- package/dist/handlers/cedar-ext.js.map +1 -0
- package/dist/handlers/cedar.d.ts +146 -0
- package/dist/handlers/cedar.js +689 -0
- package/dist/handlers/cedar.js.map +1 -0
- package/dist/handlers/connections.d.ts +4 -1
- package/dist/handlers/connections.js +267 -38
- package/dist/handlers/connections.js.map +1 -1
- package/dist/handlers/control.js +6 -1
- package/dist/handlers/control.js.map +1 -1
- package/dist/handlers/device.js +18 -1
- package/dist/handlers/device.js.map +1 -1
- package/dist/handlers/emu3-misc.d.ts +38 -0
- package/dist/handlers/emu3-misc.js +1056 -0
- package/dist/handlers/emu3-misc.js.map +1 -0
- package/dist/handlers/emu4-actions-access.d.ts +46 -0
- package/dist/handlers/emu4-actions-access.js +107 -0
- package/dist/handlers/emu4-actions-access.js.map +1 -0
- package/dist/handlers/emu4-actions-catalog-extra.d.ts +3 -0
- package/dist/handlers/emu4-actions-catalog-extra.js +204 -0
- package/dist/handlers/emu4-actions-catalog-extra.js.map +1 -0
- package/dist/handlers/emu4-actions-catalog.d.ts +3 -0
- package/dist/handlers/emu4-actions-catalog.js +143 -0
- package/dist/handlers/emu4-actions-catalog.js.map +1 -0
- package/dist/handlers/emu4-actions-def.d.ts +50 -0
- package/dist/handlers/emu4-actions-def.js +250 -0
- package/dist/handlers/emu4-actions-def.js.map +1 -0
- package/dist/handlers/emu4-actions-sandbox.d.ts +36 -0
- package/dist/handlers/emu4-actions-sandbox.js +318 -0
- package/dist/handlers/emu4-actions-sandbox.js.map +1 -0
- package/dist/handlers/emu4-actions-typegen.d.ts +5 -0
- package/dist/handlers/emu4-actions-typegen.js +73 -0
- package/dist/handlers/emu4-actions-typegen.js.map +1 -0
- package/dist/handlers/emu4-actions.d.ts +2 -0
- package/dist/handlers/emu4-actions.js +506 -0
- package/dist/handlers/emu4-actions.js.map +1 -0
- package/dist/handlers/emu4-authz-trust.d.ts +40 -0
- package/dist/handlers/emu4-authz-trust.js +488 -0
- package/dist/handlers/emu4-authz-trust.js.map +1 -0
- package/dist/handlers/emu4-authz.d.ts +2 -0
- package/dist/handlers/emu4-authz.js +752 -0
- package/dist/handlers/emu4-authz.js.map +1 -0
- package/dist/handlers/emu4-billing-core.d.ts +23 -0
- package/dist/handlers/emu4-billing-core.js +22 -0
- package/dist/handlers/emu4-billing-core.js.map +1 -0
- package/dist/handlers/emu4-billing-x.d.ts +2 -0
- package/dist/handlers/emu4-billing-x.js +489 -0
- package/dist/handlers/emu4-billing-x.js.map +1 -0
- package/dist/handlers/emu4-billing.d.ts +14 -0
- package/dist/handlers/emu4-billing.js +846 -0
- package/dist/handlers/emu4-billing.js.map +1 -0
- package/dist/handlers/emu4-clones.d.ts +2 -0
- package/dist/handlers/emu4-clones.js +322 -0
- package/dist/handlers/emu4-clones.js.map +1 -0
- package/dist/handlers/emu4-compliance.d.ts +8 -0
- package/dist/handlers/emu4-compliance.js +39 -0
- package/dist/handlers/emu4-compliance.js.map +1 -0
- package/dist/handlers/emu4-config.d.ts +14 -0
- package/dist/handlers/emu4-config.js +148 -0
- package/dist/handlers/emu4-config.js.map +1 -0
- package/dist/handlers/emu4-cookies.d.ts +8 -0
- package/dist/handlers/emu4-cookies.js +248 -0
- package/dist/handlers/emu4-cookies.js.map +1 -0
- package/dist/handlers/emu4-erasure.d.ts +2 -0
- package/dist/handlers/emu4-erasure.js +587 -0
- package/dist/handlers/emu4-erasure.js.map +1 -0
- package/dist/handlers/emu4-federation.d.ts +8 -0
- package/dist/handlers/emu4-federation.js +18 -0
- package/dist/handlers/emu4-federation.js.map +1 -0
- package/dist/handlers/emu4-journey-math.d.ts +42 -0
- package/dist/handlers/emu4-journey-math.js +99 -0
- package/dist/handlers/emu4-journey-math.js.map +1 -0
- package/dist/handlers/emu4-messaging.d.ts +21 -0
- package/dist/handlers/emu4-messaging.js +595 -0
- package/dist/handlers/emu4-messaging.js.map +1 -0
- package/dist/handlers/emu4-minors.d.ts +7 -0
- package/dist/handlers/emu4-minors.js +408 -0
- package/dist/handlers/emu4-minors.js.map +1 -0
- package/dist/handlers/emu4-oauth-audit.d.ts +5 -0
- package/dist/handlers/emu4-oauth-audit.js +17 -0
- package/dist/handlers/emu4-oauth-audit.js.map +1 -0
- package/dist/handlers/emu4-oauth-auth.d.ts +83 -0
- package/dist/handlers/emu4-oauth-auth.js +363 -0
- package/dist/handlers/emu4-oauth-auth.js.map +1 -0
- package/dist/handlers/emu4-oauth-delivery.d.ts +68 -0
- package/dist/handlers/emu4-oauth-delivery.js +207 -0
- package/dist/handlers/emu4-oauth-delivery.js.map +1 -0
- package/dist/handlers/emu4-oauth-keys.d.ts +11 -0
- package/dist/handlers/emu4-oauth-keys.js +268 -0
- package/dist/handlers/emu4-oauth-keys.js.map +1 -0
- package/dist/handlers/emu4-oauth-pub.d.ts +36 -0
- package/dist/handlers/emu4-oauth-pub.js +420 -0
- package/dist/handlers/emu4-oauth-pub.js.map +1 -0
- package/dist/handlers/emu4-oauth.d.ts +10 -0
- package/dist/handlers/emu4-oauth.js +23 -0
- package/dist/handlers/emu4-oauth.js.map +1 -0
- package/dist/handlers/emu4-policies.d.ts +2 -0
- package/dist/handlers/emu4-policies.js +408 -0
- package/dist/handlers/emu4-policies.js.map +1 -0
- package/dist/handlers/emu4-region.d.ts +2 -0
- package/dist/handlers/emu4-region.js +189 -0
- package/dist/handlers/emu4-region.js.map +1 -0
- package/dist/handlers/emu4-saml.d.ts +12 -0
- package/dist/handlers/emu4-saml.js +532 -0
- package/dist/handlers/emu4-saml.js.map +1 -0
- package/dist/handlers/emu4-shared.d.ts +44 -0
- package/dist/handlers/emu4-shared.js +90 -0
- package/dist/handlers/emu4-shared.js.map +1 -0
- package/dist/handlers/emu4-snapshots.d.ts +38 -0
- package/dist/handlers/emu4-snapshots.js +231 -0
- package/dist/handlers/emu4-snapshots.js.map +1 -0
- package/dist/handlers/emu4-sns.d.ts +9 -0
- package/dist/handlers/emu4-sns.js +21 -0
- package/dist/handlers/emu4-sns.js.map +1 -0
- package/dist/handlers/emu4-trust-binding.d.ts +27 -0
- package/dist/handlers/emu4-trust-binding.js +344 -0
- package/dist/handlers/emu4-trust-binding.js.map +1 -0
- package/dist/handlers/emu4-trust.d.ts +35 -0
- package/dist/handlers/emu4-trust.js +543 -0
- package/dist/handlers/emu4-trust.js.map +1 -0
- package/dist/handlers/fga-engine.d.ts +78 -0
- package/dist/handlers/fga-engine.js +663 -0
- package/dist/handlers/fga-engine.js.map +1 -0
- package/dist/handlers/hooks.js +13 -0
- package/dist/handlers/hooks.js.map +1 -1
- package/dist/handlers/index.js +14 -1
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/machines.js +6 -0
- package/dist/handlers/machines.js.map +1 -1
- package/dist/handlers/messaging.d.ts +3 -1
- package/dist/handlers/messaging.js +455 -48
- package/dist/handlers/messaging.js.map +1 -1
- package/dist/handlers/oauth-adv.d.ts +191 -0
- package/dist/handlers/oauth-adv.js +1305 -0
- package/dist/handlers/oauth-adv.js.map +1 -0
- package/dist/handlers/oauth-flows.d.ts +25 -1
- package/dist/handlers/oauth-flows.js +111 -50
- package/dist/handlers/oauth-flows.js.map +1 -1
- package/dist/handlers/oauth.d.ts +24 -3
- package/dist/handlers/oauth.js +245 -60
- package/dist/handlers/oauth.js.map +1 -1
- package/dist/handlers/orgs.js +6 -0
- package/dist/handlers/orgs.js.map +1 -1
- package/dist/handlers/platform.d.ts +5 -0
- package/dist/handlers/platform.js +33 -15
- package/dist/handlers/platform.js.map +1 -1
- package/dist/handlers/scim.d.ts +2 -0
- package/dist/handlers/scim.js +198 -11
- package/dist/handlers/scim.js.map +1 -1
- package/dist/handlers/sessions.d.ts +2 -0
- package/dist/handlers/sessions.js +24 -16
- package/dist/handlers/sessions.js.map +1 -1
- package/dist/handlers/users.js +3 -0
- package/dist/handlers/users.js.map +1 -1
- package/dist/jwe.d.ts +18 -0
- package/dist/jwe.js +101 -0
- package/dist/jwe.js.map +1 -0
- package/dist/jws.js +2 -0
- package/dist/jws.js.map +1 -1
- package/dist/messaging-callbacks.d.ts +100 -0
- package/dist/messaging-callbacks.js +767 -0
- package/dist/messaging-callbacks.js.map +1 -0
- package/dist/openapi.js +41 -1
- package/dist/openapi.js.map +1 -1
- package/dist/parity-emu3-authz.d.ts +3 -0
- package/dist/parity-emu3-authz.js +61 -0
- package/dist/parity-emu3-authz.js.map +1 -0
- package/dist/parity-emu3-billing.d.ts +3 -0
- package/dist/parity-emu3-billing.js +25 -0
- package/dist/parity-emu3-billing.js.map +1 -0
- package/dist/parity-emu3-messaging.d.ts +3 -0
- package/dist/parity-emu3-messaging.js +27 -0
- package/dist/parity-emu3-messaging.js.map +1 -0
- package/dist/parity-emu3-misc.d.ts +2 -0
- package/dist/parity-emu3-misc.js +63 -0
- package/dist/parity-emu3-misc.js.map +1 -0
- package/dist/parity-emu3-oauth.d.ts +2 -0
- package/dist/parity-emu3-oauth.js +39 -0
- package/dist/parity-emu3-oauth.js.map +1 -0
- package/dist/parity-emu4-actions.d.ts +2 -0
- package/dist/parity-emu4-actions.js +38 -0
- package/dist/parity-emu4-actions.js.map +1 -0
- package/dist/parity-emu4-authz.d.ts +3 -0
- package/dist/parity-emu4-authz.js +68 -0
- package/dist/parity-emu4-authz.js.map +1 -0
- package/dist/parity-emu4-billing.d.ts +6 -0
- package/dist/parity-emu4-billing.js +44 -0
- package/dist/parity-emu4-billing.js.map +1 -0
- package/dist/parity-emu4-compliance.d.ts +2 -0
- package/dist/parity-emu4-compliance.js +92 -0
- package/dist/parity-emu4-compliance.js.map +1 -0
- package/dist/parity-emu4-federation.d.ts +2 -0
- package/dist/parity-emu4-federation.js +33 -0
- package/dist/parity-emu4-federation.js.map +1 -0
- package/dist/parity-emu4-messaging.d.ts +3 -0
- package/dist/parity-emu4-messaging.js +42 -0
- package/dist/parity-emu4-messaging.js.map +1 -0
- package/dist/parity-emu4-oauth.d.ts +2 -0
- package/dist/parity-emu4-oauth.js +55 -0
- package/dist/parity-emu4-oauth.js.map +1 -0
- package/dist/parity-emu4-trust.d.ts +2 -0
- package/dist/parity-emu4-trust.js +41 -0
- package/dist/parity-emu4-trust.js.map +1 -0
- package/dist/parity-routes.js +19 -1
- package/dist/parity-routes.js.map +1 -1
- package/dist/sns-cert.d.ts +9 -0
- package/dist/sns-cert.js +101 -0
- package/dist/sns-cert.js.map +1 -0
- package/dist/tokens.d.ts +69 -4
- package/dist/tokens.js +164 -38
- package/dist/tokens.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encodeRef = void 0;
|
|
4
|
+
exports.signProvider = signProvider;
|
|
5
|
+
exports.installBillingWebhooks = installBillingWebhooks;
|
|
6
|
+
exports.emu4BillingHandlers = emu4BillingHandlers;
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
const store_1 = require("../store");
|
|
9
|
+
const billing_1 = require("./billing");
|
|
10
|
+
const emu4_billing_core_1 = require("./emu4-billing-core");
|
|
11
|
+
const emu4_billing_x_1 = require("./emu4-billing-x");
|
|
12
|
+
const support_1 = require("./support");
|
|
13
|
+
/**
|
|
14
|
+
* Round-four billing: payment-provider credentials (`/v1/billing/providers`), the per-instance provider webhook with the
|
|
15
|
+
* provider's own signature checks (Stripe, Paddle, Lemon Squeezy, RevenueCat), app-store purchase links, and the
|
|
16
|
+
* helpers that sign provider deliveries so a test can play them. Stripe Connect payouts, the credit ledger and referral
|
|
17
|
+
* rewards are in emu4-billing-x.ts; proration is in billing.ts, with the read-only helpers at the end of this file.
|
|
18
|
+
*
|
|
19
|
+
* `POST /__emulator/billing/webhooks/sign` returns the exact URL, headers and body a provider would send, signed with the
|
|
20
|
+
* secret stored for that provider (`tamper: signature | stale` breaks it on purpose); `.../deliver` also posts it. Nothing
|
|
21
|
+
* here calls a payment provider: the provider side is the test's to play.
|
|
22
|
+
*/
|
|
23
|
+
const PROVIDER_NAMES = billing_1.billingInternals.PROVIDERS;
|
|
24
|
+
const SECRET = { stripe: ['secret_key', 'webhook_secret'], polar: ['access_token', 'webhook_secret'], dodo: ['api_key', 'webhook_secret'], creem: ['api_key', 'webhook_secret'], chargebee: ['api_key', 'webhook_password'], autumn: ['secret_key', 'webhook_secret'], commet: ['api_key', 'webhook_secret'], paddle: ['api_key', 'webhook_secret'], lemonsqueezy: ['api_key', 'webhook_secret'], revenuecat: ['api_key', 'webhook_secret'], appstore: ['private_key'], googleplay: ['service_account_json'] };
|
|
25
|
+
const PUBLIC = { stripe: ['publishable_key', 'portal_configuration_id'], polar: ['organization_id'], dodo: [], creem: [], chargebee: ['site', 'webhook_username'], autumn: [], commet: ['seat_feature_code'], paddle: [], lemonsqueezy: ['store_id'], revenuecat: ['project_id'], appstore: ['key_id', 'issuer_id', 'bundle_id'], googleplay: ['package_name', 'push_audience', 'push_service_account'] };
|
|
26
|
+
const REQUIRED = { stripe: 'secret_key', polar: 'access_token', dodo: 'api_key', creem: 'api_key', chargebee: 'api_key', autumn: 'secret_key', commet: 'api_key', paddle: 'api_key', lemonsqueezy: 'api_key', revenuecat: 'api_key', appstore: 'private_key', googleplay: 'service_account_json' };
|
|
27
|
+
const BASE = { embeddedCheckout: false, portal: true, scheduleAtPeriodEnd: false, restore: true, seats: false, metering: false, paymentMethod: false, importSubscription: false, featureCheck: false, planSync: false };
|
|
28
|
+
const capabilities = (c) => {
|
|
29
|
+
const caps = {
|
|
30
|
+
stripe: { ...BASE, embeddedCheckout: true, scheduleAtPeriodEnd: true, seats: true, metering: true, paymentMethod: true, importSubscription: true, discounts: true, credits: true, refunds: true, trialExtend: true, connect: true },
|
|
31
|
+
polar: { ...BASE },
|
|
32
|
+
dodo: { ...BASE, seats: true },
|
|
33
|
+
creem: { ...BASE, restore: false, seats: true },
|
|
34
|
+
chargebee: { ...BASE, scheduleAtPeriodEnd: true, seats: true, metering: true, paymentMethod: true },
|
|
35
|
+
autumn: { ...BASE, seats: true, metering: true, featureCheck: true, planSync: true },
|
|
36
|
+
commet: { ...BASE, seats: Boolean(c.config.seat_feature_code), metering: true, featureCheck: true, planSync: true },
|
|
37
|
+
paddle: { ...BASE, seats: true },
|
|
38
|
+
lemonsqueezy: { ...BASE, seats: true },
|
|
39
|
+
revenuecat: { ...BASE, portal: false, restore: false },
|
|
40
|
+
appstore: { ...BASE, restore: false },
|
|
41
|
+
googleplay: { ...BASE, restore: false },
|
|
42
|
+
};
|
|
43
|
+
return caps[c.provider];
|
|
44
|
+
};
|
|
45
|
+
const st = (0, support_1.engineState)((e) => ({ storeLinks: new store_1.Table('billing_store_links', e.clock.now, 'bsl'), trials: new Set() }));
|
|
46
|
+
const webhookUrl = (e, provider) => `${e.issuer}/v1/webhooks/billing/${e.instanceId}/${provider}`;
|
|
47
|
+
const notFoundProvider = () => new errors_1.EmulatorError('NOT_FOUND', `Unknown billing provider. Use one of ${PROVIDER_NAMES.join(', ')}.`);
|
|
48
|
+
const parseProvider = (raw) => {
|
|
49
|
+
if (!raw || !PROVIDER_NAMES.includes(raw))
|
|
50
|
+
throw notFoundProvider();
|
|
51
|
+
return raw;
|
|
52
|
+
};
|
|
53
|
+
function providerView(e, c) {
|
|
54
|
+
const secretsSet = Object.fromEntries(SECRET[c.provider].map((k) => [k, Boolean(c.config[k])]));
|
|
55
|
+
const config = Object.fromEntries(PUBLIC[c.provider].filter((k) => c.config[k] !== undefined).map((k) => [k, c.config[k]]));
|
|
56
|
+
return { object: 'billing_provider', configured: true, provider: c.provider, environment: c.environment, status: c.status, config, secrets_set: secretsSet, webhook_url: webhookUrl(e, c.provider), capabilities: capabilities(c), last_verified_at: c.last_verified_at, last_webhook_at: c.last_webhook_at, updated_at: c.updated_at };
|
|
57
|
+
}
|
|
58
|
+
/** The write rules of `PUT /v1/billing/providers/{provider}`: only the provider's own keys, secrets kept when left blank, per-provider format checks. */
|
|
59
|
+
function prepareConfig(e, provider, body) {
|
|
60
|
+
billing_1.billingInternals.known(body, ['environment', 'config']);
|
|
61
|
+
const raw = body.config;
|
|
62
|
+
if (raw !== undefined && (typeof raw !== 'object' || raw === null || Array.isArray(raw)))
|
|
63
|
+
throw (0, emu4_billing_core_1.bad)('config must be an object of strings.', 'config');
|
|
64
|
+
const secret = new Set(SECRET[provider]);
|
|
65
|
+
const allowed = new Set([...SECRET[provider], ...PUBLIC[provider]]);
|
|
66
|
+
const existing = (0, emu4_billing_core_1.providerOf)(e, provider);
|
|
67
|
+
const config = { ...(existing?.config ?? {}) };
|
|
68
|
+
for (const [k, v] of Object.entries((raw ?? {}))) {
|
|
69
|
+
if (!allowed.has(k))
|
|
70
|
+
throw (0, emu4_billing_core_1.bad)(`${provider} does not take config.${k}. Allowed: ${[...allowed].join(', ')}.`, `config.${k}`);
|
|
71
|
+
if (v !== undefined && v !== null && typeof v !== 'string')
|
|
72
|
+
throw (0, emu4_billing_core_1.bad)(`config.${k} must be a string.`, `config.${k}`);
|
|
73
|
+
const t = typeof v === 'string' ? v.trim() : undefined;
|
|
74
|
+
if (t)
|
|
75
|
+
config[k] = t;
|
|
76
|
+
else if (!secret.has(k) && v !== undefined)
|
|
77
|
+
delete config[k];
|
|
78
|
+
}
|
|
79
|
+
if (!config[REQUIRED[provider]])
|
|
80
|
+
throw (0, emu4_billing_core_1.bad)(`config.${REQUIRED[provider]} is required.`, `config.${REQUIRED[provider]}`);
|
|
81
|
+
const envRaw = body.environment;
|
|
82
|
+
if (envRaw !== undefined && envRaw !== 'live' && envRaw !== 'test')
|
|
83
|
+
throw (0, emu4_billing_core_1.bad)('environment must be live or test.', 'environment');
|
|
84
|
+
let environment;
|
|
85
|
+
if (provider === 'stripe') {
|
|
86
|
+
const key = config.secret_key;
|
|
87
|
+
const fromKey = /^(sk|rk)_test_/.test(key) ? 'test' : /^(sk|rk)_live_/.test(key) ? 'live' : null;
|
|
88
|
+
if (!fromKey)
|
|
89
|
+
throw (0, emu4_billing_core_1.bad)('secret_key must be a Stripe secret (sk_) or restricted (rk_) key.', 'config.secret_key');
|
|
90
|
+
if (envRaw !== undefined && envRaw !== fromKey)
|
|
91
|
+
throw (0, emu4_billing_core_1.bad)(`That is a ${fromKey} Stripe key; environment must match it.`, 'environment');
|
|
92
|
+
if (config.webhook_secret && !config.webhook_secret.startsWith('whsec_'))
|
|
93
|
+
throw (0, emu4_billing_core_1.bad)('webhook_secret must be the endpoint signing secret (whsec_…).', 'config.webhook_secret');
|
|
94
|
+
if (config.publishable_key && !/^pk_(test|live)_/.test(config.publishable_key))
|
|
95
|
+
throw (0, emu4_billing_core_1.bad)('publishable_key must be a Stripe publishable key (pk_…).', 'config.publishable_key');
|
|
96
|
+
if (config.publishable_key && !config.publishable_key.startsWith(`pk_${fromKey}_`))
|
|
97
|
+
throw (0, emu4_billing_core_1.bad)('publishable_key and secret_key must be from the same mode.', 'config.publishable_key');
|
|
98
|
+
environment = fromKey;
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
environment = envRaw ?? existing?.environment ?? 'live';
|
|
102
|
+
if (provider === 'chargebee') {
|
|
103
|
+
if (!config.site)
|
|
104
|
+
throw (0, emu4_billing_core_1.bad)('config.site is required (your Chargebee site name, e.g. acme or acme-test).', 'config.site');
|
|
105
|
+
if (!/^[a-z0-9-]+$/.test(config.site))
|
|
106
|
+
throw (0, emu4_billing_core_1.bad)('config.site must be the Chargebee site name only (lowercase letters, digits and hyphens), not a URL.', 'config.site');
|
|
107
|
+
if (config.webhook_username && !config.webhook_password)
|
|
108
|
+
throw (0, emu4_billing_core_1.bad)('Set config.webhook_password together with config.webhook_username.', 'config.webhook_password');
|
|
109
|
+
}
|
|
110
|
+
if (provider === 'autumn') {
|
|
111
|
+
if (!/^am_sk_/.test(config.secret_key))
|
|
112
|
+
throw (0, emu4_billing_core_1.bad)('secret_key must be an Autumn secret key (am_sk_…).', 'config.secret_key');
|
|
113
|
+
if (config.webhook_secret && !config.webhook_secret.startsWith('whsec_'))
|
|
114
|
+
throw (0, emu4_billing_core_1.bad)('webhook_secret must be the endpoint signing secret (whsec_…).', 'config.webhook_secret');
|
|
115
|
+
}
|
|
116
|
+
if (provider === 'commet' && config.seat_feature_code && !/^[A-Za-z0-9_.:-]{1,100}$/.test(config.seat_feature_code))
|
|
117
|
+
throw (0, emu4_billing_core_1.bad)('seat_feature_code must be a Commet feature code.', 'config.seat_feature_code');
|
|
118
|
+
if (provider === 'lemonsqueezy' && config.store_id && !/^\d{1,12}$/.test(config.store_id))
|
|
119
|
+
throw (0, emu4_billing_core_1.bad)('store_id must be the numeric Lemon Squeezy store id.', 'config.store_id');
|
|
120
|
+
if (provider === 'revenuecat' && config.project_id && !/^[A-Za-z0-9_-]{1,64}$/.test(config.project_id))
|
|
121
|
+
throw (0, emu4_billing_core_1.bad)('project_id must be your RevenueCat project id.', 'config.project_id');
|
|
122
|
+
if (provider === 'appstore') {
|
|
123
|
+
if (!/^-{5}BEGIN PRIVATE KEY-{5}[\s\S]+-{5}END PRIVATE KEY-{5}\s*$/.test(config.private_key))
|
|
124
|
+
throw (0, emu4_billing_core_1.bad)('private_key must be the App Store Connect API key (.p8, PEM text).', 'config.private_key');
|
|
125
|
+
for (const k of ['key_id', 'issuer_id', 'bundle_id'])
|
|
126
|
+
if (!config[k])
|
|
127
|
+
throw (0, emu4_billing_core_1.bad)(`config.${k} is required for the App Store.`, `config.${k}`);
|
|
128
|
+
if (!/^[A-Z0-9]{10}$/.test(config.key_id))
|
|
129
|
+
throw (0, emu4_billing_core_1.bad)('key_id must be the 10-character App Store Connect key id.', 'config.key_id');
|
|
130
|
+
if (!/^[0-9a-f-]{36}$/i.test(config.issuer_id))
|
|
131
|
+
throw (0, emu4_billing_core_1.bad)('issuer_id must be the App Store Connect issuer id (a UUID).', 'config.issuer_id');
|
|
132
|
+
if (!/^[A-Za-z0-9.-]{3,155}$/.test(config.bundle_id))
|
|
133
|
+
throw (0, emu4_billing_core_1.bad)('bundle_id must be your app bundle id.', 'config.bundle_id');
|
|
134
|
+
}
|
|
135
|
+
if (provider === 'googleplay') {
|
|
136
|
+
let sa = null;
|
|
137
|
+
try {
|
|
138
|
+
sa = JSON.parse(config.service_account_json);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
sa = null;
|
|
142
|
+
}
|
|
143
|
+
if (!sa || typeof sa.client_email !== 'string' || typeof sa.private_key !== 'string')
|
|
144
|
+
throw (0, emu4_billing_core_1.bad)('service_account_json must be the Google service account key file (JSON with client_email and private_key).', 'config.service_account_json');
|
|
145
|
+
for (const k of ['package_name', 'push_audience', 'push_service_account'])
|
|
146
|
+
if (!config[k])
|
|
147
|
+
throw (0, emu4_billing_core_1.bad)(`config.${k} is required for Google Play.`, `config.${k}`);
|
|
148
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(config.package_name))
|
|
149
|
+
throw (0, emu4_billing_core_1.bad)('package_name must be your Android package name.', 'config.package_name');
|
|
150
|
+
if (!/^https:\/\//.test(config.push_audience))
|
|
151
|
+
throw (0, emu4_billing_core_1.bad)('push_audience must be the https URL set as the audience on the Pub/Sub push subscription.', 'config.push_audience');
|
|
152
|
+
if (!/^[^@\s]+@[^@\s]+\.iam\.gserviceaccount\.com$/.test(config.push_service_account))
|
|
153
|
+
throw (0, emu4_billing_core_1.bad)('push_service_account must be the service account the push subscription authenticates as.', 'config.push_service_account');
|
|
154
|
+
}
|
|
155
|
+
return { environment, config };
|
|
156
|
+
}
|
|
157
|
+
// ── signing and verifying provider deliveries ──
|
|
158
|
+
const enc = new TextEncoder();
|
|
159
|
+
async function hmacHex(secret, material) {
|
|
160
|
+
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
161
|
+
return [...new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(material)))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
162
|
+
}
|
|
163
|
+
const same = (a, b) => {
|
|
164
|
+
let d = a.length ^ b.length;
|
|
165
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++)
|
|
166
|
+
d |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
167
|
+
return d === 0;
|
|
168
|
+
};
|
|
169
|
+
const STRIPE_TOLERANCE = 300;
|
|
170
|
+
const PADDLE_TOLERANCE = 300;
|
|
171
|
+
const sigError = (message = 'Invalid webhook signature.') => (0, emu4_billing_core_1.bad)(message);
|
|
172
|
+
/** The headers a provider signs a delivery with. `salt` breaks the signature on purpose. */
|
|
173
|
+
async function signProvider(provider, secret, raw, nowSec, salt = '') {
|
|
174
|
+
switch (provider) {
|
|
175
|
+
case 'stripe':
|
|
176
|
+
return { 'stripe-signature': `t=${nowSec},v1=${await hmacHex(secret + salt, `${nowSec}.${raw}`)}` };
|
|
177
|
+
case 'paddle':
|
|
178
|
+
return { 'paddle-signature': `ts=${nowSec};h1=${await hmacHex(secret + salt, `${nowSec}:${raw}`)}` };
|
|
179
|
+
case 'lemonsqueezy':
|
|
180
|
+
return { 'x-signature': await hmacHex(secret + salt, raw) };
|
|
181
|
+
case 'revenuecat':
|
|
182
|
+
return { authorization: `Bearer ${secret}${salt}` };
|
|
183
|
+
default:
|
|
184
|
+
throw (0, emu4_billing_core_1.bad)(`The emulator signs and verifies Stripe, Paddle, Lemon Squeezy and RevenueCat deliveries; ${provider} is not modelled.`, 'provider');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function verifyDelivery(e, provider, cfg, raw, headers) {
|
|
188
|
+
const secret = cfg.config.webhook_secret;
|
|
189
|
+
const nowSec = e.clock.now() / 1000;
|
|
190
|
+
switch (provider) {
|
|
191
|
+
case 'stripe': {
|
|
192
|
+
const header = headers.get('stripe-signature');
|
|
193
|
+
if (!secret || !header)
|
|
194
|
+
throw sigError();
|
|
195
|
+
let t = '';
|
|
196
|
+
const given = [];
|
|
197
|
+
for (const part of header.split(',')) {
|
|
198
|
+
const i = part.indexOf('=');
|
|
199
|
+
const k = part.slice(0, i).trim();
|
|
200
|
+
const v = part.slice(i + 1).trim();
|
|
201
|
+
if (k === 't')
|
|
202
|
+
t = v;
|
|
203
|
+
else if (k === 'v1')
|
|
204
|
+
given.push(v);
|
|
205
|
+
}
|
|
206
|
+
if (!/^\d+$/.test(t) || !given.length)
|
|
207
|
+
throw sigError();
|
|
208
|
+
const expected = await hmacHex(secret, `${t}.${raw}`);
|
|
209
|
+
if (!given.some((g) => same(g, expected)))
|
|
210
|
+
throw sigError();
|
|
211
|
+
if (Math.abs(nowSec - Number(t)) > STRIPE_TOLERANCE)
|
|
212
|
+
throw sigError('Webhook timestamp outside the tolerance window.');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case 'paddle': {
|
|
216
|
+
const header = headers.get('paddle-signature');
|
|
217
|
+
if (!secret || !header)
|
|
218
|
+
throw sigError();
|
|
219
|
+
let ts = '';
|
|
220
|
+
const given = [];
|
|
221
|
+
for (const part of header.split(';')) {
|
|
222
|
+
const i = part.indexOf('=');
|
|
223
|
+
const k = part.slice(0, i).trim();
|
|
224
|
+
const v = part.slice(i + 1).trim();
|
|
225
|
+
if (k === 'ts')
|
|
226
|
+
ts = v;
|
|
227
|
+
else if (k === 'h1' && /^[0-9a-f]+$/i.test(v))
|
|
228
|
+
given.push(v.toLowerCase());
|
|
229
|
+
}
|
|
230
|
+
if (!/^\d+$/.test(ts) || !given.length)
|
|
231
|
+
throw sigError();
|
|
232
|
+
const expected = await hmacHex(secret, `${ts}:${raw}`);
|
|
233
|
+
if (!given.some((g) => same(g, expected)))
|
|
234
|
+
throw sigError();
|
|
235
|
+
if (Math.abs(nowSec - Number(ts)) > PADDLE_TOLERANCE)
|
|
236
|
+
throw sigError('Webhook timestamp outside the tolerance window.');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
case 'lemonsqueezy': {
|
|
240
|
+
const given = headers.get('x-signature');
|
|
241
|
+
if (!secret || !given || !/^[0-9a-f]+$/i.test(given))
|
|
242
|
+
throw sigError();
|
|
243
|
+
if (!same(given.toLowerCase(), await hmacHex(secret, raw)))
|
|
244
|
+
throw sigError();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case 'revenuecat': {
|
|
248
|
+
const given = (headers.get('authorization') ?? '').trim();
|
|
249
|
+
if (!secret || !given || !(same(given, secret.trim()) || same(given, `Bearer ${secret.trim()}`)))
|
|
250
|
+
throw sigError();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
default:
|
|
254
|
+
throw (0, emu4_billing_core_1.withStatus)(new errors_1.EmulatorError('VALIDATION_FAILED', `The emulator verifies Stripe, Paddle, Lemon Squeezy and RevenueCat deliveries; ${provider} is not modelled.`, undefined, { meta: { reason: 'unsupported' } }), 422);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const REF_PREFIX = 'bsub';
|
|
258
|
+
const encodeRef = (r) => [REF_PREFIX, r.instance, r.type, r.id, r.plan].join(':');
|
|
259
|
+
exports.encodeRef = encodeRef;
|
|
260
|
+
function decodeRef(raw) {
|
|
261
|
+
if (typeof raw !== 'string' || !raw.startsWith(`${REF_PREFIX}:`))
|
|
262
|
+
return null;
|
|
263
|
+
const p = raw.split(':');
|
|
264
|
+
if (p.length !== 5 || (p[2] !== 'user' && p[2] !== 'org') || !p[1] || !p[3] || !p[4])
|
|
265
|
+
return null;
|
|
266
|
+
return { instance: p[1], type: p[2], id: p[3], plan: p[4] };
|
|
267
|
+
}
|
|
268
|
+
const hintOf = (id) => (typeof id === 'string' ? (/^user_[0-9A-Za-z]{10,}$/.test(id) ? { type: 'user', id } : /^org_[0-9A-Za-z]{10,}$/.test(id) ? { type: 'org', id } : null) : null);
|
|
269
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
270
|
+
const str = (v) => (typeof v === 'string' && v ? v : null);
|
|
271
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
272
|
+
const secs = (v) => (typeof v === 'number' ? v * 1000 : null);
|
|
273
|
+
const date = (v) => {
|
|
274
|
+
if (typeof v === 'number')
|
|
275
|
+
return v;
|
|
276
|
+
const t = typeof v === 'string' ? Date.parse(v) : NaN;
|
|
277
|
+
return Number.isNaN(t) ? null : t;
|
|
278
|
+
};
|
|
279
|
+
const blankSub = (id) => ({ id, customerId: null, status: 'incomplete', refs: [], quantity: null, currentPeriodEnd: null, cancelAtPeriodEnd: false, trialEnd: null, canceledAt: null, ref: null, accountToken: null, hint: null });
|
|
280
|
+
function normalizeStripe(body) {
|
|
281
|
+
const type = str(body.type) ?? '';
|
|
282
|
+
const o = obj(obj(body.data).object);
|
|
283
|
+
const sub = () => {
|
|
284
|
+
const item = obj(obj(o.items).data instanceof Array ? obj(o.items).data[0] : undefined);
|
|
285
|
+
return { ...blankSub(str(o.id) ?? ''), customerId: str(o.customer), status: str(o.status) ?? 'incomplete', refs: [str(obj(item.price).id)].filter(Boolean), quantity: num(item.quantity), currentPeriodEnd: secs(o.current_period_end), cancelAtPeriodEnd: o.cancel_at_period_end === true, trialEnd: secs(o.trial_end), canceledAt: secs(o.ended_at), ref: decodeRef(obj(o.metadata).atlas_ref) };
|
|
286
|
+
};
|
|
287
|
+
switch (type) {
|
|
288
|
+
case 'checkout.session.completed': {
|
|
289
|
+
const ref = decodeRef(o.client_reference_id);
|
|
290
|
+
const id = str(o.subscription);
|
|
291
|
+
return ref && id ? [{ kind: 'sub', sub: { ...blankSub(id), customerId: str(o.customer), status: 'active', ref, fromCheckout: true } }] : [];
|
|
292
|
+
}
|
|
293
|
+
case 'customer.subscription.created':
|
|
294
|
+
case 'customer.subscription.updated':
|
|
295
|
+
case 'customer.subscription.paused':
|
|
296
|
+
case 'customer.subscription.resumed':
|
|
297
|
+
return sub().id ? [{ kind: 'sub', sub: sub() }] : [];
|
|
298
|
+
case 'customer.subscription.deleted':
|
|
299
|
+
return sub().id ? [{ kind: 'sub', sub: { ...sub(), status: 'canceled', deleted: true } }] : [];
|
|
300
|
+
case 'customer.subscription.trial_will_end':
|
|
301
|
+
return [{ kind: 'will_end', subscriptionId: str(o.id) ?? '', trialEnd: secs(o.trial_end) }];
|
|
302
|
+
case 'invoice.finalized':
|
|
303
|
+
case 'invoice.paid':
|
|
304
|
+
case 'invoice.payment_succeeded':
|
|
305
|
+
case 'invoice.payment_failed':
|
|
306
|
+
case 'invoice.updated': {
|
|
307
|
+
const subscriptionId = str(o.subscription);
|
|
308
|
+
if (!subscriptionId)
|
|
309
|
+
return [];
|
|
310
|
+
return [{ kind: 'invoice', invoice: { id: str(o.id) ?? '', subscriptionId, number: str(o.number), status: str(o.status) ?? 'draft', amountDue: num(o.amount_due) ?? 0, amountPaid: num(o.amount_paid) ?? 0, currency: str(o.currency) ?? 'usd', failed: type === 'invoice.payment_failed', periodStart: secs(o.period_start), periodEnd: secs(o.period_end) } }];
|
|
311
|
+
}
|
|
312
|
+
default:
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function normalizePaddle(body) {
|
|
317
|
+
const type = str(body.event_type) ?? '';
|
|
318
|
+
const d = obj(body.data);
|
|
319
|
+
if (type.startsWith('subscription.')) {
|
|
320
|
+
const first = obj(Array.isArray(d.items) ? d.items[0] : undefined);
|
|
321
|
+
const status = str(d.status) ?? 'incomplete';
|
|
322
|
+
const sub = { ...blankSub(str(d.id) ?? ''), customerId: str(d.customer_id), status, refs: [str(obj(first.price).id) ?? str(first.price_id)].filter(Boolean), quantity: num(first.quantity), currentPeriodEnd: date(obj(d.current_billing_period).ends_at) ?? date(d.next_billed_at), cancelAtPeriodEnd: obj(d.scheduled_change).action === 'cancel', trialEnd: status === 'trialing' ? date(obj(first.trial_dates).ends_at) : null, canceledAt: date(d.canceled_at), ref: decodeRef(obj(d.custom_data).atlas_ref) };
|
|
323
|
+
if (!sub.id)
|
|
324
|
+
return [];
|
|
325
|
+
return [{ kind: 'sub', sub: type === 'subscription.canceled' ? { ...sub, status: 'canceled', deleted: true } : sub }];
|
|
326
|
+
}
|
|
327
|
+
if (['transaction.completed', 'transaction.paid', 'transaction.payment_failed', 'transaction.past_due'].includes(type)) {
|
|
328
|
+
const subscriptionId = str(d.subscription_id);
|
|
329
|
+
if (!subscriptionId)
|
|
330
|
+
return [];
|
|
331
|
+
const failed = type === 'transaction.payment_failed' || type === 'transaction.past_due';
|
|
332
|
+
const total = Number(str(obj(obj(d.details).totals).grand_total) ?? num(obj(obj(d.details).totals).grand_total) ?? 0);
|
|
333
|
+
const amount = Number.isFinite(total) ? total : 0;
|
|
334
|
+
const paid = !failed;
|
|
335
|
+
const period = obj(d.billing_period);
|
|
336
|
+
return [{ kind: 'invoice', invoice: { id: str(d.id) ?? '', subscriptionId, number: str(d.invoice_number), status: paid ? 'paid' : 'open', amountDue: amount, amountPaid: paid ? amount : 0, currency: (str(d.currency_code) ?? 'usd').toLowerCase(), failed, periodStart: date(period.starts_at), periodEnd: date(period.ends_at) } }];
|
|
337
|
+
}
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
function normalizeLemon(body) {
|
|
341
|
+
const name = str(obj(body.meta).event_name) ?? '';
|
|
342
|
+
const custom = obj(obj(body.meta).custom_data);
|
|
343
|
+
const d = obj(body.data);
|
|
344
|
+
const a = obj(d.attributes);
|
|
345
|
+
const asId = (v) => (v === undefined || v === null ? null : String(v));
|
|
346
|
+
if (name.startsWith('subscription_') && !name.startsWith('subscription_payment_')) {
|
|
347
|
+
const raw = str(a.status) ?? 'incomplete';
|
|
348
|
+
const cancelled = a.cancelled === true || raw === 'cancelled';
|
|
349
|
+
const map = { on_trial: 'trialing', active: 'active', paused: 'paused', past_due: 'past_due', unpaid: 'unpaid', cancelled: 'active', expired: 'canceled' };
|
|
350
|
+
const ended = raw === 'expired';
|
|
351
|
+
const sub = { ...blankSub(str(d.id) ?? ''), customerId: asId(a.customer_id), status: map[raw] ?? raw, refs: [asId(a.variant_id)].filter(Boolean), quantity: num(obj(a.first_subscription_item).quantity), currentPeriodEnd: date(cancelled ? (a.ends_at ?? a.renews_at) : a.renews_at) ?? date(a.ends_at), cancelAtPeriodEnd: cancelled && !ended, trialEnd: raw === 'on_trial' ? date(a.trial_ends_at) : null, canceledAt: ended ? (date(a.ends_at) ?? 0) : null, ref: decodeRef(custom.atlas_ref) };
|
|
352
|
+
if (!sub.id)
|
|
353
|
+
return [];
|
|
354
|
+
return [{ kind: 'sub', sub: name === 'subscription_expired' ? { ...sub, status: 'canceled', deleted: true } : sub }];
|
|
355
|
+
}
|
|
356
|
+
if (['subscription_payment_success', 'subscription_payment_recovered', 'subscription_payment_failed'].includes(name)) {
|
|
357
|
+
const subscriptionId = asId(a.subscription_id);
|
|
358
|
+
if (!subscriptionId)
|
|
359
|
+
return [];
|
|
360
|
+
const failed = name === 'subscription_payment_failed';
|
|
361
|
+
const total = num(a.total) ?? 0;
|
|
362
|
+
const paid = !failed && str(a.status) === 'paid';
|
|
363
|
+
return [{ kind: 'invoice', invoice: { id: str(d.id) ?? '', subscriptionId, number: null, status: failed ? 'open' : paid ? 'paid' : 'open', amountDue: total, amountPaid: paid ? total : 0, currency: (str(a.currency) ?? 'usd').toLowerCase(), failed, periodStart: null, periodEnd: null } }];
|
|
364
|
+
}
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
const RC_ENTITLING = new Set(['INITIAL_PURCHASE', 'RENEWAL', 'UNCANCELLATION', 'PRODUCT_CHANGE', 'SUBSCRIPTION_EXTENDED', 'TEMPORARY_ENTITLEMENT_GRANT']);
|
|
368
|
+
function normalizeRevenueCat(body, environment) {
|
|
369
|
+
const ev = obj(body.event);
|
|
370
|
+
const type = str(ev.type) ?? '';
|
|
371
|
+
if (str(ev.environment) !== (environment === 'live' ? 'PRODUCTION' : 'SANDBOX'))
|
|
372
|
+
return [];
|
|
373
|
+
const id = str(ev.original_transaction_id) ?? str(ev.transaction_id);
|
|
374
|
+
const appUser = str(ev.app_user_id) ?? str(ev.original_app_user_id);
|
|
375
|
+
const hint = hintOf(appUser);
|
|
376
|
+
if (!id || !hint)
|
|
377
|
+
return [];
|
|
378
|
+
const product = str(ev.new_product_id) ?? str(ev.product_id);
|
|
379
|
+
const entitlements = Array.isArray(ev.entitlement_ids) ? ev.entitlement_ids.filter((x) => typeof x === 'string') : [];
|
|
380
|
+
const expiry = num(ev.expiration_at_ms);
|
|
381
|
+
const base = { ...blankSub(id), customerId: appUser, status: 'active', refs: [product, ...entitlements].filter(Boolean), quantity: 1, currentPeriodEnd: expiry, hint };
|
|
382
|
+
if (RC_ENTITLING.has(type)) {
|
|
383
|
+
const trial = str(ev.period_type) === 'TRIAL';
|
|
384
|
+
return [{ kind: 'sub', sub: { ...base, status: trial ? 'trialing' : 'active', trialEnd: trial ? expiry : null } }];
|
|
385
|
+
}
|
|
386
|
+
switch (type) {
|
|
387
|
+
case 'CANCELLATION': {
|
|
388
|
+
const reason = str(ev.cancel_reason);
|
|
389
|
+
return reason === 'CUSTOMER_SUPPORT' || reason === 'DEVELOPER_INITIATED' ? [{ kind: 'sub', sub: { ...base, status: 'canceled', deleted: true } }] : [{ kind: 'sub', sub: { ...base, cancelAtPeriodEnd: true } }];
|
|
390
|
+
}
|
|
391
|
+
case 'BILLING_ISSUE':
|
|
392
|
+
return [{ kind: 'sub', sub: { ...base, status: 'past_due' } }];
|
|
393
|
+
case 'SUBSCRIPTION_PAUSED':
|
|
394
|
+
return [{ kind: 'sub', sub: { ...base, status: 'paused' } }];
|
|
395
|
+
case 'EXPIRATION':
|
|
396
|
+
return [{ kind: 'sub', sub: { ...base, status: 'canceled', deleted: true } }];
|
|
397
|
+
default:
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// ── the reducer: what a verified delivery does to the emulator's billing state ──
|
|
402
|
+
/** A plan sells to users unless it says org (the generic row leaves the field empty). */
|
|
403
|
+
const audienceOf = (plan) => (plan.audience === 'org' ? 'org' : 'user');
|
|
404
|
+
const planTable = (e) => e.generic.get('billing/plans');
|
|
405
|
+
/** The plan a provider price reference (or one of its alternate spellings) is configured as. */
|
|
406
|
+
function matchPlan(e, provider, refs) {
|
|
407
|
+
const plans = planTable(e)?.all() ?? [];
|
|
408
|
+
for (const ref of refs) {
|
|
409
|
+
for (const p of plans) {
|
|
410
|
+
if ((p.provider ?? 'stripe') !== provider)
|
|
411
|
+
continue;
|
|
412
|
+
if (p.provider_price_id === ref || p.stripe_price_id === ref)
|
|
413
|
+
return { plan: p, interval: p.interval === 'year' ? 'year' : 'month' };
|
|
414
|
+
if (p.annual_price_id === ref)
|
|
415
|
+
return { plan: p, interval: 'year' };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
/** A subscription of this provider's, filed through the token a store link minted: only for a token of this instance, and only when the product is the plan the token was minted for. */
|
|
421
|
+
function tokenRef(e, provider, n) {
|
|
422
|
+
if (!n.accountToken)
|
|
423
|
+
return null;
|
|
424
|
+
const link = st(e).storeLinks.find((l) => l.token === n.accountToken);
|
|
425
|
+
const m = link ? matchPlan(e, provider, n.refs) : undefined;
|
|
426
|
+
return link && m && m.plan.id === link.plan_id ? { instance: e.instanceId, type: link.subject_type, id: link.subject_id, plan: link.plan_id } : null;
|
|
427
|
+
}
|
|
428
|
+
/** A subscription whose provider customer IS the subject (RevenueCat app user id = Atlas id): the subject must exist and the plan be of this provider and audience. */
|
|
429
|
+
function hintRef(e, provider, n) {
|
|
430
|
+
const h = n.hint;
|
|
431
|
+
if (!h)
|
|
432
|
+
return null;
|
|
433
|
+
const exists = h.type === 'user' ? e.db.users.get(h.id) && !e.db.users.get(h.id).deleted : e.db.organizations.get(h.id);
|
|
434
|
+
const m = exists ? matchPlan(e, provider, n.refs) : undefined;
|
|
435
|
+
return m && audienceOf(m.plan) === h.type ? { instance: e.instanceId, type: h.type, id: h.id, plan: m.plan.id } : null;
|
|
436
|
+
}
|
|
437
|
+
const moveSig = (r) => JSON.stringify([r.plan_id, r.status, r.interval ?? 'month', r.quantity ?? 1, Boolean(r.cancel_at_period_end), r.current_period_end, r.scheduled_plan_id ?? null]);
|
|
438
|
+
function applySub(e, provider, n) {
|
|
439
|
+
const s = billing_1.billingInternals.st(e);
|
|
440
|
+
let row = s.subs.find((r) => r.provider === provider && r.stripe_subscription_id === n.id);
|
|
441
|
+
const now = e.clock.now();
|
|
442
|
+
if (!row) {
|
|
443
|
+
const ref = n.ref ?? (n.deleted ? null : (tokenRef(e, provider, n) ?? hintRef(e, provider, n)));
|
|
444
|
+
if (!ref || ref.instance !== e.instanceId)
|
|
445
|
+
return false;
|
|
446
|
+
const named = billing_1.billingInternals.planOf(e, ref.plan);
|
|
447
|
+
if (!named)
|
|
448
|
+
return false;
|
|
449
|
+
const m = matchPlan(e, provider, n.refs);
|
|
450
|
+
// the plan the subscription is actually on wins over the one the reference names, as long as it is for this audience
|
|
451
|
+
const plan = m && audienceOf(m.plan) === ref.type && m.plan.id !== named.id ? m.plan : named;
|
|
452
|
+
const interval = m && m.plan.id === plan.id ? m.interval : plan.interval === 'year' ? 'year' : 'month';
|
|
453
|
+
const before = billing_1.billingInternals.entSnap(e, ref.type, ref.id);
|
|
454
|
+
const status = n.deleted ? 'canceled' : n.status;
|
|
455
|
+
row = s.subs.insert({ subject_type: ref.type, subject_id: ref.id, plan_id: plan.id, status, stripe_subscription_id: n.id, stripe_customer_id: n.customerId, current_period_end: n.currentPeriodEnd ?? now + billing_1.billingInternals.periodLen(interval), provider, interval, quantity: n.quantity ?? 1, cancel_at_period_end: n.cancelAtPeriodEnd, canceled_at: n.deleted ? (n.canceledAt ?? now) : null, scheduled_plan_id: null, scheduled_interval: null, scheduled_change_at: null, payment_failing: false, entitlements: null, trial_start: status === 'trialing' ? now : null, trial_end: n.trialEnd, external: true, pending_lines: [], created_at: now, updated_at: now });
|
|
456
|
+
billing_1.billingInternals.emitSub(e, 'billing_subscription.created', row, null);
|
|
457
|
+
const who = `${ref.type}|${ref.id}`;
|
|
458
|
+
if (row.status === 'trialing' && !st(e).trials.has(who)) {
|
|
459
|
+
st(e).trials.add(who);
|
|
460
|
+
billing_1.billingInternals.emitSub(e, 'billing_trial.started', row, null);
|
|
461
|
+
}
|
|
462
|
+
billing_1.billingInternals.emitEnt(e, ref.type, ref.id, before);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
if (n.fromCheckout)
|
|
466
|
+
return true; // a completed checkout must not regress what the subscription events already filed
|
|
467
|
+
const prev = { ...row };
|
|
468
|
+
const before = billing_1.billingInternals.entSnap(e, row.subject_type, row.subject_id);
|
|
469
|
+
const m = matchPlan(e, provider, n.refs);
|
|
470
|
+
const status = n.deleted ? 'canceled' : n.status;
|
|
471
|
+
Object.assign(row, {
|
|
472
|
+
...(m ? { plan_id: m.plan.id, interval: m.interval } : {}),
|
|
473
|
+
status,
|
|
474
|
+
...(n.currentPeriodEnd !== null ? { current_period_end: n.currentPeriodEnd } : {}),
|
|
475
|
+
...(n.quantity !== null ? { quantity: n.quantity } : {}),
|
|
476
|
+
cancel_at_period_end: status === 'canceled' ? false : n.cancelAtPeriodEnd,
|
|
477
|
+
...(n.trialEnd !== null ? { trial_end: n.trialEnd } : {}),
|
|
478
|
+
...(!row.stripe_customer_id && n.customerId ? { stripe_customer_id: n.customerId } : {}),
|
|
479
|
+
...(status === 'canceled' ? { canceled_at: n.canceledAt || row.canceled_at || now, scheduled_plan_id: null, scheduled_interval: null, scheduled_change_at: null } : {}),
|
|
480
|
+
...(status !== 'canceled' && n.cancelAtPeriodEnd && !row.canceled_at ? { canceled_at: now } : {}),
|
|
481
|
+
updated_at: now,
|
|
482
|
+
});
|
|
483
|
+
if (row.scheduled_plan_id && m && m.plan.id === row.scheduled_plan_id)
|
|
484
|
+
Object.assign(row, { scheduled_plan_id: null, scheduled_interval: null, scheduled_change_at: null });
|
|
485
|
+
if (row.status === 'canceled' && prev.status !== 'canceled')
|
|
486
|
+
billing_1.billingInternals.emitSub(e, 'billing_subscription.canceled', row, prev);
|
|
487
|
+
else if (moveSig(prev) !== moveSig(row))
|
|
488
|
+
billing_1.billingInternals.emitSub(e, 'billing_subscription.updated', row, prev);
|
|
489
|
+
const who = `${row.subject_type}|${row.subject_id}`;
|
|
490
|
+
if (row.status === 'trialing' && prev.status !== 'trialing' && !st(e).trials.has(who)) {
|
|
491
|
+
st(e).trials.add(who);
|
|
492
|
+
billing_1.billingInternals.emitSub(e, 'billing_trial.started', row, null);
|
|
493
|
+
}
|
|
494
|
+
else if (prev.status === 'trialing' && row.status !== 'trialing')
|
|
495
|
+
billing_1.billingInternals.emitSub(e, 'billing_trial.ended', row, prev, undefined, { converted: row.status === 'active' });
|
|
496
|
+
billing_1.billingInternals.emitEnt(e, row.subject_type, row.subject_id, before);
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
function applyInvoice(e, provider, n, failedEvent) {
|
|
500
|
+
const s = billing_1.billingInternals.st(e);
|
|
501
|
+
const sub = s.subs.find((r) => r.provider === provider && r.stripe_subscription_id === n.subscriptionId);
|
|
502
|
+
if (!sub)
|
|
503
|
+
return false;
|
|
504
|
+
const have = s.invoices.find((i) => i.provider === provider && i.provider_invoice_id === n.id);
|
|
505
|
+
const fields = { subject_type: sub.subject_type, subject_id: sub.subject_id, subscription_id: sub.id, status: n.status, currency: n.currency, amount_due: n.amountDue, amount_paid: n.amountPaid, provider, provider_invoice_id: n.id, number: n.number };
|
|
506
|
+
if (have)
|
|
507
|
+
Object.assign(have, fields);
|
|
508
|
+
else
|
|
509
|
+
s.invoices.insert({ ...fields, amount_refunded: 0, lines: [], created_at: e.clock.now() });
|
|
510
|
+
if (failedEvent)
|
|
511
|
+
billing_1.billingInternals.emitSub(e, 'billing_invoice.payment_failed', sub, null, undefined, { invoice: { provider_invoice_id: n.id, number: n.number, amount_due: n.amountDue, currency: n.currency, hosted_invoice_url: null } });
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
/** Apply normalised events; returns whether any of them found a subscription to move. */
|
|
515
|
+
function reduce(e, provider, events) {
|
|
516
|
+
return events.map((ev) => {
|
|
517
|
+
if (ev.kind === 'sub')
|
|
518
|
+
return applySub(e, provider, ev.sub);
|
|
519
|
+
if (ev.kind === 'invoice')
|
|
520
|
+
return applyInvoice(e, provider, ev.invoice, ev.invoice.failed);
|
|
521
|
+
const sub = billing_1.billingInternals.st(e).subs.find((r) => r.provider === provider && r.stripe_subscription_id === ev.subscriptionId);
|
|
522
|
+
if (!sub)
|
|
523
|
+
return false;
|
|
524
|
+
billing_1.billingInternals.emitSub(e, 'billing_trial.will_end', sub, null, undefined, { trial_end: ev.trialEnd ?? sub.trial_end ?? null });
|
|
525
|
+
return true;
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
// ── the raw-body hook: a signature covers the exact bytes ──
|
|
529
|
+
const RAW_BILLING = /^\/v1\/webhooks\/billing\/[^/]+\/[a-z]+\/?$/;
|
|
530
|
+
const rawBodies = new WeakMap();
|
|
531
|
+
/** Called once by the engine: hand the provider webhook route the bytes that were signed. */
|
|
532
|
+
function installBillingWebhooks(e) {
|
|
533
|
+
const handle = e.handle.bind(e);
|
|
534
|
+
e.handle = async (request) => {
|
|
535
|
+
if (request.method !== 'POST' || !RAW_BILLING.test(new URL(request.url).pathname))
|
|
536
|
+
return handle(request);
|
|
537
|
+
const raw = await request.text();
|
|
538
|
+
const headers = new Headers(request.headers);
|
|
539
|
+
headers.set('content-type', 'application/json');
|
|
540
|
+
const next = new Request(request.url, { method: 'POST', headers, body: '{}' });
|
|
541
|
+
rawBodies.set(next, raw);
|
|
542
|
+
return handle(next);
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
async function receive(c) {
|
|
546
|
+
const e = c.engine;
|
|
547
|
+
const provider = c.params.provider;
|
|
548
|
+
if (!PROVIDER_NAMES.includes(provider))
|
|
549
|
+
throw (0, emu4_billing_core_1.withStatus)(new errors_1.EmulatorError('NOT_FOUND', 'Unknown billing provider.'), 404);
|
|
550
|
+
const cfg = c.params.instanceId === e.instanceId ? (0, emu4_billing_core_1.providerOf)(e, provider) : undefined;
|
|
551
|
+
if (!cfg || !cfg.config[emu4_billing_core_1.WEBHOOK_SECRET[provider]])
|
|
552
|
+
throw (0, emu4_billing_core_1.withStatus)(new errors_1.EmulatorError('NOT_CONFIGURED', 'Billing webhooks are not configured for this instance and provider.'), 404);
|
|
553
|
+
const raw = rawBodies.get(c.request) ?? JSON.stringify(c.body);
|
|
554
|
+
await verifyDelivery(e, provider, cfg, raw, c.request.headers);
|
|
555
|
+
let body = {};
|
|
556
|
+
try {
|
|
557
|
+
body = JSON.parse(raw);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
body = {};
|
|
561
|
+
}
|
|
562
|
+
cfg.last_webhook_at = e.clock.now();
|
|
563
|
+
billing_1.billingInternals.settle(e);
|
|
564
|
+
const events = provider === 'stripe' ? normalizeStripe(body) : provider === 'paddle' ? normalizePaddle(body) : provider === 'lemonsqueezy' ? normalizeLemon(body) : normalizeRevenueCat(body, cfg.environment);
|
|
565
|
+
reduce(e, provider, events);
|
|
566
|
+
return { status: 200, body: { received: true } };
|
|
567
|
+
}
|
|
568
|
+
// ── playing a provider: build, sign and deliver a delivery ──
|
|
569
|
+
const WEBHOOK_PROVIDERS = ['stripe', 'paddle', 'lemonsqueezy', 'revenuecat'];
|
|
570
|
+
const SCENARIOS = ['subscription.created', 'subscription.updated', 'subscription.canceled', 'trial.will_end', 'payment.succeeded', 'payment.failed'];
|
|
571
|
+
function priceRefOf(plan, interval, provider) {
|
|
572
|
+
if (!plan)
|
|
573
|
+
return 'price_unknown';
|
|
574
|
+
const own = provider === 'stripe' ? [plan.stripe_price_id, plan.provider_price_id] : [plan.provider_price_id, plan.stripe_price_id];
|
|
575
|
+
return String((interval === 'year' ? plan.annual_price_id : undefined) ?? own.find((x) => typeof x === 'string' && x) ?? `price_${plan.id}`);
|
|
576
|
+
}
|
|
577
|
+
/** The provider-shaped payload for a scenario. */
|
|
578
|
+
function buildPayload(e, provider, cfg, b) {
|
|
579
|
+
const event = (0, support_1.oneOf)(b, 'event', SCENARIOS);
|
|
580
|
+
const subId = typeof b.subscription_id === 'string' && b.subscription_id ? b.subscription_id : (() => { throw (0, emu4_billing_core_1.bad)('subscription_id is required (the provider\'s own subscription id).', 'subscription_id'); })();
|
|
581
|
+
const plan = typeof b.plan_id === 'string' ? billing_1.billingInternals.planOf(e, b.plan_id) : undefined;
|
|
582
|
+
if (typeof b.plan_id === 'string' && !plan)
|
|
583
|
+
throw (0, errors_1.notFound)('Billing plan');
|
|
584
|
+
const interval = (0, support_1.oneOf)(b, 'interval', ['month', 'year'], 'month');
|
|
585
|
+
const subjectType = b.subject_type === undefined ? undefined : (0, support_1.oneOf)(b, 'subject_type', ['user', 'org']);
|
|
586
|
+
const subjectId = typeof b.subject_id === 'string' ? b.subject_id : undefined;
|
|
587
|
+
const ref = !b.omit_ref && plan && subjectType && subjectId ? (0, exports.encodeRef)({ instance: e.instanceId, type: subjectType, id: subjectId, plan: plan.id }) : undefined;
|
|
588
|
+
const priceRef = typeof b.product_id === 'string' ? b.product_id : priceRefOf(plan, interval, provider);
|
|
589
|
+
const customer = typeof b.customer_id === 'string' ? b.customer_id : `cus_${subId}`;
|
|
590
|
+
const now = e.clock.now();
|
|
591
|
+
const end = typeof b.current_period_end === 'number' ? b.current_period_end : now + billing_1.billingInternals.periodLen(interval);
|
|
592
|
+
const trialEnd = typeof b.trial_end === 'number' ? b.trial_end : null;
|
|
593
|
+
const cancelAtEnd = b.cancel_at_period_end === true;
|
|
594
|
+
const amount = typeof b.amount === 'number' ? b.amount : billing_1.billingInternals.priceCents(plan, interval);
|
|
595
|
+
const currency = typeof b.currency === 'string' ? b.currency : 'usd';
|
|
596
|
+
const status = typeof b.status === 'string' ? b.status : event === 'payment.failed' ? 'past_due' : trialEnd ? 'trialing' : 'active';
|
|
597
|
+
const evId = `evt_${Math.floor(now)}_${subId}`;
|
|
598
|
+
const invoiceId = typeof b.invoice_id === 'string' ? b.invoice_id : `inv_${Math.floor(now)}_${subId}`;
|
|
599
|
+
if (provider === 'stripe') {
|
|
600
|
+
const subObj = { id: subId, object: 'subscription', customer, status: event === 'subscription.canceled' ? 'canceled' : status, cancel_at_period_end: cancelAtEnd, current_period_end: Math.floor(end / 1000), trial_end: trialEnd ? Math.floor(trialEnd / 1000) : null, ended_at: event === 'subscription.canceled' ? Math.floor(now / 1000) : null, items: { data: [{ id: `si_${subId}`, quantity: typeof b.quantity === 'number' ? b.quantity : 1, price: { id: priceRef } }] }, metadata: ref ? { atlas_ref: ref } : {} };
|
|
601
|
+
switch (event) {
|
|
602
|
+
case 'subscription.created': return { id: evId, object: 'event', type: 'customer.subscription.created', data: { object: subObj } };
|
|
603
|
+
case 'subscription.updated': return { id: evId, object: 'event', type: 'customer.subscription.updated', data: { object: subObj } };
|
|
604
|
+
case 'subscription.canceled': return { id: evId, object: 'event', type: 'customer.subscription.deleted', data: { object: subObj } };
|
|
605
|
+
case 'trial.will_end': return { id: evId, object: 'event', type: 'customer.subscription.trial_will_end', data: { object: subObj } };
|
|
606
|
+
default: return { id: evId, object: 'event', type: event === 'payment.failed' ? 'invoice.payment_failed' : 'invoice.payment_succeeded', data: { object: { id: invoiceId, object: 'invoice', subscription: subId, customer, number: invoiceId.toUpperCase(), status: event === 'payment.failed' ? 'open' : 'paid', amount_due: amount, amount_paid: event === 'payment.failed' ? 0 : amount, currency, period_start: Math.floor(now / 1000), period_end: Math.floor(end / 1000) } } };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (provider === 'paddle') {
|
|
610
|
+
const sub = { id: subId, customer_id: customer, status: event === 'subscription.canceled' ? 'canceled' : status, items: [{ price: { id: priceRef }, quantity: typeof b.quantity === 'number' ? b.quantity : 1, ...(trialEnd ? { trial_dates: { ends_at: new Date(trialEnd).toISOString() } } : {}) }], current_billing_period: { starts_at: new Date(now).toISOString(), ends_at: new Date(end).toISOString() }, scheduled_change: cancelAtEnd ? { action: 'cancel', effective_at: new Date(end).toISOString() } : null, canceled_at: event === 'subscription.canceled' ? new Date(now).toISOString() : null, custom_data: ref ? { atlas_ref: ref } : {} };
|
|
611
|
+
switch (event) {
|
|
612
|
+
case 'subscription.created': return { event_id: evId, event_type: 'subscription.created', data: sub };
|
|
613
|
+
case 'subscription.updated': return { event_id: evId, event_type: 'subscription.updated', data: sub };
|
|
614
|
+
case 'subscription.canceled': return { event_id: evId, event_type: 'subscription.canceled', data: sub };
|
|
615
|
+
case 'trial.will_end': throw (0, emu4_billing_core_1.bad)('Paddle sends no trial warning; use subscription.updated.', 'event');
|
|
616
|
+
default: return { event_id: evId, event_type: event === 'payment.failed' ? 'transaction.payment_failed' : 'transaction.completed', data: { id: invoiceId, subscription_id: subId, customer_id: customer, invoice_number: invoiceId.toUpperCase(), currency_code: currency.toUpperCase(), details: { totals: { grand_total: String(amount) } }, billing_period: { starts_at: new Date(now).toISOString(), ends_at: new Date(end).toISOString() } } };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (provider === 'lemonsqueezy') {
|
|
620
|
+
const lemonStatus = event === 'subscription.canceled' ? 'expired' : cancelAtEnd ? 'cancelled' : trialEnd ? 'on_trial' : status === 'past_due' ? 'past_due' : 'active';
|
|
621
|
+
const meta = (name) => ({ event_name: name, custom_data: ref ? { atlas_ref: ref } : {} });
|
|
622
|
+
const sub = { id: subId, type: 'subscriptions', attributes: { status: lemonStatus, cancelled: cancelAtEnd, customer_id: customer, variant_id: priceRef, renews_at: new Date(end).toISOString(), ends_at: cancelAtEnd || event === 'subscription.canceled' ? new Date(end).toISOString() : null, trial_ends_at: trialEnd ? new Date(trialEnd).toISOString() : null, first_subscription_item: { quantity: typeof b.quantity === 'number' ? b.quantity : 1 } } };
|
|
623
|
+
switch (event) {
|
|
624
|
+
case 'subscription.created': return { meta: meta('subscription_created'), data: sub };
|
|
625
|
+
case 'subscription.updated': return { meta: meta('subscription_updated'), data: sub };
|
|
626
|
+
case 'subscription.canceled': return { meta: meta('subscription_expired'), data: sub };
|
|
627
|
+
case 'trial.will_end': throw (0, emu4_billing_core_1.bad)('Lemon Squeezy sends no trial warning; use subscription.updated.', 'event');
|
|
628
|
+
default: return { meta: meta(event === 'payment.failed' ? 'subscription_payment_failed' : 'subscription_payment_success'), data: { id: invoiceId, type: 'subscription-invoices', attributes: { subscription_id: subId, customer_id: customer, total: amount, status: event === 'payment.failed' ? 'open' : 'paid', currency, urls: { invoice_url: null } } } };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// revenuecat
|
|
632
|
+
const environment = typeof b.environment === 'string' ? b.environment : cfg.environment === 'live' ? 'PRODUCTION' : 'SANDBOX';
|
|
633
|
+
const user = subjectId ?? (typeof b.app_user_id === 'string' ? b.app_user_id : '');
|
|
634
|
+
const rc = (type, extra = {}) => ({ api_version: '1.0', event: { type, id: evId, app_user_id: user, original_app_user_id: user, original_transaction_id: subId, transaction_id: subId, product_id: priceRef, expiration_at_ms: end, environment, period_type: trialEnd ? 'TRIAL' : 'NORMAL', entitlement_ids: [], ...extra } });
|
|
635
|
+
switch (event) {
|
|
636
|
+
case 'subscription.created': return rc('INITIAL_PURCHASE');
|
|
637
|
+
case 'subscription.updated': return cancelAtEnd ? rc('CANCELLATION', { cancel_reason: 'UNSUBSCRIBE' }) : rc(typeof b.product_id === 'string' ? 'PRODUCT_CHANGE' : 'RENEWAL');
|
|
638
|
+
case 'subscription.canceled': return rc('EXPIRATION');
|
|
639
|
+
case 'payment.failed': return rc('BILLING_ISSUE');
|
|
640
|
+
default: throw (0, emu4_billing_core_1.bad)(`RevenueCat has no ${event} event.`, 'event');
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function signed(c) {
|
|
644
|
+
const e = c.engine;
|
|
645
|
+
const provider = (0, support_1.oneOf)(c.body, 'provider', WEBHOOK_PROVIDERS);
|
|
646
|
+
const cfg = (0, emu4_billing_core_1.providerOf)(e, provider);
|
|
647
|
+
const secret = cfg?.config.webhook_secret;
|
|
648
|
+
if (!cfg || !secret)
|
|
649
|
+
throw (0, emu4_billing_core_1.bad)(`Configure ${provider} first: PUT /v1/billing/providers/${provider} with config.webhook_secret.`, 'provider');
|
|
650
|
+
const tamper = c.body.tamper === undefined ? undefined : (0, support_1.oneOf)(c.body, 'tamper', ['signature', 'stale']);
|
|
651
|
+
const payload = c.body.payload !== undefined ? obj(c.body.payload) : buildPayload(e, provider, cfg, c.body);
|
|
652
|
+
const raw = JSON.stringify(payload);
|
|
653
|
+
const nowSec = Math.floor(e.clock.now() / 1000) - (tamper === 'stale' ? 3600 : 0);
|
|
654
|
+
const headers = await signProvider(provider, secret, raw, nowSec, tamper === 'signature' ? 'tampered' : '');
|
|
655
|
+
return { provider, url: webhookUrl(e, provider), headers: { 'content-type': 'application/json', ...headers }, body: raw };
|
|
656
|
+
}
|
|
657
|
+
// ── store purchases: the verified notification of an app store, played ──
|
|
658
|
+
const STORE_EVENTS = ['purchase', 'renewal', 'cancel', 'billing_issue', 'expire', 'refund'];
|
|
659
|
+
function emu4BillingHandlers() {
|
|
660
|
+
const settled = (r) => ({ ...r, handler: (c) => (billing_1.billingInternals.settle(c.engine), r.handler(c)) });
|
|
661
|
+
const routes = [
|
|
662
|
+
// ── provider credentials ──
|
|
663
|
+
{
|
|
664
|
+
method: 'GET',
|
|
665
|
+
pattern: '/v1/billing/providers',
|
|
666
|
+
handler: (c) => ({ body: { object: 'list', data: [...(0, emu4_billing_core_1.providerState)(c.engine).values()].map((p) => providerView(c.engine, p)), has_more: false } }),
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
method: 'GET',
|
|
670
|
+
pattern: '/v1/billing/providers/:provider',
|
|
671
|
+
handler: (c) => {
|
|
672
|
+
const provider = parseProvider(c.params.provider);
|
|
673
|
+
const cfg = (0, emu4_billing_core_1.providerOf)(c.engine, provider);
|
|
674
|
+
if (!cfg)
|
|
675
|
+
return { body: { object: 'billing_provider', configured: false, provider, message: `No ${provider} credentials are configured. PUT /v1/billing/providers/${provider} to add them.` } };
|
|
676
|
+
return { body: providerView(c.engine, cfg) };
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
method: 'PUT',
|
|
681
|
+
pattern: '/v1/billing/providers/:provider',
|
|
682
|
+
scope: 'billing:write',
|
|
683
|
+
handler: (c) => {
|
|
684
|
+
const e = c.engine;
|
|
685
|
+
const provider = parseProvider(c.params.provider);
|
|
686
|
+
const existed = (0, emu4_billing_core_1.providerOf)(e, provider);
|
|
687
|
+
const { environment, config } = prepareConfig(e, provider, c.body);
|
|
688
|
+
const now = e.clock.now();
|
|
689
|
+
const cfg = { provider, environment, status: 'active', config, last_verified_at: existed?.last_verified_at ?? null, last_webhook_at: existed?.last_webhook_at ?? null, updated_at: now };
|
|
690
|
+
(0, emu4_billing_core_1.providerState)(e).set(provider, cfg);
|
|
691
|
+
(0, emu4_billing_core_1.audit)(e, `billing_provider.${existed ? 'updated' : 'created'}`, 'billing_provider', provider, { provider });
|
|
692
|
+
return { status: existed ? 200 : 201, body: providerView(e, cfg) };
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
method: 'POST',
|
|
697
|
+
pattern: '/v1/billing/providers/:provider/test',
|
|
698
|
+
scope: 'billing:write',
|
|
699
|
+
handler: (c) => {
|
|
700
|
+
const e = c.engine;
|
|
701
|
+
const provider = parseProvider(c.params.provider);
|
|
702
|
+
const cfg = (0, emu4_billing_core_1.providerOf)(e, provider);
|
|
703
|
+
if (!cfg)
|
|
704
|
+
throw new errors_1.EmulatorError('NOT_FOUND', `No ${provider} credentials are configured.`);
|
|
705
|
+
(0, emu4_billing_core_1.audit)(e, 'billing_provider.tested', 'billing_provider', provider, { provider });
|
|
706
|
+
// The emulator reaches no provider: a stored key passes, except the two checks the real adapters make before calling out.
|
|
707
|
+
if (provider === 'revenuecat' && !cfg.config.project_id)
|
|
708
|
+
return { body: { object: 'billing_provider_test', provider, ok: false, error: 'Set config.project_id (your RevenueCat project id).', account: null } };
|
|
709
|
+
cfg.last_verified_at = e.clock.now();
|
|
710
|
+
return { body: { object: 'billing_provider_test', provider, ok: true, error: null, account: provider === 'stripe' ? 'acct_emulator' : provider === 'revenuecat' ? (cfg.config.project_id ?? null) : null } };
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
method: 'DELETE',
|
|
715
|
+
pattern: '/v1/billing/providers/:provider',
|
|
716
|
+
scope: 'billing:write',
|
|
717
|
+
handler: (c) => {
|
|
718
|
+
const e = c.engine;
|
|
719
|
+
const provider = parseProvider(c.params.provider);
|
|
720
|
+
if (!(0, emu4_billing_core_1.providerOf)(e, provider))
|
|
721
|
+
throw new errors_1.EmulatorError('NOT_FOUND', `No ${provider} credentials are configured.`);
|
|
722
|
+
const live = billing_1.billingInternals.st(e).subs.filter((s) => s.provider === provider && ['active', 'trialing', 'past_due', 'unpaid', 'paused'].includes(s.status)).length;
|
|
723
|
+
if (live > 0 && c.query.get('force') !== 'true')
|
|
724
|
+
throw new errors_1.EmulatorError('CONFLICT', `${live} live subscription(s) run on these ${provider} credentials. Removing them stops their webhooks and plan changes. Pass force=true to remove anyway.`, undefined, { meta: { live_subscriptions: live } });
|
|
725
|
+
(0, emu4_billing_core_1.providerState)(e).delete(provider);
|
|
726
|
+
(0, emu4_billing_core_1.audit)(e, 'billing_provider.deleted', 'billing_provider', provider, { provider });
|
|
727
|
+
return { body: { object: 'billing_provider', provider, deleted: true } };
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
// ── the per-instance provider webhook ──
|
|
731
|
+
{ method: 'POST', pattern: '/v1/webhooks/billing/:instanceId/:provider', public: true, handler: receive },
|
|
732
|
+
{
|
|
733
|
+
method: 'POST',
|
|
734
|
+
pattern: '/__emulator/billing/webhooks/sign',
|
|
735
|
+
public: true,
|
|
736
|
+
handler: async (c) => ({ body: { object: 'billing_webhook', method: 'POST', ...(await signed(c)) } }),
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
method: 'POST',
|
|
740
|
+
pattern: '/__emulator/billing/webhooks/deliver',
|
|
741
|
+
public: true,
|
|
742
|
+
handler: async (c) => {
|
|
743
|
+
const e = c.engine;
|
|
744
|
+
const s = await signed(c);
|
|
745
|
+
const res = await e.handle(new Request(s.url, { method: 'POST', headers: s.headers, body: s.body }));
|
|
746
|
+
const text = await res.text();
|
|
747
|
+
return { body: { object: 'billing_webhook_delivery', provider: s.provider, url: s.url, status: res.status, response: text ? JSON.parse(text) : null, body: s.body } };
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
// ── store links: the token an app hands the app store at purchase ──
|
|
751
|
+
{
|
|
752
|
+
method: 'POST',
|
|
753
|
+
pattern: '/v1/billing/store_links',
|
|
754
|
+
scope: 'billing:write',
|
|
755
|
+
handler: (c) => {
|
|
756
|
+
const e = c.engine;
|
|
757
|
+
billing_1.billingInternals.known(c.body, ['subject_type', 'subject_id', 'plan_id']);
|
|
758
|
+
const sub = billing_1.billingInternals.subject(e, c.body);
|
|
759
|
+
if (typeof c.body.plan_id !== 'string' || !c.body.plan_id)
|
|
760
|
+
throw (0, emu4_billing_core_1.bad)('plan_id is required.', 'plan_id');
|
|
761
|
+
const plan = billing_1.billingInternals.planOf(e, c.body.plan_id);
|
|
762
|
+
if (!plan)
|
|
763
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown plan.');
|
|
764
|
+
if (plan.provider !== 'appstore' && plan.provider !== 'googleplay')
|
|
765
|
+
throw (0, emu4_billing_core_1.bad)('Store links are for plans sold through appstore or googleplay.', 'plan_id');
|
|
766
|
+
if (!plan.active)
|
|
767
|
+
throw (0, emu4_billing_core_1.bad)('That plan is not active.', 'plan_id');
|
|
768
|
+
if (audienceOf(plan) !== sub.type)
|
|
769
|
+
throw (0, emu4_billing_core_1.bad)(`That plan is for ${audienceOf(plan)} subjects.`, 'plan_id');
|
|
770
|
+
let row = st(e).storeLinks.find((l) => l.subject_type === sub.type && l.subject_id === sub.id && l.plan_id === plan.id);
|
|
771
|
+
const created = !row;
|
|
772
|
+
if (!row) {
|
|
773
|
+
row = st(e).storeLinks.insert({ token: crypto.randomUUID(), subject_type: sub.type, subject_id: sub.id, plan_id: plan.id, created_at: e.clock.now() });
|
|
774
|
+
(0, emu4_billing_core_1.audit)(e, 'billing_store_link.created', 'billing_store_link', row.id, { subject_type: sub.type, subject_id: sub.id, plan_id: plan.id, provider: plan.provider });
|
|
775
|
+
}
|
|
776
|
+
return { status: created ? 201 : 200, body: { object: 'billing_store_link', id: row.id, token: row.token, subject_type: row.subject_type, subject_id: row.subject_id, plan_id: row.plan_id, provider: plan.provider, created_at: row.created_at } };
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
// Stands in for a verified App Store or Google Play notification: the emulator cannot mint Apple's certificate chain
|
|
781
|
+
// or a Google token, so the delivery arrives already trusted and goes through the same attribution rules.
|
|
782
|
+
method: 'POST',
|
|
783
|
+
pattern: '/__emulator/billing/store_purchases',
|
|
784
|
+
public: true,
|
|
785
|
+
handler: (c) => {
|
|
786
|
+
const e = c.engine;
|
|
787
|
+
const provider = (0, support_1.oneOf)(c.body, 'provider', ['appstore', 'googleplay']);
|
|
788
|
+
if (!(0, emu4_billing_core_1.providerOf)(e, provider))
|
|
789
|
+
throw (0, emu4_billing_core_1.withStatus)(new errors_1.EmulatorError('NOT_CONFIGURED', 'Billing webhooks are not configured for this instance and provider.'), 404);
|
|
790
|
+
const event = (0, support_1.oneOf)(c.body, 'event', STORE_EVENTS, 'purchase');
|
|
791
|
+
const id = typeof c.body.original_transaction_id === 'string' && c.body.original_transaction_id ? c.body.original_transaction_id : (() => { throw (0, emu4_billing_core_1.bad)('original_transaction_id is required.', 'original_transaction_id'); })();
|
|
792
|
+
const product = typeof c.body.product_id === 'string' ? c.body.product_id : (() => { throw (0, emu4_billing_core_1.bad)('product_id is required (the store product id of the plan).', 'product_id'); })();
|
|
793
|
+
const expires = typeof c.body.expires_at === 'number' ? c.body.expires_at : e.clock.now() + billing_1.billingInternals.MONTH;
|
|
794
|
+
const base = { ...blankSub(id), customerId: typeof c.body.account_token === 'string' ? c.body.account_token : null, status: 'active', refs: [product], quantity: 1, currentPeriodEnd: expires, accountToken: typeof c.body.account_token === 'string' ? c.body.account_token : null };
|
|
795
|
+
const n = event === 'cancel' ? { ...base, cancelAtPeriodEnd: true } : event === 'billing_issue' ? { ...base, status: 'past_due' } : event === 'expire' || event === 'refund' ? { ...base, status: 'canceled', deleted: true } : { ...base, status: c.body.trial === true ? 'trialing' : 'active', trialEnd: c.body.trial === true ? expires : null };
|
|
796
|
+
billing_1.billingInternals.settle(e);
|
|
797
|
+
const handled = reduce(e, provider, [{ kind: 'sub', sub: n }])[0];
|
|
798
|
+
const row = billing_1.billingInternals.st(e).subs.find((r) => r.provider === provider && r.stripe_subscription_id === id);
|
|
799
|
+
return { body: { object: 'billing_store_purchase', provider, event, handled, subscription_id: row?.id ?? null } };
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
// ── proration, read-only: what the next invoice holds ──
|
|
803
|
+
{
|
|
804
|
+
method: 'GET',
|
|
805
|
+
pattern: '/__emulator/billing/invoices/:id/lines',
|
|
806
|
+
public: true,
|
|
807
|
+
handler: (c) => {
|
|
808
|
+
const inv = billing_1.billingInternals.st(c.engine).invoices.get(c.params.id);
|
|
809
|
+
if (!inv)
|
|
810
|
+
throw (0, errors_1.notFound)('Invoice');
|
|
811
|
+
return { body: { object: 'list', data: (inv.lines ?? []).map((l) => billing_1.billingInternals.lineView(inv.currency, l)), has_more: false } };
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
method: 'GET',
|
|
816
|
+
pattern: '/__emulator/billing/subscriptions/:id/upcoming_invoice',
|
|
817
|
+
public: true,
|
|
818
|
+
handler: (c) => {
|
|
819
|
+
const e = c.engine;
|
|
820
|
+
billing_1.billingInternals.settle(e);
|
|
821
|
+
const s = billing_1.billingInternals.st(e).subs.get(c.params.id);
|
|
822
|
+
if (!s)
|
|
823
|
+
throw (0, errors_1.notFound)('Subscription');
|
|
824
|
+
if (s.status === 'canceled' || s.cancel_at_period_end)
|
|
825
|
+
return { body: { object: 'emulator_upcoming_invoice', subscription_id: s.id, lines: [], total: 0, applied_balance: 0, amount_due: 0, currency: 'usd', period_end: s.current_period_end } };
|
|
826
|
+
const d = billing_1.billingInternals.draft(e, s, s.current_period_end);
|
|
827
|
+
return { body: { object: 'emulator_upcoming_invoice', subscription_id: s.id, lines: d.lines.map((l) => billing_1.billingInternals.lineView(d.currency, l)), total: d.total, applied_balance: d.applied, amount_due: d.due, currency: d.currency, period_end: s.current_period_end } };
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
// The provider-side credit a downgrade left behind; the next invoice draws on it.
|
|
832
|
+
method: 'GET',
|
|
833
|
+
pattern: '/__emulator/billing/customer_balance',
|
|
834
|
+
public: true,
|
|
835
|
+
handler: (c) => {
|
|
836
|
+
const type = c.query.get('subject_type');
|
|
837
|
+
const id = c.query.get('subject_id');
|
|
838
|
+
if (!type || !id)
|
|
839
|
+
throw (0, emu4_billing_core_1.bad)('subject_type and subject_id are required.', 'subject_id');
|
|
840
|
+
return { body: { object: 'emulator_customer_balance', subject_type: type, subject_id: id, balance: billing_1.billingInternals.st(c.engine).customerBalance.get(`${type}|${id}`) ?? 0 } };
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
];
|
|
844
|
+
return [...routes.map((r) => (r.pattern.startsWith('/v1/billing/') || r.pattern.startsWith('/__emulator/billing/store') ? settled(r) : r)), ...(0, emu4_billing_x_1.emu4BillingCommerceHandlers)()];
|
|
845
|
+
}
|
|
846
|
+
//# sourceMappingURL=emu4-billing.js.map
|