@jskit-ai/payments-core 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - `@jskit-ai/database-runtime` and `@jskit-ai/resource-runtime` are optional peer dependencies, as they are of assistant-runtime, instead of dependencies: an app without a database installs, composes and starts the package without them, where installing them made boot refuse it for want of a database driver. An app that uses its payment store has them through its database.
6
+ - On json-rest-api 3.0.0's release candidate, an account's changed state is written with one read of the locked row before its UPDATE, where it took two.
7
+ - The payment store locks or creates each account and catalogue row with its handle's `lockOrCreate`, in place of a helper of its own with the same behaviour.
8
+ - Breaking: `createKnexPaymentStore` is gone, so the payment tables have one owner, `PaymentStoreFeature`. A JSKIT application registers it in its server providers and composes billing on the `payments.store` it provides; a script or CLI starts it in a capability runtime of its own on the application's Knex, as `docs/standalone.md` shows. `createPaymentService` given no store names that fix, and a missing configuration names `validatePaymentConfiguration`.
9
+ - `withAccount` and `withCatalogue` create a missing account or catalogue row inside the work's own transaction (an insert that skips an existing id), so work that fails leaves no row behind. Two first uses of one key that collide on that insert before either work runs (a deadlock on MySQL's and MariaDB's gap locks, a duplicate key elsewhere) run the loser again, up to three times; work that has started never runs again. An existing row costs one locking read, where the store read it once outside the transaction and again under the lock, and work that leaves the state as it was (a duplicate grant, a read) writes nothing.
10
+
3
11
  ## 0.2.0 - 2026-10-29
4
12
 
5
13
  - Translation ready: every user-visible text is a message key with its English in `locales/en.json`, so an app translates it by supplying translation files only. With no translations, texts and behaviour are unchanged. See the guide "Translating a JSKIT app".
package/README.md CHANGED
@@ -24,7 +24,11 @@ they are not an editor configuration database.
24
24
  Public API imports:
25
25
 
26
26
  - `@jskit-ai/payments-core/shared`: `validatePaymentConfiguration(document)`.
27
- - `@jskit-ai/payments-core/server/storage`: `createKnexPaymentStore({ knex })`.
27
+ - `@jskit-ai/payments-core/server/storage`: `PaymentStoreFeature`, the payment
28
+ tables' one owner: registered in the server providers, it owns them through
29
+ `runtime.resources` and provides the payment store as `payments.store`. A
30
+ script or CLI starts it in a capability runtime of its own
31
+ ([standalone setup](docs/standalone.md#payments-from-a-script-or-cli)).
28
32
  - `@jskit-ai/payments-core/server`: `createPaymentService({ store, configuration, clock })`.
29
33
  - `@jskit-ai/payments-core/server/stripe`: `createStripePaymentAdapter(options)`.
30
34
  - `@jskit-ai/payments-core/server/paddle`: `createPaddlePaymentAdapter(options)`.
@@ -81,9 +81,12 @@ values, including the placeholder `MISSING`, must fail setup visibly.
81
81
 
82
82
  ## Compose the server libraries
83
83
 
84
- This is application startup code. `knex`, `authorizeBilling`, `applicationId`,
85
- `environment` and `configurationUrl` are supplied by the application's existing
86
- server composition. Select them on the server. `applicationId` is a durable
84
+ This is application startup code, in a server provider that requires
85
+ `payments.store`: the payment store of `PaymentStoreFeature` (from
86
+ `@jskit-ai/payments-core/server/storage`), which the application registers in
87
+ its server providers as the payment tables' one owner. `store`,
88
+ `authorizeBilling`, `applicationId`, `environment` and `configurationUrl` are
89
+ supplied by the application's existing server composition. Select them on the server. `applicationId` is a durable
87
90
  application identity, not its current domain. The environment is explicitly
88
91
  `sandbox` or `live`; do not silently fall back between them.
89
92
 
@@ -91,7 +94,6 @@ application identity, not its current domain. The environment is explicitly
91
94
  import { readFile } from 'node:fs/promises';
92
95
  import { createEnvironmentReferenceResolver } from '@jskit-ai/connectors-core/server';
93
96
  import { validatePaymentConfiguration } from '@jskit-ai/payments-core/shared';
94
- import { createKnexPaymentStore } from '@jskit-ai/payments-core/server/storage';
95
97
  import { createPaymentService } from '@jskit-ai/payments-core/server';
96
98
  import { createStripePaymentAdapter } from '@jskit-ai/payments-core/server/stripe';
97
99
  import { createPaymentCheckoutService } from '@jskit-ai/payments-core/server/checkout';
@@ -114,7 +116,6 @@ const merchantScope = {
114
116
  applicationId, integrationId: binding.integrationId,
115
117
  providerAccountId: binding.providerAccountId, environment
116
118
  };
117
- const store = createKnexPaymentStore({ knex });
118
119
  const payments = createPaymentService({ store, configuration });
119
120
  const published = await store.inspectCatalogue(merchantScope);
120
121
  const priceBindings = Object.fromEntries(Object.entries(published.plans)
@@ -152,6 +153,36 @@ preserving the unmodified request Buffer. Enforce features using
152
153
  using a stable business-operation reference. A successful checkout redirect is
153
154
  not proof of payment: verified provider reconciliation updates access and credits.
154
155
 
156
+ ## Payments from a script or CLI
157
+
158
+ Code outside the server, such as a CLI that publishes the catalogue, starts
159
+ `PaymentStoreFeature` in a capability runtime of its own on the application's
160
+ Knex, so the payment tables keep one owner:
161
+
162
+ ```js
163
+ import { ActionProvider } from '@jskit-ai/kernel/server/actions';
164
+ import { EventProvider } from '@jskit-ai/kernel/server/runtime';
165
+ import { silentLogger } from '@jskit-ai/kernel/server/runtime/logger';
166
+ import { createCapabilityRuntime, defineProvider } from '@jskit-ai/kernel/shared/capabilities';
167
+ import { ResourceRuntimeProvider } from '@jskit-ai/resource-runtime/server';
168
+ import { PaymentStoreFeature } from '@jskit-ai/payments-core/server/storage';
169
+
170
+ let store;
171
+ const runtime = createCapabilityRuntime({
172
+ inputs: { 'runtime.logger': silentLogger },
173
+ providers: [
174
+ EventProvider,
175
+ ActionProvider,
176
+ defineProvider({ id: 'cli.database', provides: { database: 'runtime.database' }, setup: () => ({ database: { knex } }) }),
177
+ ResourceRuntimeProvider,
178
+ PaymentStoreFeature,
179
+ defineProvider({ id: 'cli.payments', requires: { payments: 'payments.store' }, setup({ payments }) { store = payments; } })
180
+ ]
181
+ });
182
+ await runtime.start();
183
+ // ... compose the services above on `store`, then `await runtime.shutdown()` and destroy the Knex.
184
+ ```
185
+
155
186
  ## Paddle composition
156
187
 
157
188
  Use `createPaddlePaymentAdapter` from `@jskit-ai/payments-core/server/paddle`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/payments-core",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Application-owned payment configuration, subscription access and transactional usage credits.",
6
6
  "exports": {
@@ -10,7 +10,7 @@
10
10
  "./docs/contract.md": "./docs/contract.md",
11
11
  "./docs/conformance.md": "./docs/conformance.md",
12
12
  "./server": "./src/server/service.js",
13
- "./server/storage": "./src/server/knexStore.js",
13
+ "./server/storage": "./src/server/storage.js",
14
14
  "./server/checkout": "./src/server/checkout.js",
15
15
  "./server/stripe": "./src/server/stripe.js",
16
16
  "./server/paddle": "./src/server/paddle.js",
@@ -20,9 +20,10 @@
20
20
  "./locales/en.json": "./locales/en.json"
21
21
  },
22
22
  "scripts": {
23
- "test": "node --test --test-concurrency=1"
23
+ "test": "jskit test"
24
24
  },
25
25
  "dependencies": {
26
+ "@jskit-ai/resource-core": "0.1.205",
26
27
  "@paddle/paddle-node-sdk": "3.10.0",
27
28
  "ajv": "8.20.0",
28
29
  "stripe": "22.6.2"
@@ -66,5 +67,18 @@
66
67
  "providers": []
67
68
  }
68
69
  }
70
+ },
71
+ "peerDependencies": {
72
+ "@jskit-ai/database-runtime": "0.1.263",
73
+ "@jskit-ai/kernel": "0.2.14",
74
+ "@jskit-ai/resource-runtime": "0.2.12"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@jskit-ai/database-runtime": {
78
+ "optional": true
79
+ },
80
+ "@jskit-ai/resource-runtime": {
81
+ "optional": true
82
+ }
69
83
  }
