@jskit-ai/payments-core 0.1.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.
@@ -0,0 +1,229 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createHmac } from 'node:crypto';
4
+ import knex from 'knex';
5
+ import Stripe from 'stripe';
6
+ import { Paddle, Environment } from '@paddle/paddle-node-sdk';
7
+ import migration from '../migrations/payments_core_initial.cjs';
8
+ import { createKnexPaymentStore } from '../src/server/knexStore.js';
9
+ import { createPaymentService } from '../src/server/service.js';
10
+ import { createPaymentCheckoutService } from '../src/server/checkout.js';
11
+ import { createStripePaymentAdapter } from '../src/server/stripe.js';
12
+ import { createPaddlePaymentAdapter } from '../src/server/paddle.js';
13
+
14
+ const scope = { applicationId: 'app', integrationId: 'billing', providerAccountId: 'acct_a', environment: 'sandbox' };
15
+ const secret = 'test-signing-secret';
16
+
17
+ test('official SDK signature verification rejects mutation and expired delivery for both providers', async () => {
18
+ const stripeClient = new Stripe('sk_test_fixture', { maxNetworkRetries: 0 });
19
+ const stripe = createStripePaymentAdapter({ client: stripeClient, webhookSecret: secret, environment: 'sandbox', providerAccountId: 'acct_a', priceBindings: { pro: 'price_a' } });
20
+ const raw = Buffer.from(JSON.stringify({ id: 'evt_a', type: 'customer.created', livemode: false, data: { object: { id: 'cus_a' } } }));
21
+ const header = stripeClient.webhooks.generateTestHeaderString({ payload: raw.toString(), secret });
22
+ assert.equal((await stripe.verifyEvent(raw, header)).id, 'evt_a');
23
+ await assert.rejects(stripe.verifyEvent(Buffer.concat([raw, Buffer.from(' ')]), header), { code: 'payment_signature_invalid' });
24
+ await assert.rejects(stripe.verifyEvent(raw, stripeClient.webhooks.generateTestHeaderString({ payload: raw.toString(), secret, timestamp: 1 })), { code: 'payment_signature_invalid' });
25
+ await assert.rejects(stripe.verifyEvent(raw.toString(), header), { code: 'payment_signature_invalid' });
26
+ const paddleClient = new Paddle('test-fixture', { environment: Environment.sandbox });
27
+ const paddle = createPaddlePaymentAdapter({ client: paddleClient, webhookSecret: secret, environment: 'sandbox', priceBindings: { pro: 'pri_a' } });
28
+ const paddleRaw = Buffer.from(JSON.stringify({ event_id: 'evt_a', event_type: 'customer.created', occurred_at: new Date().toISOString(), data: { id: 'ctm_a', email: 'test@example.com', status: 'active', marketing_consent: false, custom_data: null, import_meta: null } }));
29
+ const signature = (ts) => `ts=${ts};h1=${createHmac('sha256', secret).update(`${ts}:${paddleRaw.toString()}`).digest('hex')}`;
30
+ const currentSignature = signature(Math.floor(Date.now() / 1000));
31
+ assert.equal((await paddle.verifyEvent(paddleRaw, currentSignature)).eventId, 'evt_a');
32
+ await assert.rejects(paddle.verifyEvent(Buffer.concat([paddleRaw, Buffer.from(' ')]), currentSignature), { code: 'payment_signature_invalid' });
33
+ await assert.rejects(paddle.verifyEvent(paddleRaw, signature(1)), { code: 'payment_signature_invalid' });
34
+ });
35
+
36
+ test('checkout authorizes before provider access, persists intent, deduplicates requests and needs explicit recovery', async () => {
37
+ const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
38
+ await migration.up(db);
39
+ const store = createKnexPaymentStore({ knex: db });
40
+ let creates = 0;
41
+ let fails = false;
42
+ const adapter = {
43
+ validatePlan(planId) { assert.equal(planId, 'pro'); },
44
+ async createCustomer() { creates++; return 'cus_a'; },
45
+ async createCheckout() {
46
+ creates++;
47
+ assert.equal((await store.inspect({ ...scope, subjectId: 'tenant-a' })).pendingPaymentOperation.action, 'checkout');
48
+ if (fails) throw new Error('uncertain provider timeout');
49
+ return { id: 'cs_a', url: 'https://checkout.stripe.com/example' };
50
+ },
51
+ async createPortal() { return { id: 'portal_a', url: 'https://billing.stripe.com/example' }; }
52
+ };
53
+ const service = createPaymentCheckoutService({ adapter, store, payments: { inspect: async () => ({ balance: 0, features: [], subscriptions: [] }) }, merchantScope: scope, returnUrl: 'https://app.example/billing', authorize: async (actor, { subjectId }) => actor === subjectId });
54
+ const input = { actor: 'tenant-a', subjectId: 'tenant-a', email: 'tenant@example.com', planId: 'pro', requestId: 'request-1' };
55
+ try {
56
+ await assert.rejects(service.checkout({ ...input, actor: 'tenant-b' }), { code: 'payment_forbidden' });
57
+ assert.equal(creates, 0);
58
+ await assert.rejects(service.reconcilePending({ actor: 'tenant-b', subjectId: 'tenant-a', inspectProvider: async () => { assert.fail('denied recovery cannot inspect the provider'); } }), { code: 'payment_forbidden' });
59
+ assert.equal((await db('payment_accounts')).length, 0, 'denied operations create no account state');
60
+ await assert.rejects(service.account({ actor: 'tenant-b', subjectId: 'tenant-a' }), { code: 'payment_forbidden' });
61
+ assert.deepEqual(await service.account({ actor: 'tenant-a', subjectId: 'tenant-a' }), { balance: 0, features: [], subscriptions: [], hasCustomer: false });
62
+ assert.equal((await service.checkout(input)).id, 'cs_a');
63
+ assert.equal((await service.checkout(input)).id, 'cs_a');
64
+ assert.equal(creates, 2);
65
+ assert.equal((await service.account({ actor: 'tenant-a', subjectId: 'tenant-a' })).hasCustomer, true);
66
+ assert.equal(await store.resolveCustomer(scope, 'cus_a'), 'tenant-a');
67
+ await assert.rejects(service.portal({ actor: 'tenant-b', subjectId: 'tenant-a' }), { code: 'payment_forbidden' });
68
+ fails = true;
69
+ await assert.rejects(service.checkout({ ...input, requestId: 'request-2' }), { code: 'payment_operation_uncertain' });
70
+ await assert.rejects(service.checkout({ ...input, requestId: 'request-2' }), { code: 'payment_operation_uncertain' });
71
+ assert.equal(creates, 3);
72
+ await assert.rejects(service.reconcilePending({ actor: 'tenant-a', subjectId: 'tenant-a', inspectProvider: async () => ({}) }), { code: 'payment_reconciliation_required' });
73
+ await service.reconcilePending({ actor: 'tenant-a', subjectId: 'tenant-a', inspectProvider: async () => ({ result: { id: 'cs_recovered', url: 'https://checkout.stripe.com/recovered' } }) });
74
+ assert.equal((await service.checkout({ ...input, requestId: 'request-2' })).id, 'cs_recovered');
75
+ await store.withAccount({ ...scope, subjectId: 'tenant-a' }, async (tx) => {
76
+ tx.state.subscriptions.sub_a = { id: 'sub_a', status: 'past_due', planId: 'pro', periodEnd: 0 };
77
+ });
78
+ await assert.rejects(service.checkout({ ...input, requestId: 'request-3' }), { code: 'payment_subscription_exists' });
79
+ // A lost response can still be recovered using its original intent identity.
80
+ assert.equal((await service.checkout(input)).id, 'cs_a');
81
+ assert.equal(creates, 3);
82
+ } finally { await migration.down(db); await db.destroy(); }
83
+ });
84
+
85
+ test('Stripe reconciliation loads current subscription and validates invoice account, quantity and price', async () => {
86
+ const subscription = { id: 'sub_a', customer: 'cus_a', livemode: false, status: 'active', items: { has_more: false, data: [{ quantity: 1, price: { id: 'price_a' }, current_period_end: 20000 }] } };
87
+ const invoice = { id: 'in_a', customer: 'cus_a', livemode: false, status: 'paid', billing_reason: 'subscription_cycle', parent: { subscription_details: { subscription: 'sub_a' } }, lines: { has_more: false, data: [{ quantity: 1, parent: { subscription_item_details: { proration: false } }, pricing: { price_details: { price: 'price_a' } }, period: { end: 20000 } }] } };
88
+ const client = { accounts: { retrieve: async () => ({ id: 'acct_a' }) }, balance: { retrieve: async () => ({ livemode: false }) }, subscriptions: { retrieve: async () => subscription }, invoices: { retrieve: async () => invoice } };
89
+ const adapter = createStripePaymentAdapter({ client, webhookSecret: secret, environment: 'sandbox', providerAccountId: 'acct_a', priceBindings: { pro: 'price_a' } });
90
+ const event = { type: 'invoice.paid', data: { object: { id: 'in_a' } } };
91
+ assert.equal((await adapter.loadEvent(event)).renewal.planId, 'pro');
92
+ invoice.status = 'open';
93
+ subscription.status = 'past_due';
94
+ event.type = 'invoice.payment_failed';
95
+ const failed = await adapter.loadEvent(event);
96
+ assert.equal(failed.subscription.status, 'past_due');
97
+ assert.equal(failed.renewal, undefined);
98
+ // A delayed failure delivery reads current provider state after recovery.
99
+ invoice.status = 'paid';
100
+ subscription.status = 'active';
101
+ assert.equal((await adapter.loadEvent(event)).renewal.id, 'in_a');
102
+ subscription.status = 'canceled';
103
+ assert.equal((await adapter.loadEvent(event)).subscription.status, 'canceled');
104
+ invoice.billing_reason = 'subscription_update';
105
+ assert.equal((await adapter.loadEvent(event)).renewal, undefined);
106
+ invoice.customer = 'cus_other';
107
+ await assert.rejects(adapter.loadEvent(event), { code: 'payment_provider_result_invalid' });
108
+ });
109
+
110
+ test('signed standalone Stripe invoices are ignored before customer lookup; malformed and tampered events fail', async () => {
111
+ const client = new Stripe('sk_test_fixture', { maxNetworkRetries: 0 });
112
+ const adapter = createStripePaymentAdapter({ client, webhookSecret: secret, environment: 'sandbox', providerAccountId: 'acct_a', priceBindings: {} });
113
+ let reconciliations = 0;
114
+ const service = createPaymentCheckoutService({ adapter, merchantScope: scope, returnUrl: 'https://app.example/billing', authorize: async () => false,
115
+ payments: { async reconcileEvent() { reconciliations++; } } });
116
+ const event = { id: 'evt_oneoff', type: 'invoice.paid', livemode: false, data: { object: { id: 'in_oneoff', customer: 'cus_unrelated', parent: null } } };
117
+ const rawBody = Buffer.from(JSON.stringify(event));
118
+ const signature = client.webhooks.generateTestHeaderString({ payload: rawBody.toString(), secret });
119
+ assert.deepEqual(await service.webhook({ rawBody, signature }), { ignored: true });
120
+ assert.equal(reconciliations, 0);
121
+ await assert.rejects(service.webhook({ rawBody: Buffer.concat([rawBody, Buffer.from(' ')]), signature }), { code: 'payment_signature_invalid' });
122
+ delete event.data.object.parent;
123
+ assert.throws(() => adapter.eventIdentity(event), { code: 'payment_provider_result_invalid' });
124
+ event.type = 'invoice.payment_failed';
125
+ event.data.object.parent = { subscription_details: { subscription: 'sub_a' } };
126
+ assert.deepEqual(adapter.eventIdentity(event), { eventId: 'evt_oneoff', customerId: 'cus_unrelated' });
127
+ });
128
+
129
+ test('Paddle event identity separates standalone transactions from subscription updates', () => {
130
+ const adapter = createPaddlePaymentAdapter({ client: {}, webhookSecret: secret, environment: 'sandbox', priceBindings: {} });
131
+ const event = { eventId: 'evt_a', eventType: 'transaction.completed', data: { subscriptionId: null, customerId: 'ctm_a' } };
132
+ assert.equal(adapter.eventIdentity(event), null);
133
+ delete event.data.subscriptionId;
134
+ assert.throws(() => adapter.eventIdentity(event), { code: 'payment_provider_result_invalid' });
135
+ event.data.subscriptionId = 'sub_a';
136
+ assert.deepEqual(adapter.eventIdentity(event), { eventId: 'evt_a', customerId: 'ctm_a' });
137
+ event.eventType = 'subscription.past_due';
138
+ assert.deepEqual(adapter.eventIdentity(event), { eventId: 'evt_a', customerId: 'ctm_a' });
139
+ event.eventType = 'customer.created';
140
+ assert.equal(adapter.eventIdentity(event), null);
141
+ });
142
+
143
+ test('Paddle reconciliation uses current status and grants only qualifying completed renewals', async () => {
144
+ const subscription = { id: 'sub_a', customerId: 'ctm_a', status: 'active', items: [{ quantity: 1, price: { id: 'pri_a' } }], currentBillingPeriod: { endsAt: '2030-01-01T00:00:00Z' } };
145
+ const transaction = { id: 'txn_a', customerId: 'ctm_a', subscriptionId: 'sub_a', status: 'completed', origin: 'subscription_recurring', items: [{ quantity: 1, price: { id: 'pri_a' }, proration: null }], billingPeriod: { endsAt: '2030-01-01T00:00:00Z' } };
146
+ const client = { subscriptions: { get: async () => subscription }, transactions: { get: async () => transaction } };
147
+ const adapter = createPaddlePaymentAdapter({ client, webhookSecret: secret, environment: 'sandbox', priceBindings: { pro: 'pri_a' } });
148
+ const event = { eventType: 'transaction.completed', data: { id: 'txn_a' } };
149
+ assert.equal((await adapter.loadEvent(event)).renewal.id, 'txn_a');
150
+ transaction.origin = 'subscription_update';
151
+ assert.equal((await adapter.loadEvent(event)).renewal, undefined);
152
+ subscription.status = 'paused';
153
+ subscription.currentBillingPeriod = null;
154
+ const paused = await adapter.loadEvent({ eventType: 'subscription.updated', data: { id: 'sub_a' } });
155
+ assert.equal(paused.subscription.status, 'paused');
156
+ assert.equal(paused.subscription.periodEnd, 0);
157
+ assert.equal(paused.renewal, undefined);
158
+ transaction.customerId = 'ctm_other';
159
+ await assert.rejects(adapter.loadEvent(event), { code: 'payment_provider_result_invalid' });
160
+ });
161
+
162
+
163
+ test('signed subscription lifecycle updates app access without crossing subjects or environments', async () => {
164
+ const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
165
+ await migration.up(db);
166
+ const store = createKnexPaymentStore({ knex: db });
167
+ const stripeSdk = new Stripe('sk_test_fixture', { maxNetworkRetries: 0 });
168
+ const paddleSdk = new Paddle('test-fixture', { environment: Environment.sandbox });
169
+ try {
170
+ for (const provider of ['stripe', 'paddle']) {
171
+ const merchant = { ...scope, applicationId: provider };
172
+ const subject = { ...merchant, subjectId: 'tenant-a' };
173
+ let now = Date.parse('2030-01-01T00:00:00Z');
174
+ const end = Date.parse('2030-02-01T00:00:00Z');
175
+ let status = 'active';
176
+ let customerId = 'customer_a';
177
+ let loads = 0;
178
+ const payments = createPaymentService({ store, clock: () => now, configuration: {
179
+ environments: { sandbox: { integrationId: merchant.integrationId, providerAccountId: merchant.providerAccountId } },
180
+ plans: { pro: { features: ['export'], renewalCredits: 100 } }
181
+ } });
182
+ const adapter = provider === 'stripe'
183
+ ? createStripePaymentAdapter({ webhookSecret: secret, environment: 'sandbox', providerAccountId: 'acct_a', priceBindings: { pro: 'price_a' }, client: {
184
+ webhooks: stripeSdk.webhooks,
185
+ accounts: { retrieve: async () => ({ id: 'acct_a' }) }, balance: { retrieve: async () => ({ livemode: false }) },
186
+ subscriptions: { retrieve: async () => { loads++; return { id: 'sub_a', customer: customerId, livemode: false, status, items: { has_more: false, data: [{ quantity: 1, price: { id: 'price_a' }, current_period_end: end / 1000 }] } }; } }
187
+ } })
188
+ : createPaddlePaymentAdapter({ webhookSecret: secret, environment: 'sandbox', priceBindings: { pro: 'pri_a' }, client: {
189
+ webhooks: paddleSdk.webhooks,
190
+ subscriptions: { get: async () => { loads++; return { id: 'sub_a', customerId, status, items: [{ quantity: 1, price: { id: 'pri_a' } }], currentBillingPeriod: { endsAt: new Date(end).toISOString() } }; } }
191
+ } });
192
+ const checkout = createPaymentCheckoutService({ adapter, store, payments, merchantScope: merchant, returnUrl: 'https://app.example/billing', authorize: async () => false });
193
+ await store.bindCustomer(merchant, 'customer_a', 'tenant-a');
194
+ const deliver = async (id, eventCustomer = 'customer_a') => {
195
+ const payload = provider === 'stripe'
196
+ ? { id, type: 'customer.subscription.updated', livemode: false, data: { object: { id: 'sub_a', customer: eventCustomer } } }
197
+ : { event_id: id, event_type: 'subscription.updated', occurred_at: new Date().toISOString(), data: { id: 'sub_a', customer_id: eventCustomer, billing_cycle: { interval: 'month', frequency: 1 }, items: [] } };
198
+ const rawBody = Buffer.from(JSON.stringify(payload));
199
+ const ts = Math.floor(Date.now() / 1000);
200
+ const signature = provider === 'stripe'
201
+ ? stripeSdk.webhooks.generateTestHeaderString({ payload: rawBody.toString(), secret })
202
+ : `ts=${ts};h1=${createHmac('sha256', secret).update(`${ts}:${rawBody.toString()}`).digest('hex')}`;
203
+ return checkout.webhook({ rawBody, signature });
204
+ };
205
+ for (const [index, next] of ['active', 'past_due', 'active', 'paused', 'canceled'].entries()) {
206
+ status = next;
207
+ await deliver(`event_${index}`);
208
+ const state = await payments.inspect(subject);
209
+ assert.deepEqual(state.features, next === 'active' ? ['export'] : [], `${provider}: ${next}`);
210
+ assert.equal(state.balance, 0, 'a subscription event alone never grants renewal credits');
211
+ assert.equal((await payments.inspect({ ...subject, subjectId: 'tenant-b' })).subscriptions.length, 0);
212
+ assert.equal((await store.inspect({ ...subject, environment: 'live' })).subscriptions.sub_a, undefined);
213
+ }
214
+ const previousLoads = loads;
215
+ assert.equal((await deliver('event_4')).duplicate, true);
216
+ assert.equal(loads, previousLoads, 'duplicate delivery does not call the provider again');
217
+ customerId = 'customer_other';
218
+ await assert.rejects(deliver('event_foreign'), { code: 'payment_provider_result_invalid' });
219
+ assert.equal((await payments.inspect(subject)).subscriptions[0].status, 'canceled');
220
+ customerId = 'customer_a';
221
+ status = 'active';
222
+ await deliver('event_foreign'); // failed verification must not commit a receipt
223
+ await payments.requireFeature(subject, 'export');
224
+ now = end;
225
+ await assert.rejects(payments.requireFeature(subject, 'export'), { code: 'payment_feature_required' });
226
+ await assert.rejects(deliver('event_unbound', 'customer_unbound'), { code: 'payment_customer_unbound' });
227
+ }
228
+ } finally { await migration.down(db); await db.destroy(); }
229
+ });
@@ -0,0 +1,77 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import Ajv from 'ajv';
5
+ import knex from 'knex';
6
+ import migration from '../migrations/payments_core_initial.cjs';
7
+ import { validatePaymentConfiguration } from '../src/shared/configuration.js';
8
+ import { createPaymentService } from '../src/server/service.js';
9
+ import { createKnexPaymentStore } from '../src/server/knexStore.js';
10
+
11
+ const fixture = JSON.parse(await readFile(new URL('../contracts/conformance.json', import.meta.url), 'utf8'));
12
+ const schema = JSON.parse(await readFile(new URL('../contracts/configuration.schema.json', import.meta.url), 'utf8'));
13
+
14
+ test('portable configuration fixtures distinguish JSON Schema from cross-reference validation', () => {
15
+ const validate = new Ajv({ strict: true }).compile(schema);
16
+ for (const item of fixture.configurationCases) {
17
+ assert.equal(validate(item.document.extensions.payments), item.schemaValid, item.id);
18
+ if (item.configurationValid) assert.deepEqual(validatePaymentConfiguration(item.document), item.document.extensions.payments, item.id);
19
+ else assert.throws(() => validatePaymentConfiguration(item.document), { code: 'payment_configuration_invalid' }, item.id);
20
+ }
21
+ });
22
+
23
+ test('payment binding errors identify the field to repair without returning credentials', () => {
24
+ const cases = [
25
+ ['missing-connection', 'integrationId', /shared Stripe or Paddle/],
26
+ ['inline-api-key', 'integrationId', /Env reference/],
27
+ ['paddle-environment-mismatch', 'integrationId', /configured for sandbox/],
28
+ ['paddle-client-token-missing', 'publicClientTokenRef', /public client token/]
29
+ ];
30
+ for (const [id, field, message] of cases) {
31
+ const item = fixture.configurationCases.find((entry) => entry.id === id);
32
+ assert.throws(() => validatePaymentConfiguration(item.document), (error) => {
33
+ assert.equal(error.code, 'payment_configuration_invalid');
34
+ assert.equal(error.statusCode, 422);
35
+ assert.ok(error.fieldErrors.some((entry) => entry.path === `extensions.payments/environments/sandbox/${field}` && message.test(entry.message)), id);
36
+ assert.doesNotMatch(JSON.stringify(error.fieldErrors), /not-an-env-reference/);
37
+ return true;
38
+ });
39
+ }
40
+ const document = structuredClone(fixture.configurationCases.find((entry) => entry.id === 'paddle-sandbox').document);
41
+ delete document.extensions.payments.environments.sandbox.taxCategory;
42
+ delete document.extensions.payments.environments.sandbox.publicClientTokenRef;
43
+ assert.throws(() => validatePaymentConfiguration(document), (error) => {
44
+ assert.deepEqual(error.fieldErrors.map((entry) => entry.path), [
45
+ 'extensions.payments/environments/sandbox/taxCategory',
46
+ 'extensions.payments/environments/sandbox/publicClientTokenRef'
47
+ ]);
48
+ return true;
49
+ });
50
+ });
51
+
52
+ test('portable account sequence defines actual credit, renewal, expiry and subject-isolation outcomes', async () => {
53
+ const scenario = fixture.accountScenario;
54
+ const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
55
+ await migration.up(db);
56
+ const store = createKnexPaymentStore({ knex: db });
57
+ let now = scenario.clock;
58
+ const service = createPaymentService({ store, configuration: validatePaymentConfiguration(scenario.document), clock: () => now });
59
+ const { subjectId, ...merchant } = scenario.scope;
60
+ // Expected objects assert the listed fields recursively; arrays are exact.
61
+ const compare = (actual, expected) => {
62
+ if (!expected || typeof expected !== 'object' || Array.isArray(expected)) return assert.deepEqual(actual, expected);
63
+ for (const [key, value] of Object.entries(expected)) compare(actual[key], value);
64
+ };
65
+ try {
66
+ await store.bindCustomer(merchant, scenario.customerId, subjectId);
67
+ for (const step of scenario.steps) {
68
+ if (step.clock !== undefined) now = step.clock;
69
+ const scope = { ...scenario.scope, subjectId: step.subjectId ?? subjectId };
70
+ const run = () => step.operation === 'reconcileEvent'
71
+ ? service.reconcileEvent(merchant, { ...step.input, load: async () => structuredClone(step.facts) })
72
+ : service[step.operation](scope, step.input);
73
+ if (step.error) await assert.rejects(run(), { code: step.error });
74
+ else compare(await run(), step.expect);
75
+ }
76
+ } finally { await migration.down(db); await db.destroy(); }
77
+ });
@@ -0,0 +1,105 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createPaymentCheckoutService } from '../src/server/checkout.js';
4
+ import { createStripePaymentAdapter } from '../src/server/stripe.js';
5
+ import { createPaddlePaymentAdapter } from '../src/server/paddle.js';
6
+
7
+ const merchant = { applicationId: 'app', integrationId: 'billing', providerAccountId: 'acct_a', environment: 'sandbox' };
8
+
9
+ test('every subject billing entry point requires explicit authorization before touching state', async () => {
10
+ const touched = [];
11
+ const forbidden = new Proxy({}, { get(_target, key) { touched.push(key); throw new Error('Unauthorized dependency access'); } });
12
+ const actions = { account: 'account', history: 'history', checkout: 'checkout', portal: 'portal', reconcilePending: 'reconcile' };
13
+ for (const decision of [false, undefined, null, 'true', 1, { allowed: true }]) {
14
+ const requests = [];
15
+ const service = createPaymentCheckoutService({ merchantScope: merchant, returnUrl: 'https://example.com/billing',
16
+ store: forbidden, adapter: forbidden, payments: forbidden,
17
+ authorize: async (actor, request) => { requests.push({ actor, ...request }); return decision; }
18
+ });
19
+ for (const [method, action] of Object.entries(actions)) {
20
+ await assert.rejects(service[method]({ actor: 'editor-owner', subjectId: 'tenant-a',
21
+ collection: 'transactions', email: 'person@example.com', planId: 'pro', requestId: 'request-a',
22
+ inspectProvider: async () => { touched.push('provider-inspection'); }
23
+ }), { code: 'payment_forbidden' });
24
+ assert.deepEqual(requests.at(-1), { actor: 'editor-owner', subjectId: 'tenant-a', action });
25
+ }
26
+ }
27
+ assert.deepEqual(touched, [], 'denied calls cannot read state, contact providers or inspect recovery');
28
+ });
29
+
30
+ test('history authorizes the subject before storage or network and never accepts a caller customer ID', async () => {
31
+ const reads = [];
32
+ let boundCustomer = 'cus_owned';
33
+ const service = createPaymentCheckoutService({ merchantScope: merchant, returnUrl: 'https://example.com/billing',
34
+ authorize: async (actor, request) => actor === 'billing-admin' && request.subjectId === 'tenant-a' && request.action === 'history',
35
+ store: { async inspect(scope) { reads.push(scope); return { customerId: boundCustomer }; } },
36
+ adapter: { async readHistory(query) { reads.push(query); return { collection: query.collection, items: [], nextCursor: null }; } }
37
+ });
38
+ await assert.rejects(service.history({ actor: 'tenant-b', subjectId: 'tenant-a', collection: 'transactions' }), { code: 'payment_forbidden' });
39
+ await assert.rejects(service.history({ actor: 'billing-admin', subjectId: 'tenant-b', collection: 'transactions' }), { code: 'payment_forbidden' });
40
+ assert.equal(reads.length, 0);
41
+ const input = { actor: 'billing-admin', subjectId: 'tenant-a', collection: 'transactions' };
42
+ await assert.rejects(service.history({ ...input, after: 'https://attacker.example' }), { code: 'payment_input_invalid' });
43
+ await assert.rejects(service.history({ ...input, collection: 'customers' }), { code: 'payment_input_invalid' });
44
+ assert.equal(reads.length, 0);
45
+ await service.history({ ...input, customerId: 'cus_attacker', after: 'in_previous' });
46
+ assert.deepEqual(reads, [{ ...merchant, subjectId: 'tenant-a' }, { customerId: 'cus_owned', collection: 'transactions', after: 'in_previous' }]);
47
+ boundCustomer = null;
48
+ reads.length = 0;
49
+ assert.deepEqual(await service.history(input), { collection: 'transactions', items: [], nextCursor: null });
50
+ assert.equal(reads.length, 1);
51
+ });
52
+
53
+ test('history errors do not expose provider credentials or diagnostics', async () => {
54
+ const service = createPaymentCheckoutService({ merchantScope: merchant, returnUrl: 'https://example.com/billing', authorize: async () => true,
55
+ store: { inspect: async () => ({ customerId: 'cus_a' }) }, adapter: { readHistory: async () => { throw new Error('sk_secret customer_private@example.com'); } } });
56
+ await assert.rejects(service.history({ actor: 'admin', subjectId: 'a', collection: 'transactions' }), (error) => {
57
+ assert.equal(error.code, 'payment_history_unavailable');
58
+ assert.doesNotMatch(error.message, /sk_secret|customer_private/);
59
+ return true;
60
+ });
61
+ });
62
+
63
+ test('Stripe history uses customer-filtered single pages, includes canceled subscriptions, and projects billing facts only', async () => {
64
+ const queries = [];
65
+ const invoice = { id: 'in_a', customer: 'cus_a', livemode: false, created: 1700000000, status: 'open', total: 1200, amount_paid: 0, currency: 'usd', customer_email: 'private@example.com', metadata: { secret: 'hidden' }, hosted_invoice_url: 'https://private.example' };
66
+ let accountId = 'acct_a';
67
+ const client = { accounts: { retrieve: async () => ({ id: accountId }) }, balance: { retrieve: async () => ({ livemode: false }) },
68
+ invoices: { list: async (query) => { queries.push(query); return { data: [invoice], has_more: true }; } },
69
+ subscriptions: { list: async (query) => { queries.push(query); return { data: [{ id: 'sub_a', customer: 'cus_a', livemode: false, created: 1700000000, status: 'canceled', metadata: { hidden: true } }], has_more: false }; } } };
70
+ const adapter = createStripePaymentAdapter({ client, webhookSecret: 'fixture', environment: 'sandbox', providerAccountId: 'acct_a', priceBindings: {} });
71
+ const result = await adapter.readHistory({ customerId: 'cus_a', collection: 'transactions', after: 'in_previous' });
72
+ assert.deepEqual(queries[0], { customer: 'cus_a', limit: 20, starting_after: 'in_previous' });
73
+ assert.deepEqual(result, { collection: 'transactions', items: [{ id: 'in_a', kind: 'invoice', status: 'open', createdAt: '2023-11-14T22:13:20.000Z', currency: 'USD', totalMinor: '1200', paidMinor: '0' }], nextCursor: 'in_a' });
74
+ assert.equal((await adapter.readHistory({ customerId: 'cus_a', collection: 'subscriptions' })).items[0].status, 'canceled');
75
+ assert.deepEqual(queries[1], { customer: 'cus_a', limit: 20, status: 'all' });
76
+ invoice.customer = 'cus_other';
77
+ await assert.rejects(adapter.readHistory({ customerId: 'cus_a', collection: 'transactions' }), { code: 'payment_provider_result_invalid' });
78
+ invoice.customer = 'cus_a';
79
+ invoice.livemode = true;
80
+ await assert.rejects(adapter.readHistory({ customerId: 'cus_a', collection: 'transactions' }), { code: 'payment_provider_result_invalid' });
81
+ accountId = 'acct_other';
82
+ const prior = queries.length;
83
+ await assert.rejects(adapter.readHistory({ customerId: 'cus_a', collection: 'transactions' }), { code: 'payment_provider_result_invalid' });
84
+ assert.equal(queries.length, prior);
85
+ });
86
+
87
+ test('Paddle history preserves amount strings, distinguishes unpaid totals and fetches one customer-filtered page', async () => {
88
+ let pages = 0;
89
+ const queries = [];
90
+ const record = { id: 'txn_a', customerId: 'ctm_a', status: 'billed', createdAt: '2030-01-01T00:00:00Z', currencyCode: 'USD', details: { totals: { total: '9007199254740993' } }, checkout: { url: 'https://private.example' }, customData: { secret: 'hidden' } };
91
+ const list = (query) => { queries.push(query); return { hasMore: true, async next() { pages++; return [record]; } }; };
92
+ const adapter = createPaddlePaymentAdapter({ client: { transactions: { list }, subscriptions: { list } }, webhookSecret: 'fixture', environment: 'sandbox', priceBindings: {} });
93
+ assert.deepEqual(await adapter.readHistory({ customerId: 'ctm_a', collection: 'transactions', after: 'txn_previous' }), {
94
+ collection: 'transactions', items: [{ id: 'txn_a', kind: 'transaction', status: 'billed', createdAt: '2030-01-01T00:00:00.000Z', currency: 'USD', totalMinor: '9007199254740993', paidMinor: null }], nextCursor: 'txn_a'
95
+ });
96
+ assert.equal(pages, 1);
97
+ assert.deepEqual(queries[0], { customerId: ['ctm_a'], perPage: 20, after: 'txn_previous' });
98
+ record.details = null;
99
+ assert.equal((await adapter.readHistory({ customerId: 'ctm_a', collection: 'transactions' })).items[0].totalMinor, null);
100
+ record.id = 'sub_a';
101
+ record.status = 'canceled';
102
+ assert.deepEqual((await adapter.readHistory({ customerId: 'ctm_a', collection: 'subscriptions' })).items[0], { id: 'sub_a', kind: 'subscription', status: 'canceled', createdAt: '2030-01-01T00:00:00.000Z' });
103
+ record.customerId = 'ctm_other';
104
+ await assert.rejects(adapter.readHistory({ customerId: 'ctm_a', collection: 'transactions' }), { code: 'payment_provider_result_invalid' });
105
+ });
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createPaymentReadiness } from '../src/server/readiness.js';
4
+
5
+ const scope = { environment: 'live', providerAccountId: 'acct_a' };
6
+ const catalogue = { preview: async () => ({ environment: 'live', providerAccountId: 'acct_a', changes: [], drift: [], removed: [], pending: null }) };
7
+ const byId = (report) => Object.fromEntries(report.checks.map((check) => [check.id, check]));
8
+
9
+ test('readiness distinguishes provider facts, explicit app evidence and unknown checks without mutation', async () => {
10
+ const adapter = { verifyAccount: async () => ({ environment: 'live', accountId: 'acct_a', chargesEnabled: true, payoutsEnabled: false }) };
11
+ const readiness = createPaymentReadiness({ adapter, catalogue, scope,
12
+ inspectApplication: async () => ({ webhook: { status: 'passed', detail: 'The configured route passed the signed fixture check.' } }) });
13
+ const report = await readiness.inspect();
14
+ assert.equal(report.providerAccountId, 'acct_a');
15
+ const checks = byId(report);
16
+ assert.equal(checks.credentials.status, 'passed');
17
+ assert.equal(checks.account.status, 'passed');
18
+ assert.equal(checks.charges.status, 'passed');
19
+ assert.equal(checks.payouts.status, 'failed');
20
+ assert.equal(checks.catalogue.status, 'passed');
21
+ assert.equal(checks.webhook.status, 'passed');
22
+ assert.equal(checks.checkout.status, 'unknown');
23
+ assert.equal(checks.site.status, 'manual');
24
+ assert.equal(checks.deployment.status, 'unknown');
25
+ assert.equal(Object.hasOwn(report, 'ready'), false);
26
+ });
27
+
28
+ test('Paddle-style verification does not infer merchant approval; failed inspection does not leak provider errors', async () => {
29
+ let catalogueReads = 0;
30
+ const adapter = { verifyAccount: async () => ({ environment: 'live', credentialsVerified: true, accountIdentityVerified: false }) };
31
+ const readiness = createPaymentReadiness({ adapter, catalogue: { preview: async () => { catalogueReads++; return { ...(await catalogue.preview()), changes: [{}] }; } }, scope });
32
+ let checks = byId(await readiness.inspect());
33
+ assert.equal(checks.credentials.status, 'passed');
34
+ assert.equal(checks.account.status, 'manual');
35
+ assert.equal(checks.charges.status, 'manual');
36
+ assert.equal(checks.catalogue.status, 'failed');
37
+ adapter.verifyAccount = async () => { throw new Error('sk_secret_must_not_escape'); };
38
+ const failed = await readiness.inspect();
39
+ checks = byId(failed);
40
+ assert.equal(checks.credentials.status, 'failed');
41
+ assert.equal(checks.catalogue.status, 'unknown');
42
+ assert.equal(catalogueReads, 1);
43
+ assert.doesNotMatch(JSON.stringify(failed), /sk_secret/);
44
+ });
45
+
46
+ test('readiness rejects merchant/environment substitution and malformed app evidence', async () => {
47
+ const adapter = { verifyAccount: async () => ({ environment: 'sandbox', accountId: 'acct_a' }) };
48
+ await assert.rejects(createPaymentReadiness({ adapter, catalogue, scope }).inspect(), { code: 'payment_scope_invalid' });
49
+ adapter.verifyAccount = async () => ({ environment: 'live', accountId: 'acct_other' });
50
+ await assert.rejects(createPaymentReadiness({ adapter, catalogue, scope }).inspect(), { code: 'payment_scope_invalid' });
51
+ adapter.verifyAccount = async () => ({ environment: 'live' });
52
+ assert.equal(byId(await createPaymentReadiness({ adapter, catalogue, scope }).inspect()).credentials.status, 'unknown');
53
+ await assert.rejects(createPaymentReadiness({ adapter, catalogue, scope,
54
+ inspectApplication: async () => ({ site: { status: 'approved', detail: 'Everything is fine' } }) }).inspect(), { code: 'payment_readiness_invalid' });
55
+ });
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knex from "knex";
4
+ import migration from "../migrations/payments_core_initial.cjs";
5
+ import { createKnexPaymentStore } from "../src/server/knexStore.js";
6
+ import { createPaymentService } from "../src/server/service.js";
7
+ import { validatePaymentConfiguration } from "../src/shared/configuration.js";
8
+
9
+ const document = {
10
+ integrations: {
11
+ billing: { provider: "stripe", accountMode: "shared", authentication: { method: "api-key", secretRef: "env:STRIPE_KEY" } },
12
+ production: { provider: "stripe", accountMode: "shared", authentication: { method: "api-key", secretRef: "env:STRIPE_LIVE_KEY" } }
13
+ },
14
+ extensions: { payments: {
15
+ version: 1,
16
+ environments: {
17
+ sandbox: { integrationId: "billing", providerAccountId: "acct_test", webhookSecretRef: "env:STRIPE_WEBHOOK_SECRET", returnUrlRef: "env:APP_URL" },
18
+ live: { integrationId: "production", providerAccountId: "acct_live", webhookSecretRef: "env:STRIPE_LIVE_WEBHOOK_SECRET", returnUrlRef: "env:APP_URL" }
19
+ },
20
+ plans: { pro: { name: "Pro", amount: 1200, currency: "USD", interval: "month", features: ["export"], renewalCredits: 100 } }
21
+ } }
22
+ };
23
+ const scope = { applicationId: "app", integrationId: "billing", providerAccountId: "acct_test", environment: "sandbox", subjectId: "tenant-a" };
24
+
25
+ test("portable configuration rejects secret literals, invalid prices and mismatched connector environments", () => {
26
+ assert.equal(validatePaymentConfiguration(document).plans.pro.amount, 1200);
27
+ for (const change of [
28
+ (value) => { value.extensions.payments.plans.pro.amount = 0.5; },
29
+ (value) => { value.extensions.payments.environments.sandbox.webhookSecretRef = "whsec_private"; },
30
+ (value) => { value.integrations.billing.accountMode = "per-user"; },
31
+ (value) => { value.integrations.billing.provider = "paddle"; value.integrations.billing.settings = { environment: "live" }; },
32
+ (value) => { value.extensions.payments.plans.pro.features.push("export"); }
33
+ ]) {
34
+ const value = structuredClone(document); change(value);
35
+ assert.throws(() => validatePaymentConfiguration(value), { code: "payment_configuration_invalid" });
36
+ }
37
+ });
38
+
39
+ test("configuration accepts reactive objects and returns an independent plain snapshot", () => {
40
+ const input = structuredClone(document);
41
+ input.extensions.payments = new Proxy(input.extensions.payments, {});
42
+ const configuration = validatePaymentConfiguration(input);
43
+ assert.equal(configuration.plans.pro.amount, 1200);
44
+ input.extensions.payments.plans.pro.amount = 2400;
45
+ assert.equal(configuration.plans.pro.amount, 1200);
46
+ assert.doesNotThrow(() => structuredClone(configuration));
47
+ });
48
+
49
+ test("transactional payment state isolates subjects, grants once, prevents overspend and reconciles current facts", async (t) => {
50
+ const db = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
51
+ await migration.up(db);
52
+ const store = createKnexPaymentStore({ knex: db });
53
+ let now = 1000;
54
+ const service = createPaymentService({ store, configuration: validatePaymentConfiguration(document), clock: () => now });
55
+ try {
56
+ await t.test("read-only inspection creates no rows and refuses a foreign merchant", async () => {
57
+ assert.equal((await service.inspect(scope)).balance, 0);
58
+ assert.equal((await db("payment_accounts")).length, 0);
59
+ await assert.rejects(service.inspect({ ...scope, providerAccountId: "someone-else" }), { code: "payment_scope_invalid" });
60
+ });
61
+ await t.test("duplicate grants and concurrent debits preserve the balance", async () => {
62
+ const grant = { reference: "topup-1", units: 10, expiresAt: 5000 };
63
+ assert.equal((await service.grantCredits(scope, grant)).duplicate, false);
64
+ assert.equal((await service.grantCredits(scope, grant)).duplicate, true);
65
+ await assert.rejects(service.grantCredits(scope, { ...grant, units: 11 }), { code: "payment_reference_conflict" });
66
+ const results = await Promise.allSettled(["job-1", "job-2"].map((id) => service.debitCredits(scope, { reference: id, units: 7 })));
67
+ assert.equal(results.filter((result) => result.status === "fulfilled").length, 1);
68
+ assert.equal((await service.inspect(scope)).balance, 3);
69
+ assert.equal((await service.inspect({ ...scope, subjectId: "tenant-b" })).balance, 0);
70
+ assert.equal((await service.inspect({ ...scope, applicationId: "another-app" })).balance, 0);
71
+ assert.equal((await service.inspect({ ...scope, integrationId: "production", providerAccountId: "acct_live", environment: "live" })).balance, 0);
72
+ const id = results[0].status === "fulfilled" ? "job-1" : "job-2";
73
+ assert.equal((await service.debitCredits(scope, { reference: id, units: 7 })).duplicate, true);
74
+ assert.equal((await service.refundDebit(scope, { debitReference: id })).restored, 7);
75
+ assert.equal((await service.refundDebit(scope, { debitReference: id })).duplicate, true);
76
+ });
77
+ await t.test("refunds do not resurrect expired grants", async () => {
78
+ await service.debitCredits(scope, { reference: "expired-job", units: 4 });
79
+ now = 6000;
80
+ const result = await service.refundDebit(scope, { debitReference: "expired-job" });
81
+ assert.equal(result.restored, 0);
82
+ assert.equal(result.expired, 4);
83
+ assert.equal((await service.inspect(scope)).balance, 0);
84
+ });
85
+ await t.test("customer bindings cannot be stolen or resolved across environments", async () => {
86
+ await store.bindCustomer(scope, "cus_a", "tenant-a");
87
+ await assert.rejects(store.bindCustomer(scope, "cus_a", "tenant-b"), /already bound/);
88
+ await assert.rejects(service.reconcileEvent(scope, { eventId: "evt_unbound", customerId: "cus_b", load: async () => {} }), { code: "payment_customer_unbound" });
89
+ });
90
+ await t.test("event failure rolls back; retries grant once per renewal rather than event", async () => {
91
+ const subscription = { id: "sub_a", status: "active", planId: "pro", periodEnd: 10000 };
92
+ const renewal = { id: "invoice_a", planId: "pro", periodEnd: 10000 };
93
+ const event = { eventId: "evt_a", customerId: "cus_a", load: async () => ({ subscription, renewal }) };
94
+ await assert.rejects(service.reconcileEvent(scope, { ...event, load: async () => { throw new Error("provider unavailable"); } }), /unavailable/);
95
+ assert.equal((await service.reconcileEvent(scope, event)).duplicate, false);
96
+ assert.equal((await service.reconcileEvent(scope, event)).duplicate, true);
97
+ await service.reconcileEvent(scope, { ...event, eventId: "evt_b" });
98
+ assert.equal((await service.inspect(scope)).balance, 100);
99
+ await service.requireFeature(scope, "export");
100
+ // A late delivery loads current canceled state, not the old event payload.
101
+ await service.reconcileEvent(scope, { ...event, eventId: "evt_older", load: async () => ({ subscription: { ...subscription, status: "canceled" } }) });
102
+ await assert.rejects(service.requireFeature(scope, "export"), { code: "payment_feature_required" });
103
+ assert.equal((await service.inspect(scope)).balance, 100);
104
+ });
105
+ await t.test("transaction interruption leaves neither receipt nor mutated balance", async () => {
106
+ await assert.rejects(store.withAccount(scope, async (tx) => {
107
+ tx.state.lots[0].remaining = 100000;
108
+ await tx.record("interrupted", { invalid: true });
109
+ throw new Error("abort");
110
+ }), /abort/);
111
+ assert.equal(await store.withAccount(scope, (tx) => tx.find("interrupted")), null);
112
+ assert.equal((await service.inspect(scope)).balance, 100);
113
+ });
114
+ } finally {
115
+ await migration.down(db);
116
+ await db.destroy();
117
+ }
118
+ });