70
84
  }
@@ -0,0 +1,28 @@
1
+ import { defineFeature } from "@jskit-ai/kernel/server/features";
2
+ import { PAYMENT_TABLES } from "./paymentResources.js";
3
+ import { createPaymentStore } from "./paymentStore.js";
4
+
5
+ const PAYMENT_STORE_FEATURE = "payments.store";
6
+ const PAYMENT_STORE_SYSTEM_SCOPE = "payment rows are keyed by their merchant and billable subject, which is their scope, not by a workspace or user";
7
+
8
+ // The owner of the payment tables in a JSKIT application: it provides the payment store on their handles as
9
+ // `payments.store`. An application that keeps payments elsewhere passes its own store and leaves this feature out.
10
+ const PaymentStoreFeature = defineFeature({
11
+ id: PAYMENT_STORE_FEATURE,
12
+ domain: "payments",
13
+ owns: PAYMENT_TABLES,
14
+ systemScope: PAYMENT_STORE_SYSTEM_SCOPE,
15
+ provides: {
16
+ store: "payments.store"
17
+ },
18
+ /**
19
+ * @param {object} dependencies
20
+ * @param {Record<string, import("@jskit-ai/resource-runtime/server/types").ResourceHandle>} dependencies.resources
21
+ * @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
22
+ */
23
+ setup({ resources, systemContext }) {
24
+ return { store: createPaymentStore({ resources, systemContext }) };
25
+ }
26
+ });
27
+
28
+ export { PAYMENT_STORE_FEATURE, PAYMENT_STORE_SYSTEM_SCOPE, PaymentStoreFeature };
@@ -0,0 +1,71 @@
1
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
2
+ import { normalizeOpaqueId } from "@jskit-ai/kernel/shared/support/normalize";
3
+
4
+ // The payment tables. Each row is keyed by a fingerprint of its merchant (and, for accounts and their entries, its
5
+ // billable subject), not by a workspace or user column: the key is the row's scope, so the tables are public to their
6
+ // owner feature, which reaches them only through keys it computes from a caller's merchant and subject.
7
+
8
+ // A string key the store computes; text is kept exactly as written.
9
+ const key = (column) => Object.freeze({ type: "string", primary: true, required: true, storage: { column } });
10
+ const text = Object.freeze({ type: "string", required: true, noTrim: true });
11
+
12
+ // One billable subject's account under one merchant: its state as JSON.
13
+ const paymentAccountResource = defineTableResource({
14
+ name: "paymentAccounts",
15
+ tableName: "payment_accounts",
16
+ ownership: "public",
17
+ idProperty: "account_key",
18
+ schema: {
19
+ id: key("account_key"),
20
+ payload: text
21
+ }
22
+ });
23
+
24
+ // What an account recorded once under a business reference, keyed by the account and the reference.
25
+ const paymentEntryResource = defineTableResource({
26
+ name: "paymentEntries",
27
+ tableName: "payment_entries",
28
+ ownership: "public",
29
+ idProperty: "entry_key",
30
+ schema: {
31
+ id: key("entry_key"),
32
+ accountKey: { ...text, storage: { column: "account_key" } },
33
+ payload: text
34
+ }
35
+ });
36
+
37
+ // The billable subject a provider customer is bound to, per merchant.
38
+ const paymentCustomerResource = defineTableResource({
39
+ name: "paymentCustomers",
40
+ tableName: "payment_customers",
41
+ ownership: "public",
42
+ idProperty: "customer_key",
43
+ schema: {
44
+ id: key("customer_key"),
45
+ subjectId: { ...text, storage: { column: "subject_id" } }
46
+ }
47
+ });
48
+
49
+ // One merchant's published catalogue: its state as JSON.
50
+ const paymentCatalogueResource = defineTableResource({
51
+ name: "paymentCatalogues",
52
+ tableName: "payment_catalogues",
53
+ ownership: "public",
54
+ idProperty: "catalogue_key",
55
+ schema: {
56
+ id: key("catalogue_key"),
57
+ payload: text
58
+ }
59
+ });
60
+
61
+ // The feature's owns entries: each key is an opaque fingerprint, not a numeric id.
62
+ const PAYMENT_TABLES = Object.freeze([paymentAccountResource, paymentEntryResource, paymentCustomerResource, paymentCatalogueResource]
63
+ .map((resource) => Object.freeze({ resource, normalizeId: normalizeOpaqueId })));
64
+
65
+ export {
66
+ PAYMENT_TABLES,
67
+ paymentAccountResource,
68
+ paymentCatalogueResource,
69
+ paymentCustomerResource,
70
+ paymentEntryResource
71
+ };
@@ -0,0 +1,104 @@
1
+ import { PaymentError, fingerprint } from "./errors.js";
2
+
3
+ const empty = () => ({ lots: [], subscriptions: {} });
4
+ const emptyCatalogue = () => ({ plans: {}, history: [] });
5
+
6
+ function merchant(scope) {
7
+ const fields = ["applicationId", "integrationId", "providerAccountId", "environment"];
8
+ for (const field of fields) {
9
+ if (typeof scope?.[field] !== "string" || !scope[field] || scope[field].length > 200) throw new TypeError(`Payment scope requires ${field}.`);
10
+ }
11
+ if (!["sandbox", "live"].includes(scope.environment)) throw new TypeError("Payment scope requires sandbox or live.");
12
+ return fields.map((field) => scope[field]);
13
+ }
14
+ function identifier(value) {
15
+ if (typeof value !== "string" || !value || value.length > 200) throw new TypeError("Payment identity is required (maximum 200 characters).");
16
+ return value;
17
+ }
18
+ const accountKey = (scope) => fingerprint([...merchant(scope), identifier(scope.subjectId)]);
19
+ const customerKey = (scope, customerId) => fingerprint([...merchant(scope), identifier(customerId)]);
20
+ const entryKey = (account, reference) => fingerprint([account, identifier(reference)]);
21
+
22
+ /**
23
+ * The payment store on its table resources' handles: each account and each catalogue is one locked row holding its
24
+ * state as JSON, an account's entries are recorded once per reference, and a provider customer is bound to one
25
+ * subject per merchant. Every call works in a system context, because a row's scope is its key.
26
+ * @param {object} dependencies
27
+ * @param {Record<string, import("@jskit-ai/resource-runtime/server/types").ResourceHandle>} dependencies.resources The handles of PAYMENT_TABLES.
28
+ * @param {(reason: string) => object} dependencies.systemContext
29
+ * @returns {import("./types.js").PaymentStore}
30
+ */
31
+ function createPaymentStore({ resources, systemContext }) {
32
+ const { paymentAccounts: accounts, paymentEntries: entries, paymentCustomers: customers, paymentCatalogues: catalogues } = resources;
33
+ const accountOptions = Object.freeze({ context: systemContext("an account is read and changed by its merchant and subject key") });
34
+ const customerOptions = Object.freeze({ context: systemContext("a provider customer is bound and resolved by its merchant and customer key") });
35
+ const catalogueOptions = Object.freeze({ context: systemContext("a catalogue is read and changed by its merchant key") });
36
+
37
+ async function inspect(scope) {
38
+ const account = await accounts.get(accountKey(scope), {}, { ...accountOptions, missing: "skip" });
39
+ return account ? JSON.parse(account.payload) : empty();
40
+ }
41
+
42
+ async function withAccount(scope, work) {
43
+ const key = accountKey(scope);
44
+ // The retained account row serializes grants, usage, refunds and event handling.
45
+ return accounts.lockOrCreate({ id: key, payload: JSON.stringify(empty()) }, accountOptions, async (account, txOptions) => {
46
+ const state = JSON.parse(account.payload);
47
+ const stored = JSON.stringify(state);
48
+ const result = await work({
49
+ state,
50
+ async find(reference) {
51
+ const entry = await entries.get(entryKey(key, reference), {}, { ...txOptions, missing: "skip" });
52
+ return entry ? JSON.parse(entry.payload) : null;
53
+ },
54
+ async record(reference, payload) {
55
+ await entries.create({ id: entryKey(key, reference), accountKey: key, payload: JSON.stringify(payload) }, { ...txOptions, returning: "none" });
56
+ }
57
+ });
58
+ // Work that leaves the state as it was, such as a duplicate grant, writes nothing.
59
+ const payload = JSON.stringify(state);
60
+ if (payload !== stored) await accounts.patch(key, { payload }, { ...txOptions, returning: "none" });
61
+ return result;
62
+ });
63
+ }
64
+
65
+ async function bindCustomer(scope, customerId, subjectId) {
66
+ const id = customerKey(scope, customerId);
67
+ identifier(subjectId);
68
+ const bound = await customers.findOrCreate({ id, subjectId }, customerOptions);
69
+ if (bound.subjectId !== subjectId) throw new PaymentError("payment_customer_conflict", "This provider customer is already bound to a different billable subject.", 409);
70
+ }
71
+
72
+ async function resolveCustomer(scope, customerId) {
73
+ const bound = await customers.get(customerKey(scope, customerId), {}, { ...customerOptions, missing: "skip" });
74
+ return bound?.subjectId ?? null;
75
+ }
76
+
77
+ async function inspectCatalogue(scope) {
78
+ const catalogue = await catalogues.get(fingerprint(merchant(scope)), {}, { ...catalogueOptions, missing: "skip" });
79
+ return catalogue ? JSON.parse(catalogue.payload) : emptyCatalogue();
80
+ }
81
+
82
+ async function withCatalogue(scope, work) {
83
+ const key = fingerprint(merchant(scope));
84
+ return catalogues.lockOrCreate({ id: key, payload: JSON.stringify(emptyCatalogue()) }, catalogueOptions, async (catalogue, txOptions) => {
85
+ const state = JSON.parse(catalogue.payload);
86
+ const stored = JSON.stringify(state);
87
+ const result = await work(state);
88
+ const payload = JSON.stringify(state);
89
+ if (payload !== stored) await catalogues.patch(key, { payload }, { ...txOptions, returning: "none" });
90
+ return result;
91
+ });
92
+ }
93
+
94
+ return Object.freeze({
95
+ inspect,
96
+ withAccount,
97
+ bindCustomer,
98
+ resolveCustomer,
99
+ inspectCatalogue,
100
+ withCatalogue
101
+ });
102
+ }
103
+
104
+ export { createPaymentStore };
@@ -48,9 +48,15 @@ async function grant(tx, { reference: id, units: quantity, expiresAt }, now) {
48
48
  * @param {() => number} [dependencies.clock]
49
49
  */
50
50
  function createPaymentService({ store, configuration, clock = Date.now }) {
51
- // readable-check: allow method-probe - input validation: the application passes its own payment store and validated configuration
52
- if (typeof store?.withAccount !== "function" || typeof store?.inspect !== "function" || !configuration?.plans) {
53
- throw new TypeError("Supply payment storage and validated payment configuration.");
51
+ // readable-check: allow method-probe - input validation: the application passes its own payment store
52
+ if (typeof store?.withAccount !== "function" || typeof store?.inspect !== "function") {
53
+ throw new TypeError(
54
+ "createPaymentService requires a payment store: register PaymentStoreFeature from @jskit-ai/payments-core/server/storage " +
55
+ "in your server providers and pass the payments.store it provides."
56
+ );
57
+ }
58
+ if (!configuration?.plans) {
59
+ throw new TypeError("createPaymentService requires validated payment configuration: pass validatePaymentConfiguration(document) from @jskit-ai/payments-core/shared.");
54
60
  }
55
61
  const plans = structuredClone(configuration.plans);
56
62
  const environments = structuredClone(configuration.environments);
@@ -0,0 +1 @@
1
+ export { PaymentStoreFeature } from "./PaymentStoreFeature.js";
@@ -50,8 +50,8 @@
50
50
  */
51
51
 
52
52
  /**
53
- * Where payment accounts and catalogues are kept: createKnexPaymentStore() or
54
- * the application's own store. Each `with*` call runs its work under the
53
+ * Where payment accounts and catalogues are kept: the store PaymentStoreFeature
54
+ * provides as `payments.store`, or the application's own store. Each `with*` call runs its work under the
55
55
  * record's lock and saves the state the work leaves.
56
56
  * @typedef {object} PaymentStore
57
57
  * @property {(scope: PaymentAccountScope) => Promise<PaymentAccountState>} inspect
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import knex from 'knex';
4
4
  import migration from '../migrations/payments_core_initial.cjs';
5
- import { createKnexPaymentStore } from '../src/server/knexStore.js';
5
+ import { createTestPaymentStore } from './support/paymentStore.js';
6
6
  import { createPaymentCatalogue } from '../src/server/catalogue.js';
7
7
 
8
8
  const scope = { applicationId: 'app', integrationId: 'billing', providerAccountId: 'acct_a', environment: 'sandbox' };
@@ -13,7 +13,7 @@ const config = { environments: { sandbox: { integrationId: 'billing', providerAc
13
13
  test('reviewed catalogue publication preserves IDs, handles drift, and recovers a partial write', async () => {
14
14
  const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
15
15
  await migration.up(db);
16
- const store = createKnexPaymentStore({ knex: db });
16
+ const store = await createTestPaymentStore(db);
17
17
  const products = new Map(); const prices = new Map();
18
18
  let writes = 0; let failPrice = false;
19
19
  const adapter = {
@@ -68,7 +68,7 @@ test('reviewed catalogue publication preserves IDs, handles drift, and recovers
68
68
  test('sandbox and live publish independently and cannot reuse each other’s review', async () => {
69
69
  const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
70
70
  await migration.up(db);
71
- const store = createKnexPaymentStore({ knex: db });
71
+ const store = await createTestPaymentStore(db);
72
72
  const configuration = structuredClone(config);
73
73
  configuration.environments.live = { integrationId: 'billing-live', providerAccountId: 'acct_live' };
74
74
  const liveScope = { ...scope, environment: 'live', ...configuration.environments.live };
@@ -5,7 +5,7 @@ import knex from 'knex';
5
5
  import Stripe from 'stripe';
6
6
  import { Paddle, Environment } from '@paddle/paddle-node-sdk';
7
7
  import migration from '../migrations/payments_core_initial.cjs';
8
- import { createKnexPaymentStore } from '../src/server/knexStore.js';
8
+ import { createTestPaymentStore } from './support/paymentStore.js';
9
9
  import { createPaymentService } from '../src/server/service.js';
10
10
  import { createPaymentCheckoutService } from '../src/server/checkout.js';
11
11
  import { createStripePaymentAdapter } from '../src/server/stripe.js';
@@ -36,7 +36,7 @@ test('official SDK signature verification rejects mutation and expired delivery
36
36
  test('checkout authorizes before provider access, persists intent, deduplicates requests and needs explicit recovery', async () => {
37
37
  const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
38
38
  await migration.up(db);
39
- const store = createKnexPaymentStore({ knex: db });
39
+ const store = await createTestPaymentStore(db);
40
40
  let creates = 0;
41
41
  let fails = false;
42
42
  const adapter = {
@@ -163,7 +163,7 @@ test('Paddle reconciliation uses current status and grants only qualifying compl
163
163
  test('signed subscription lifecycle updates app access without crossing subjects or environments', async () => {
164
164
  const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
165
165
  await migration.up(db);
166
- const store = createKnexPaymentStore({ knex: db });
166
+ const store = await createTestPaymentStore(db);
167
167
  const stripeSdk = new Stripe('sk_test_fixture', { maxNetworkRetries: 0 });
168
168
  const paddleSdk = new Paddle('test-fixture', { environment: Environment.sandbox });
169
169
  try {
@@ -6,7 +6,7 @@ import knex from 'knex';
6
6
  import migration from '../migrations/payments_core_initial.cjs';
7
7
  import { validatePaymentConfiguration } from '../src/shared/configuration.js';
8
8
  import { createPaymentService } from '../src/server/service.js';
9
- import { createKnexPaymentStore } from '../src/server/knexStore.js';
9
+ import { createTestPaymentStore } from './support/paymentStore.js';
10
10
 
11
11
  const fixture = JSON.parse(await readFile(new URL('../contracts/conformance.json', import.meta.url), 'utf8'));
12
12
  const schema = JSON.parse(await readFile(new URL('../contracts/configuration.schema.json', import.meta.url), 'utf8'));
@@ -53,7 +53,7 @@ test('portable account sequence defines actual credit, renewal, expiry and subje
53
53
  const scenario = fixture.accountScenario;
54
54
  const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
55
55
  await migration.up(db);
56
- const store = createKnexPaymentStore({ knex: db });
56
+ const store = await createTestPaymentStore(db);
57
57
  let now = scenario.clock;
58
58
  const service = createPaymentService({ store, configuration: validatePaymentConfiguration(scenario.document), clock: () => now });
59
59
  const { subjectId, ...merchant } = scenario.scope;
@@ -0,0 +1,396 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
5
+ import migration from "../migrations/payments_core_initial.cjs";
6
+ import { createPaymentService } from "../src/server/service.js";
7
+ import { validatePaymentConfiguration } from "../src/shared/configuration.js";
8
+ import { createTestPaymentStore } from "./support/paymentStore.js";
9
+
10
+ // The payment store's behaviour on MariaDB, through its public functions, as PaymentStoreFeature provides it: what an
11
+ // account, its entries, a provider customer and a merchant's catalogue answer and keep; which scope sees which rows;
12
+ // that work on one account or catalogue runs one at a time and rolls back as a whole, its row's creation included; and
13
+ // the errors it gives.
14
+
15
+ // The runtime's own MySQL connection settings (database-runtime's resolveKnexConnectionFromEnvironment).
16
+ const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
17
+
18
+ /** A migrated database of the test's own, beside TEST_DB_NAME, dropped after the test. */
19
+ async function ownDatabase(context, suffix) {
20
+ const name = `${requireTestDatabase(process.env)}_${suffix}`;
21
+ const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
22
+ const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
23
+ await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
24
+ await admin.raw("CREATE DATABASE ??", [name]);
25
+ const knex = knexLib({ client: "mysql2", connection: { ...server, database: name, ...RUNTIME_MYSQL }, pool: { min: 0, max: 6 } });
26
+ context.after(async () => {
27
+ await knex.destroy();
28
+ await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
29
+ await admin.destroy();
30
+ });
31
+ await migration.up(knex);
32
+ return knex;
33
+ }
34
+
35
+ const merchant = Object.freeze({ applicationId: "app", integrationId: "billing", providerAccountId: "acct_test", environment: "sandbox" });
36
+ const subject = (subjectId, changes = {}) => ({ ...merchant, ...changes, subjectId });
37
+ const EMPTY_ACCOUNT = Object.freeze({ lots: [], subscriptions: {} });
38
+ const EMPTY_CATALOGUE = Object.freeze({ plans: {}, history: [] });
39
+ const IDENTITY_REQUIRED = { name: "TypeError", message: "Payment identity is required (maximum 200 characters)." };
40
+
41
+ /** The rows in one of the payment tables (COUNT is a big number, which the runtime reads as a string). */
42
+ async function rowCount(knex, table) {
43
+ const [{ rows }] = await knex(table).count({ rows: "*" });
44
+ return Number(rows);
45
+ }
46
+
47
+ /** A promise the test resolves itself, to hold one piece of work while others run. */
48
+ function gate() {
49
+ let open;
50
+ const opened = new Promise((resolve) => {
51
+ open = resolve;
52
+ });
53
+ return { opened, open };
54
+ }
55
+
56
+ test("the store refuses incomplete scopes and identities before any work runs", async (context) => {
57
+ requireTestDatabase(process.env);
58
+ const store = await createTestPaymentStore(await ownDatabase(context, "da_pay_refusals"));
59
+ assert.ok(Object.isFrozen(store));
60
+ assert.deepEqual(Object.keys(store), ["inspect", "withAccount", "bindCustomer", "resolveCustomer", "inspectCatalogue", "withCatalogue"]);
61
+
62
+ for (const [changes, message] of [
63
+ [{ applicationId: "" }, "Payment scope requires applicationId."],
64
+ [{ integrationId: 7 }, "Payment scope requires integrationId."],
65
+ [{ providerAccountId: "a".repeat(201) }, "Payment scope requires providerAccountId."],
66
+ [{ environment: undefined }, "Payment scope requires environment."],
67
+ [{ environment: "production" }, "Payment scope requires sandbox or live."]
68
+ ]) {
69
+ await assert.rejects(store.inspect(subject("tenant-a", changes)), { name: "TypeError", message }, message);
70
+ await assert.rejects(store.inspectCatalogue({ ...merchant, ...changes }), { name: "TypeError", message }, message);
71
+ await assert.rejects(store.resolveCustomer({ ...merchant, ...changes }, "cus_1"), { name: "TypeError", message }, message);
72
+ }
73
+ // A field of exactly 200 characters is accepted.
74
+ assert.deepEqual(await store.inspect(subject("s".repeat(200), { providerAccountId: "p".repeat(200) })), EMPTY_ACCOUNT);
75
+
76
+ let worked = false;
77
+ const work = async () => {
78
+ worked = true;
79
+ };
80
+ await assert.rejects(store.inspect(merchant), IDENTITY_REQUIRED, "an account needs its subject");
81
+ await assert.rejects(store.inspect(subject("s".repeat(201))), IDENTITY_REQUIRED);
82
+ await assert.rejects(store.withAccount(merchant, work), IDENTITY_REQUIRED);
83
+ await assert.rejects(store.withAccount(subject("tenant-a", { environment: "test" })), { name: "TypeError", message: "Payment scope requires sandbox or live." });
84
+ await assert.rejects(store.withCatalogue({ ...merchant, applicationId: null }, work), { name: "TypeError", message: "Payment scope requires applicationId." });
85
+ assert.equal(worked, false, "no work runs for a refused scope");
86
+ await assert.rejects(store.bindCustomer(merchant, "", "tenant-a"), IDENTITY_REQUIRED);
87
+ await assert.rejects(store.bindCustomer(merchant, "cus_1", ""), IDENTITY_REQUIRED);
88
+ await assert.rejects(store.bindCustomer(merchant, "cus_1", 42), IDENTITY_REQUIRED);
89
+ await assert.rejects(store.resolveCustomer(merchant, "c".repeat(201)), IDENTITY_REQUIRED);
90
+ assert.equal(await store.resolveCustomer(merchant, "cus_1"), null, "the refused binding left nothing behind");
91
+ });
92
+
93
+ test("an account starts empty, keeps the state its work leaves as JSON, and answers what the work returns", async (context) => {
94
+ requireTestDatabase(process.env);
95
+ const store = await createTestPaymentStore(await ownDatabase(context, "da_pay_account"));
96
+ const scope = subject("tenant-a");
97
+
98
+ const fresh = await store.inspect(scope);
99
+ assert.deepEqual(fresh, EMPTY_ACCOUNT);
100
+ fresh.lots.push({ credits: 1 });
101
+ assert.deepEqual(await store.inspect(scope), EMPTY_ACCOUNT, "each inspect answers a new copy");
102
+
103
+ const result = await store.withAccount(scope, async (account) => {
104
+ assert.deepEqual(Object.keys(account).sort(), ["find", "record", "state"]);
105
+ assert.deepEqual(account.state, EMPTY_ACCOUNT, "a new account's work starts from the empty state");
106
+ account.state.lots.push({ credits: 5, grantedAt: new Date(Date.UTC(2026, 0, 1)), note: undefined });
107
+ account.state.subscriptions.pro = { status: "active" };
108
+ account.state.extra = { kept: true };
109
+ return { granted: 5 };
110
+ });
111
+ assert.deepEqual(result, { granted: 5 });
112
+ const expected = {
113
+ lots: [{ credits: 5, grantedAt: "2026-01-01T00:00:00.000Z" }],
114
+ subscriptions: { pro: { status: "active" } },
115
+ extra: { kept: true }
116
+ };
117
+ assert.deepEqual(await store.inspect(scope), expected, "the whole state is kept, as JSON: dates become text, undefined goes");
118
+
119
+ await store.withAccount(scope, async ({ state }) => {
120
+ assert.deepEqual(state, expected, "the next work starts from the kept state");
121
+ });
122
+ assert.deepEqual(await store.inspect(scope), expected, "work that changes nothing keeps the state");
123
+ assert.equal(await store.withAccount(scope, async () => undefined), undefined);
124
+ });
125
+
126
+ test("entries are recorded once per reference, read back by find, and belong to their own account", async (context) => {
127
+ requireTestDatabase(process.env);
128
+ const store = await createTestPaymentStore(await ownDatabase(context, "da_pay_entries"));
129
+ const scope = subject("tenant-a");
130
+
131
+ await store.withAccount(scope, async ({ find, record }) => {
132
+ assert.equal(await find("event:evt_1"), null);
133
+ assert.equal(await record("event:evt_1", { amount: 5, at: new Date(Date.UTC(2026, 1, 2)), skipped: undefined }), undefined);
134
+ assert.deepEqual(await find("event:evt_1"), { amount: 5, at: "2026-02-02T00:00:00.000Z" }, "seen at once inside the same work");
135
+ });
136
+ assert.deepEqual(await store.withAccount(scope, ({ find }) => find("event:evt_1")), { amount: 5, at: "2026-02-02T00:00:00.000Z" });
137
+
138
+ // Another subject, or the same subject under another merchant, has entries of its own.
139
+ for (const other of [subject("tenant-b"), subject("tenant-a", { environment: "live" }), subject("tenant-a", { applicationId: "other-app" })]) {
140
+ assert.equal(await store.withAccount(other, ({ find }) => find("event:evt_1")), null);
141
+ }
142
+ await store.withAccount(subject("tenant-b"), ({ record }) => record("event:evt_1", { amount: 9 }));
143
+ assert.deepEqual(await store.withAccount(subject("tenant-b"), ({ find }) => find("event:evt_1")), { amount: 9 });
144
+ assert.deepEqual(await store.withAccount(scope, ({ find }) => find("event:evt_1")), { amount: 5, at: "2026-02-02T00:00:00.000Z" });
145
+
146
+ // Recording a reference twice fails the whole work with the driver's own duplicate-key error (no PaymentError),
147
+ // and nothing the work did is kept.
148
+ await assert.rejects(store.withAccount(scope, async ({ state, record }) => {
149
+ state.lots.push({ credits: 1 });
150
+ await record("event:evt_1", { amount: 6 });
151
+ }), (error) => {
152
+ assert.equal(error.code, "ER_DUP_ENTRY");
153
+ assert.notEqual(error.name, "PaymentError");
154
+ return true;
155
+ });
156
+ assert.deepEqual(await store.inspect(scope), EMPTY_ACCOUNT);
157
+ assert.deepEqual(await store.withAccount(scope, ({ find }) => find("event:evt_1")), { amount: 5, at: "2026-02-02T00:00:00.000Z" });
158
+
159
+ await assert.rejects(store.withAccount(scope, ({ find }) => find("")), IDENTITY_REQUIRED);
160
+ await assert.rejects(store.withAccount(scope, ({ record }) => record("r".repeat(201), {})), IDENTITY_REQUIRED);
161
+ await assert.rejects(store.withAccount(scope, ({ find }) => find(7)), IDENTITY_REQUIRED);
162
+ });
163
+
164
+ test("work that fails rolls back its state and entries, and its error reaches the caller unchanged", async (context) => {
165
+ requireTestDatabase(process.env);
166
+ const knex = await ownDatabase(context, "da_pay_rollback");
167
+ const store = await createTestPaymentStore(knex);
168
+ const scope = subject("tenant-a");
169
+ await store.withAccount(scope, async ({ state, record }) => {
170
+ state.lots.push({ credits: 1 });
171
+ await record("debit:first", { quantity: 1 });
172
+ });
173
+
174
+ const failure = new Error("provider unavailable");
175
+ await assert.rejects(store.withAccount(scope, async ({ state, record }) => {
176
+ state.lots.push({ credits: 2 });
177
+ state.subscriptions.pro = { status: "active" };
178
+ await record("debit:second", { quantity: 2 });
179
+ throw failure;
180
+ }), (error) => error === failure);
181
+ assert.deepEqual(await store.inspect(scope), { lots: [{ credits: 1 }], subscriptions: {} });
182
+ assert.deepEqual(await store.withAccount(scope, async ({ find }) => [await find("debit:first"), await find("debit:second")]), [{ quantity: 1 }, null]);
183
+
184
+ // A new account or catalogue whose first work fails leaves no row: its creation rolls back with the work.
185
+ await assert.rejects(store.withAccount(subject("tenant-new"), async ({ state }) => {
186
+ state.lots.push({ credits: 3 });
187
+ throw failure;
188
+ }), (error) => error === failure);
189
+ assert.deepEqual(await store.inspect(subject("tenant-new")), EMPTY_ACCOUNT);
190
+ await assert.rejects(store.withCatalogue(merchant, async (state) => {
191
+ state.history.push("never kept");
192
+ throw failure;
193
+ }), (error) => error === failure);
194
+ assert.equal(await rowCount(knex, "payment_accounts"), 1, "tenant-a's account alone");
195
+ assert.equal(await rowCount(knex, "payment_catalogues"), 0);
196
+ });
197
+
198
+ test("a reference granted again answers duplicate: true, also when two first grants on a new account race", { timeout: 20_000 }, async (context) => {
199
+ requireTestDatabase(process.env);
200
+ const knex = await ownDatabase(context, "da_pay_duplicate");
201
+ const store = await createTestPaymentStore(knex);
202
+ const configuration = validatePaymentConfiguration({
203
+ integrations: { billing: { provider: "stripe", accountMode: "shared", authentication: { method: "api-key", secretRef: "env:STRIPE_KEY" } } },
204
+ extensions: { payments: {
205
+ version: 1,
206
+ environments: { sandbox: { integrationId: "billing", providerAccountId: "acct_test", webhookSecretRef: "env:STRIPE_WEBHOOK_SECRET", returnUrlRef: "env:APP_URL" } },
207
+ plans: { pro: { name: "Pro", amount: 1200, currency: "USD", interval: "month", features: [], renewalCredits: 100 } }
208
+ } }
209
+ });
210
+ const payments = createPaymentService({ store, configuration, clock: () => 1000 });
211
+ const grant = { reference: "welcome", units: 5, expiresAt: null };
212
+
213
+ assert.deepEqual(await payments.grantCredits(subject("tenant-a"), grant), { granted: 5, expiresAt: null, duplicate: false });
214
+ assert.deepEqual(await payments.grantCredits(subject("tenant-a"), grant), { granted: 5, expiresAt: null, duplicate: true });
215
+ assert.equal((await payments.inspect(subject("tenant-a"))).balance, 5);
216
+
217
+ // Two first grants on accounts that do not exist yet, each pair with one reference: each account is created once,
218
+ // one grant of each pair applies and the other answers the first one's result as a duplicate.
219
+ const tenants = ["tenant-b", "tenant-c", "tenant-d"];
220
+ const outcomes = await Promise.all(tenants.flatMap((tenant) => [payments.grantCredits(subject(tenant), grant), payments.grantCredits(subject(tenant), grant)]));
221
+ for (const [index, tenant] of tenants.entries()) {
222
+ assert.deepEqual(outcomes.slice(index * 2, index * 2 + 2).map((outcome) => outcome.duplicate).sort(), [false, true], tenant);
223
+ assert.equal((await payments.inspect(subject(tenant))).balance, 5, tenant);
224
+ }
225
+ assert.equal(await rowCount(knex, "payment_accounts"), 4);
226
+ assert.equal(await rowCount(knex, "payment_entries"), 4);
227
+ });
228
+
229
+ test("the hot path: inspect is one read, and work on an existing account locks it once and writes only a changed state", async (context) => {
230
+ requireTestDatabase(process.env);
231
+ const knex = await ownDatabase(context, "da_pay_statements");
232
+ const store = await createTestPaymentStore(knex);
233
+ const scope = subject("tenant-a");
234
+ await store.withAccount(scope, async ({ state, record }) => {
235
+ state.lots.push({ credits: 1 });
236
+ await record("grant:first", { credits: 1 });
237
+ });
238
+ const statementsOf = async (operation) => {
239
+ const statements = [];
240
+ const listener = (query) => statements.push(query.sql);
241
+ knex.on("query", listener);
242
+ try {
243
+ await operation();
244
+ } finally {
245
+ knex.off("query", listener);
246
+ }
247
+ return statements.map((sql) => sql.split(" ")[0].toLowerCase());
248
+ };
249
+
250
+ assert.deepEqual(await statementsOf(() => store.inspect(scope)), ["select"]);
251
+ assert.deepEqual(await statementsOf(() => store.withAccount(scope, ({ find }) => find("grant:first"))), ["begin;", "select", "select", "commit;"],
252
+ "the account's lock and the entry's read; an unchanged state is not written");
253
+ // A changed state is one UPDATE, which json-rest-api's patch precedes with one read of the row the work holds locked.
254
+ assert.deepEqual(await statementsOf(() => store.withAccount(scope, async ({ state }) => {
255
+ state.lots[0].credits = 2;
256
+ })), ["begin;", "select", "select", "update", "commit;"]);
257
+ assert.deepEqual((await store.inspect(scope)).lots, [{ credits: 2 }]);
258
+ });
259
+
260
+ test("work on one account runs one at a time, while other accounts, the catalogue and plain reads go on", { timeout: 20_000 }, async (context) => {
261
+ requireTestDatabase(process.env);
262
+ const store = await createTestPaymentStore(await ownDatabase(context, "da_pay_serial"));
263
+ const scope = subject("tenant-a");
264
+ const other = subject("tenant-b");
265
+ await store.withAccount(scope, async ({ state }) => {
266
+ state.count = 0;
267
+ });
268
+ await store.withAccount(other, async () => {});
269
+
270
+ const entered = [];
271
+ const firstIn = gate();
272
+ const release = gate();
273
+ const first = store.withAccount(scope, async ({ state }) => {
274
+ entered.push("first");
275
+ firstIn.open();
276
+ const seen = state.count;
277
+ await release.opened;
278
+ state.count = seen + 1;
279
+ return seen;
280
+ });
281
+ await firstIn.opened;
282
+ const second = store.withAccount(scope, async ({ state }) => {
283
+ entered.push("second");
284
+ const seen = state.count;
285
+ state.count = seen + 1;
286
+ return seen;
287
+ });
288
+ // While the first work holds tenant-a: another account's work, the merchant's catalogue and plain reads finish.
289
+ await store.withAccount(other, async ({ state }) => {
290
+ state.touched = true;
291
+ });
292
+ await store.withCatalogue(merchant, async (state) => {
293
+ state.history.push("touched");
294
+ });
295
+ assert.deepEqual(await store.inspect(scope), { lots: [], subscriptions: {}, count: 0 }, "inspect reads what is committed, without waiting");
296
+ assert.deepEqual(entered, ["first"], "the second work on tenant-a waits for the first");
297
+ release.open();
298
+ assert.deepEqual(await Promise.all([first, second]), [0, 1], "the second work saw the first one's state: no update is lost");
299
+ assert.deepEqual(entered, ["first", "second"]);
300
+ assert.equal((await store.inspect(scope)).count, 2);
301
+ assert.equal((await store.inspect(other)).touched, true);
302
+ });
303
+
304
+ test("a provider customer binds to one subject per merchant: the first binding wins and a different one is refused", { timeout: 20_000 }, async (context) => {
305
+ requireTestDatabase(process.env);
306
+ const store = await createTestPaymentStore(await ownDatabase(context, "da_pay_customers"));
307
+
308
+ assert.equal(await store.resolveCustomer(merchant, "cus_1"), null);
309
+ assert.equal(await store.bindCustomer(merchant, "cus_1", "tenant-a"), undefined);
310
+ assert.equal(await store.resolveCustomer(merchant, "cus_1"), "tenant-a");
311
+ assert.equal(await store.bindCustomer(merchant, "cus_1", "tenant-a"), undefined, "binding again to the same subject is fine");
312
+
313
+ await assert.rejects(store.bindCustomer(merchant, "cus_1", "tenant-b"), (error) => {
314
+ assert.equal(error.name, "PaymentError");
315
+ assert.equal(error.code, "payment_customer_conflict");
316
+ assert.equal(error.statusCode, 409);
317
+ assert.equal(error.message, "This provider customer is already bound to a different billable subject.");
318
+ assert.equal(Object.hasOwn(error, "messageCode"), false);
319
+ return true;
320
+ });
321
+ assert.equal(await store.resolveCustomer(merchant, "cus_1"), "tenant-a");
322
+
323
+ // The customer belongs to the merchant: a subject in the scope is ignored, and another merchant has its own.
324
+ assert.equal(await store.resolveCustomer(subject("tenant-z"), "cus_1"), "tenant-a");
325
+ await assert.rejects(store.bindCustomer(subject("tenant-a"), "cus_1", "tenant-b"), { code: "payment_customer_conflict" });
326
+ for (const changes of [{ providerAccountId: "acct_other" }, { environment: "live" }, { integrationId: "other" }, { applicationId: "other-app" }]) {
327
+ assert.equal(await store.resolveCustomer({ ...merchant, ...changes }, "cus_1"), null);
328
+ }
329
+ await store.bindCustomer({ ...merchant, environment: "live" }, "cus_1", "tenant-b");
330
+ assert.equal(await store.resolveCustomer({ ...merchant, environment: "live" }, "cus_1"), "tenant-b");
331
+ assert.equal(await store.resolveCustomer(merchant, "cus_1"), "tenant-a");
332
+
333
+ // Two subjects binding a new customer at once: exactly one wins, the other is refused.
334
+ const outcomes = await Promise.allSettled([
335
+ store.bindCustomer(merchant, "cus_2", "tenant-x"),
336
+ store.bindCustomer(merchant, "cus_2", "tenant-y")
337
+ ]);
338
+ const won = outcomes.findIndex((outcome) => outcome.status === "fulfilled");
339
+ assert.deepEqual(outcomes.map((outcome) => outcome.status).sort(), ["fulfilled", "rejected"]);
340
+ assert.equal(outcomes[1 - won].reason.code, "payment_customer_conflict");
341
+ assert.equal(await store.resolveCustomer(merchant, "cus_2"), ["tenant-x", "tenant-y"][won]);
342
+ });
343
+
344
+ test("a merchant's catalogue starts empty, keeps what its work leaves, rolls back on failure and is shared by the merchant's subjects", { timeout: 20_000 }, async (context) => {
345
+ requireTestDatabase(process.env);
346
+ const store = await createTestPaymentStore(await ownDatabase(context, "da_pay_catalogue"));
347
+
348
+ const fresh = await store.inspectCatalogue(merchant);
349
+ assert.deepEqual(fresh, EMPTY_CATALOGUE);
350
+ fresh.history.push("changed");
351
+ assert.deepEqual(await store.inspectCatalogue(merchant), EMPTY_CATALOGUE, "each inspect answers a new copy");
352
+
353
+ const result = await store.withCatalogue(merchant, async (...args) => {
354
+ assert.equal(args.length, 1, "catalogue work gets the state alone");
355
+ const [state] = args;
356
+ assert.deepEqual(state, EMPTY_CATALOGUE);
357
+ state.plans.pro = { productId: "prod_1", priceId: "price_1", publishedAt: new Date(Date.UTC(2026, 2, 3)) };
358
+ state.history.push({ action: "create-price" });
359
+ return "published";
360
+ });
361
+ assert.equal(result, "published");
362
+ const saved = { plans: { pro: { productId: "prod_1", priceId: "price_1", publishedAt: "2026-03-03T00:00:00.000Z" } }, history: [{ action: "create-price" }] };
363
+ assert.deepEqual(await store.inspectCatalogue(merchant), saved);
364
+ assert.deepEqual(await store.inspectCatalogue(subject("tenant-a")), saved, "a subject in the scope is ignored");
365
+ assert.deepEqual(await store.inspect(subject("tenant-a")), EMPTY_ACCOUNT, "the catalogue is apart from the accounts");
366
+ for (const changes of [{ environment: "live" }, { providerAccountId: "acct_other" }, { integrationId: "other" }, { applicationId: "other-app" }]) {
367
+ assert.deepEqual(await store.inspectCatalogue({ ...merchant, ...changes }), EMPTY_CATALOGUE);
368
+ }
369
+
370
+ const failure = new Error("review expired");
371
+ await assert.rejects(store.withCatalogue(merchant, async (state) => {
372
+ state.plans.pro.priceId = "price_2";
373
+ throw failure;
374
+ }), (error) => error === failure);
375
+ assert.deepEqual(await store.inspectCatalogue(merchant), saved, "failed work keeps nothing");
376
+
377
+ // Catalogue work runs one at a time per merchant.
378
+ const firstIn = gate();
379
+ const release = gate();
380
+ const first = store.withCatalogue(merchant, async (state) => {
381
+ firstIn.open();
382
+ const seen = state.history.length;
383
+ await release.opened;
384
+ state.history.push({ action: "first" });
385
+ return seen;
386
+ });
387
+ await firstIn.opened;
388
+ const second = store.withCatalogue(merchant, async (state) => {
389
+ state.history.push({ action: "second" });
390
+ return state.history.length - 1;
391
+ });
392
+ assert.deepEqual(await store.withCatalogue({ ...merchant, environment: "live" }, async () => "other merchant"), "other merchant");
393
+ release.open();
394
+ assert.deepEqual(await Promise.all([first, second]), [1, 2]);
395
+ assert.deepEqual((await store.inspectCatalogue(merchant)).history, [{ action: "create-price" }, { action: "first" }, { action: "second" }]);
396
+ });
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { ActionProvider } from "@jskit-ai/kernel/server/actions";
5
+ import { EventProvider } from "@jskit-ai/kernel/server/runtime";
6
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
7
+ import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
8
+ import { createTestResourceRuntime } from "@jskit-ai/resource-runtime/test";
9
+ import migration from "../migrations/payments_core_initial.cjs";
10
+ import { PaymentStoreFeature } from "../src/server/storage.js";
11
+
12
+ const merchant = Object.freeze({ applicationId: "app", integrationId: "billing", providerAccountId: "acct_test", environment: "sandbox" });
13
+
14
+ test("PaymentStoreFeature owns the payment tables and provides the store on their handles as payments.store", async (t) => {
15
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
16
+ t.after(() => knex.destroy());
17
+ await migration.up(knex);
18
+ const resources = await createTestResourceRuntime({ knex });
19
+ let store = null;
20
+ const runtime = createCapabilityRuntime({
21
+ inputs: { "runtime.logger": silentLogger },
22
+ providers: [
23
+ EventProvider,
24
+ ActionProvider,
25
+ defineProvider({ id: "test.resources", provides: { resources: "runtime.resources" }, setup: () => ({ resources }) }),
26
+ PaymentStoreFeature,
27
+ defineProvider({ id: "test.payments", requires: { payments: "payments.store" }, setup({ payments }) { store = payments; } })
28
+ ]
29
+ });
30
+ await runtime.start();
31
+ t.after(() => runtime.shutdown());
32
+
33
+ assert.deepEqual(resources.ownedTables().map(({ tableName, featureId }) => [tableName, featureId]), [
34
+ ["payment_accounts", "payments.store"],
35
+ ["payment_entries", "payments.store"],
36
+ ["payment_customers", "payments.store"],
37
+ ["payment_catalogues", "payments.store"]
38
+ ]);
39
+ const scope = { ...merchant, subjectId: "tenant-a" };
40
+ assert.equal(await store.withAccount(scope, async ({ state, record }) => {
41
+ state.lots.push({ credits: 2 });
42
+ await record("grant:1", { credits: 2 });
43
+ return "granted";
44
+ }), "granted");
45
+ assert.deepEqual(await store.inspect(scope), { lots: [{ credits: 2 }], subscriptions: {} });
46
+ assert.deepEqual(await store.withAccount(scope, ({ find }) => find("grant:1")), { credits: 2 });
47
+ await store.bindCustomer(merchant, "cus_1", "tenant-a");
48
+ assert.equal(await store.resolveCustomer(merchant, "cus_1"), "tenant-a");
49
+ await store.withCatalogue(merchant, async (state) => {
50
+ state.revision = 1;
51
+ });
52
+ assert.deepEqual(await store.inspectCatalogue(merchant), { plans: {}, history: [], revision: 1 });
53
+ });
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import knex from "knex";
4
4
  import migration from "../migrations/payments_core_initial.cjs";
5
- import { createKnexPaymentStore } from "../src/server/knexStore.js";
5
+ import { createTestPaymentStore } from "./support/paymentStore.js";
6
6
  import { createPaymentService } from "../src/server/service.js";
7
7
  import { validatePaymentConfiguration } from "../src/shared/configuration.js";
8
8
 
@@ -64,7 +64,7 @@ test("configuration accepts reactive objects and returns an independent plain sn
64
64
  test("transactional payment state isolates subjects, grants once, prevents overspend and reconciles current facts", async (t) => {
65
65
  const db = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
66
66
  await migration.up(db);
67
- const store = createKnexPaymentStore({ knex: db });
67
+ const store = await createTestPaymentStore(db);
68
68
  let now = 1000;
69
69
  const service = createPaymentService({ store, configuration: validatePaymentConfiguration(document), clock: () => now });
70
70
  try {
@@ -6,9 +6,32 @@ import { join } from 'node:path';
6
6
  import knex from 'knex';
7
7
  import { validatePaymentConfiguration } from '@jskit-ai/payments-core/shared';
8
8
  import { createPaymentService } from '@jskit-ai/payments-core/server';
9
- import { createKnexPaymentStore } from '@jskit-ai/payments-core/server/storage';
9
+ import { PaymentStoreFeature } from '@jskit-ai/payments-core/server/storage';
10
+ import { ActionProvider } from '@jskit-ai/kernel/server/actions';
11
+ import { EventProvider } from '@jskit-ai/kernel/server/runtime';
12
+ import { silentLogger } from '@jskit-ai/kernel/server/runtime/logger';
13
+ import { createCapabilityRuntime, defineProvider } from '@jskit-ai/kernel/shared/capabilities';
14
+ import { ResourceRuntimeProvider } from '@jskit-ai/resource-runtime/server';
10
15
  import migration from '../migrations/payments_core_initial.cjs';
11
16
 
17
+ /** The payment store as a script or CLI starts it (docs/standalone.md): PaymentStoreFeature in a runtime of its own. */
18
+ async function startPaymentStore(knex) {
19
+ let store;
20
+ const runtime = createCapabilityRuntime({
21
+ inputs: { 'runtime.logger': silentLogger },
22
+ providers: [
23
+ EventProvider,
24
+ ActionProvider,
25
+ defineProvider({ id: 'cli.database', provides: { database: 'runtime.database' }, setup: () => ({ database: { knex } }) }),
26
+ ResourceRuntimeProvider,
27
+ PaymentStoreFeature,
28
+ defineProvider({ id: 'cli.payments', requires: { payments: 'payments.store' }, setup({ payments }) { store = payments; } })
29
+ ]
30
+ });
31
+ await runtime.start();
32
+ return { store, stop: () => runtime.shutdown() };
33
+ }
34
+
12
35
  // No editor, project tooling, network or generated application is involved.
13
36
  test('a hand-written app reloads payment policy from disk while retaining its own billing state', async () => {
14
37
  const directory = await mkdtemp(join(tmpdir(), 'standalone-payments-'));
@@ -23,16 +46,18 @@ test('a hand-written app reloads payment policy from disk while retaining its ow
23
46
  } }
24
47
  };
25
48
  const scope = { applicationId: 'hand-written-app', integrationId: 'billing', providerAccountId: 'acct_fixture', environment: 'sandbox', subjectId: 'workspace-a' };
49
+ let payments = null;
26
50
  const boot = async () => createPaymentService({
27
- store: createKnexPaymentStore({ knex: db }),
51
+ store: payments.store,
28
52
  configuration: validatePaymentConfiguration(JSON.parse(await readFile(path, 'utf8'))),
29
53
  clock: () => 1000
30
54
  });
31
55
  try {
32
56
  await migration.up(db);
57
+ payments = await startPaymentStore(db);
33
58
  await writeFile(path, JSON.stringify(document));
34
59
  const first = await boot();
35
- await createKnexPaymentStore({ knex: db }).bindCustomer(scope, 'cus_fixture', scope.subjectId);
60
+ await payments.store.bindCustomer(scope, 'cus_fixture', scope.subjectId);
36
61
  await first.reconcileEvent(scope, {
37
62
  eventId: 'evt_fixture', customerId: 'cus_fixture',
38
63
  load: async () => ({ subscription: { id: 'sub_fixture', status: 'active', planId: 'pro', periodEnd: 10000 }, renewal: { id: 'invoice_fixture', planId: 'pro', periodEnd: 10000 } })
@@ -55,6 +80,7 @@ test('a hand-written app reloads payment policy from disk while retaining its ow
55
80
  await assert.rejects(boot(), { code: 'payment_configuration_invalid' });
56
81
  assert.equal((await restarted.inspect(scope)).balance, 93);
57
82
  } finally {
83
+ await payments?.stop();
58
84
  await db.destroy();
59
85
  await rm(directory, { recursive: true, force: true });
60
86
  }
@@ -0,0 +1,36 @@
1
+ import { ActionProvider } from "@jskit-ai/kernel/server/actions";
2
+ import { EventProvider } from "@jskit-ai/kernel/server/runtime";
3
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
4
+ import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
5
+ import { ResourceRuntimeProvider } from "@jskit-ai/resource-runtime/server";
6
+ import { PaymentStoreFeature } from "../../src/server/storage.js";
7
+
8
+ /**
9
+ * The payment store on a test's own Knex, as an application gets it: PaymentStoreFeature owning the payment tables
10
+ * through the resource runtime and providing `payments.store`. The runtime holds no timers or connections of its own,
11
+ * and the Knex stays the test's.
12
+ * @param {import("knex").Knex} knex
13
+ * @returns {Promise<import("../../src/server/types.js").PaymentStore>}
14
+ */
15
+ export async function createTestPaymentStore(knex) {
16
+ let store = null;
17
+ const runtime = createCapabilityRuntime({
18
+ inputs: { "runtime.logger": silentLogger },
19
+ providers: [
20
+ EventProvider,
21
+ ActionProvider,
22
+ defineProvider({ id: "test.database", provides: { database: "runtime.database" }, setup: () => ({ database: { knex } }) }),
23
+ ResourceRuntimeProvider,
24
+ PaymentStoreFeature,
25
+ defineProvider({
26
+ id: "test.payments",
27
+ requires: { payments: "payments.store" },
28
+ setup({ payments }) {
29
+ store = payments;
30
+ }
31
+ })
32
+ ]
33
+ });
34
+ await runtime.start();
35
+ return store;
36
+ }
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { createInstalledRuntime } from "@jskit-ai/kernel/server/platform";
7
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
8
+
9
+ const TABLE_RUNTIME = ["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime"];
10
+ const readManifest = async (url) => JSON.parse(await readFile(url, "utf8"));
11
+
12
+ /**
13
+ * An app installed as npm installs it: each package's manifest under node_modules, by name, and nothing else, so no
14
+ * package of the monorepo is reached by accident.
15
+ * @param {import("node:test").TestContext} t
16
+ * @param {object[]} manifests
17
+ */
18
+ async function installedApp(t, manifests) {
19
+ const appRoot = await mkdtemp(path.join(tmpdir(), "payments-core-without-database-"));
20
+ t.after(() => rm(appRoot, { recursive: true, force: true }));
21
+ for (const manifest of manifests) {
22
+ const packageRoot = path.join(appRoot, "node_modules", ...manifest.name.split("/"));
23
+ await mkdir(packageRoot, { recursive: true });
24
+ await writeFile(path.join(packageRoot, "package.json"), JSON.stringify(manifest));
25
+ }
26
+ const [own] = manifests;
27
+ await writeFile(path.join(appRoot, "package.json"), JSON.stringify({
28
+ name: "app-without-database",
29
+ private: true,
30
+ dependencies: { [own.name]: own.version }
31
+ }));
32
+ return appRoot;
33
+ }
34
+
35
+ test("an app without a database installs @jskit-ai/payments-core without the table runtime, composes it and starts", async (t) => {
36
+ const manifest = await readManifest(new URL("../package.json", import.meta.url));
37
+ // npm installs a package's dependencies, never its optional peers: the table runtime comes with an app's database.
38
+ for (const name of TABLE_RUNTIME) {
39
+ assert.equal(manifest.dependencies?.[name], undefined, `${name} is not a dependency`);
40
+ assert.equal(manifest.peerDependenciesMeta?.[name]?.optional, true, `${name} is an optional peer`);
41
+ }
42
+
43
+ const started = await createInstalledRuntime({ appRoot: await installedApp(t, [manifest]), logger: silentLogger });
44
+ assert.ok(started.packageOrder.includes(manifest.name));
45
+ assert.equal(started.packageOrder.some((name) => TABLE_RUNTIME.includes(name)), false);
46
+
47
+ // Installed as a dependency, the table runtime would need a database driver the app does not have.
48
+ const databaseRuntime = await readManifest(new URL("../../database-runtime/package.json", import.meta.url));
49
+ await assert.rejects(
50
+ createInstalledRuntime({ appRoot: await installedApp(t, [manifest, databaseRuntime]), logger: silentLogger }),
51
+ /requires capability runtime\.database\.driver, but no installed package provides it/u
52
+ );
53
+ });
@@ -1,92 +0,0 @@
1
- import { PaymentError, fingerprint } from "./errors.js";
2
-
3
- const empty = () => ({ lots: [], subscriptions: {} });
4
-
5
- function merchant(scope) {
6
- const fields = ["applicationId", "integrationId", "providerAccountId", "environment"];
7
- for (const field of fields) {
8
- if (typeof scope?.[field] !== "string" || !scope[field] || scope[field].length > 200) throw new TypeError(`Payment scope requires ${field}.`);
9
- }
10
- if (!["sandbox", "live"].includes(scope.environment)) throw new TypeError("Payment scope requires sandbox or live.");
11
- return fields.map((field) => scope[field]);
12
- }
13
- function identifier(value) {
14
- if (typeof value !== "string" || !value || value.length > 200) throw new TypeError("Payment identity is required (maximum 200 characters).");
15
- return value;
16
- }
17
- const accountKey = (scope) => fingerprint([...merchant(scope), identifier(scope.subjectId)]);
18
-
19
- /** @param {{ knex: import("@jskit-ai/database-runtime/server/types").RuntimeKnex }} dependencies */
20
- function createKnexPaymentStore({ knex }) {
21
- // readable-check: allow method-probe - input validation: the application passes its own Knex instance
22
- if (typeof knex !== "function" || typeof knex.transaction !== "function") {
23
- throw new TypeError("createKnexPaymentStore requires the application's Knex instance.");
24
- }
25
-
26
- async function inspect(scope) {
27
- const row = await knex("payment_accounts").where({ account_key: accountKey(scope) }).first();
28
- return row ? JSON.parse(row.payload) : empty();
29
- }
30
- async function withAccount(scope, work) {
31
- const key = accountKey(scope);
32
- return knex.transaction(async (trx) => {
33
- // The retained account row serializes grants, usage, refunds and event handling.
34
- await trx("payment_accounts").insert({ account_key: key, payload: JSON.stringify(empty()) })
35
- .onConflict("account_key").merge({ account_key: key });
36
- const row = await trx("payment_accounts").where({ account_key: key }).forUpdate().first();
37
- const state = JSON.parse(row.payload);
38
- const result = await work({
39
- state,
40
- async find(reference) {
41
- const entry = await trx("payment_entries").where({ entry_key: fingerprint([key, identifier(reference)]) }).first();
42
- return entry ? JSON.parse(entry.payload) : null;
43
- },
44
- async record(reference, payload) {
45
- await trx("payment_entries").insert({ entry_key: fingerprint([key, identifier(reference)]), account_key: key, payload: JSON.stringify(payload) });
46
- }
47
- });
48
- await trx("payment_accounts").where({ account_key: key }).update({ payload: JSON.stringify(state) });
49
- return result;
50
- });
51
- }
52
- async function bindCustomer(scope, customerId, subjectId) {
53
- const key = fingerprint([...merchant(scope), identifier(customerId)]);
54
- identifier(subjectId);
55
- await knex.transaction(async (trx) => {
56
- await trx("payment_customers").insert({ customer_key: key, subject_id: subjectId })
57
- .onConflict("customer_key").merge({ customer_key: key });
58
- const row = await trx("payment_customers").where({ customer_key: key }).first();
59
- if (row.subject_id !== subjectId) throw new PaymentError("payment_customer_conflict", "This provider customer is already bound to a different billable subject.", 409);
60
- });
61
- }
62
- async function resolveCustomer(scope, customerId) {
63
- const row = await knex("payment_customers").where({ customer_key: fingerprint([...merchant(scope), identifier(customerId)]) }).first();
64
- return row?.subject_id ?? null;
65
- }
66
- async function inspectCatalogue(scope) {
67
- const row = await knex("payment_catalogues").where({ catalogue_key: fingerprint(merchant(scope)) }).first();
68
- return row ? JSON.parse(row.payload) : { plans: {}, history: [] };
69
- }
70
- async function withCatalogue(scope, work) {
71
- const key = fingerprint(merchant(scope));
72
- return knex.transaction(async (trx) => {
73
- await trx("payment_catalogues").insert({ catalogue_key: key, payload: JSON.stringify({ plans: {}, history: [] }) })
74
- .onConflict("catalogue_key").merge({ catalogue_key: key });
75
- const row = await trx("payment_catalogues").where({ catalogue_key: key }).forUpdate().first();
76
- const state = JSON.parse(row.payload);
77
- const result = await work(state);
78
- await trx("payment_catalogues").where({ catalogue_key: key }).update({ payload: JSON.stringify(state) });
79
- return result;
80
- });
81
- }
82
- return Object.freeze({
83
- inspect,
84
- withAccount,
85
- bindCustomer,
86
- resolveCustomer,
87
- inspectCatalogue,
88
- withCatalogue
89
- });
90
- }
91
-
92
- export { createKnexPaymentStore };