@jskit-ai/payments-core 0.1.62 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,128 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 - 2026-10-29
4
+
5
+ - 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".
6
+ - `PaymentError` takes `{ messageCode, messageParams }` as its fourth argument; existing `payment_*` codes keep their values.
7
+
8
+ ### New message keys (53)
9
+
10
+ All keys in `locales/en.json` (exported as `./locales/en.json`) are new in this version.
11
+
12
+ <details><summary>Key list</summary>
13
+
14
+ - `jskit.errors.payment_plan_unknown`
15
+ - `jskit.errors.payment_readiness_invalid`
16
+ - `jskit.errors.payment_forbidden`
17
+ - `jskit.errors.payment_operation_conflict`
18
+ - `jskit.errors.payment_request_invalid`
19
+ - `jskit.errors.payment_subscription_exists`
20
+ - `jskit.errors.payment_history_unavailable`
21
+ - `jskit.errors.payment_customer_missing`
22
+ - `jskit.errors.payment_reconciliation_required`
23
+ - `jskit.errors.payment_customer_conflict`
24
+ - `jskit.errors.payment_catalogue_pending`
25
+ - `jskit.errors.payment_catalogue_uncertain`
26
+ - `jskit.errors.payment_balance_limit`
27
+ - `jskit.errors.payment_feature_required`
28
+ - `jskit.errors.payment_insufficient_credits`
29
+ - `jskit.errors.payment_debit_missing`
30
+ - `jskit.errors.payment_customer_unbound`
31
+ - `jskit.payments.scope.readinessMismatch`
32
+ - `jskit.payments.scope.catalogueMismatch`
33
+ - `jskit.payments.scope.merchantMismatch`
34
+ - `jskit.payments.operation.unfinished`
35
+ - `jskit.payments.operation.mayHaveSucceeded`
36
+ - `jskit.payments.input.billingEmail`
37
+ - `jskit.payments.input.historyPage`
38
+ - `jskit.payments.input.historyCollection`
39
+ - `jskit.payments.input.recoveryObjectId`
40
+ - `jskit.payments.input.reference`
41
+ - `jskit.payments.input.creditQuantity`
42
+ - `jskit.payments.input.timestamp`
43
+ - `jskit.payments.reference.otherPlan`
44
+ - `jskit.payments.reference.differentInputs`
45
+ - `jskit.payments.provider.checkAccount`
46
+ - `jskit.payments.provider.unsupportedEvent`
47
+ - `jskit.payments.provider.objectIdMissing`
48
+ - `jskit.payments.provider.subscriptionBinding`
49
+ - `jskit.payments.provider.renewalBinding`
50
+ - `jskit.payments.webhook.rawBodyRequired`
51
+ - `jskit.payments.webhook.verificationFailed`
52
+ - `jskit.payments.configuration.invalid`
53
+ - `jskit.payments.configuration.taxCategoryUnpublishable`
54
+ - `jskit.payments.configuration.sharedConnectionRequired`
55
+ - `jskit.payments.configuration.paddleEnvironment`
56
+ - `jskit.payments.configuration.paddleTaxCategory`
57
+ - `jskit.payments.configuration.paddleClientToken`
58
+ - `jskit.payments.catalogue.reviewFirst`
59
+ - `jskit.payments.catalogue.publicationChanged`
60
+ - `jskit.payments.catalogue.planChanged`
61
+ - `jskit.payments.catalogue.productChanged`
62
+ - `jskit.payments.catalogue.priceChanged`
63
+ - `jskit.payments.catalogue.pendingCatalogueRequestChanged`
64
+ - `jskit.payments.catalogue.recoveryReviewChanged`
65
+ - `jskit.payments.catalogue.pendingRequestChanged`
66
+ - `jskit.payments.catalogue.objectMismatch`
67
+
68
+ </details>
69
+
70
+ ### Codes (53)
71
+
72
+ Error, field-error and message codes this package sends, with their English. Undotted codes kept their existing values and render from `jskit.errors.<code>`.
73
+
74
+ | Code | English |
75
+ | --- | --- |
76
+ | `payment_plan_unknown` | Select a published payment plan. |
77
+ | `payment_readiness_invalid` | App readiness checks must return bounded status and safe explanatory text. |
78
+ | `payment_forbidden` | You cannot manage this billable account. |
79
+ | `payment_operation_conflict` | The pending payment operation changed. |
80
+ | `payment_request_invalid` | Supply a stable checkout request identifier. |
81
+ | `payment_subscription_exists` | Manage the existing subscription through billing instead of starting another checkout. |
82
+ | `payment_history_unavailable` | Billing history could not be loaded. Check provider access and retry. |
83
+ | `payment_customer_missing` | Create a billing account first. |
84
+ | `payment_reconciliation_required` | Provider inspection did not resolve this request. |
85
+ | `payment_customer_conflict` | This provider customer is already bound to a different billable subject. |
86
+ | `payment_catalogue_pending` | Another catalogue request needs resolution. |
87
+ | `payment_catalogue_uncertain` | Catalogue publication stopped. Inspect the pending provider request before retrying; completed items are preserved. |
88
+ | `payment_balance_limit` | Credit balance exceeds the supported integer range. |
89
+ | `payment_feature_required` | Your subscription does not include this feature. |
90
+ | `payment_insufficient_credits` | Not enough credits for this action. |
91
+ | `payment_debit_missing` | No debit exists for this reference. |
92
+ | `payment_customer_unbound` | Bind this provider customer before processing its events. |
93
+ | `payments.scope.readinessMismatch` | Readiness returned a different payment account or environment. |
94
+ | `payments.scope.catalogueMismatch` | Catalogue scope does not match its configured environment. |
95
+ | `payments.scope.merchantMismatch` | The payment scope does not match this environment's merchant configuration. |
96
+ | `payments.operation.unfinished` | Inspect the unfinished provider request before retrying. |
97
+ | `payments.operation.mayHaveSucceeded` | Inspect this account's provider activity before retrying; the request may have succeeded. |
98
+ | `payments.input.billingEmail` | Supply the authenticated billing contact's email. |
99
+ | `payments.input.historyPage` | Select billing subscriptions or transactions and a valid page cursor. |
100
+ | `payments.input.historyCollection` | Select a billing history collection. |
101
+ | `payments.input.recoveryObjectId` | Supply the exact provider object ID to recover. |
102
+ | `payments.input.reference` | Use a nonempty business reference of at most 150 characters. |
103
+ | `payments.input.creditQuantity` | Use a positive integer credit quantity, at most one billion. |
104
+ | `payments.input.timestamp` | Use an integer UTC timestamp in milliseconds. |
105
+ | `payments.reference.otherPlan` | The checkout request already selected another plan. |
106
+ | `payments.reference.differentInputs` | This business reference was already used with different inputs. |
107
+ | `payments.provider.checkAccount` | Check the {provider} account, price mapping and subscription shape. |
108
+ | `payments.provider.unsupportedEvent` | The event is not a supported subscription payment for this customer. |
109
+ | `payments.provider.objectIdMissing` | The provider did not return an object ID. |
110
+ | `payments.provider.subscriptionBinding` | Check the provider subscription and logical price binding. |
111
+ | `payments.provider.renewalBinding` | Check the renewal's logical price binding. |
112
+ | `payments.webhook.rawBodyRequired` | Supply the unmodified webhook request bytes. |
113
+ | `payments.webhook.verificationFailed` | {provider} webhook verification failed. |
114
+ | `payments.configuration.invalid` | Check the application's payment configuration. |
115
+ | `payments.configuration.taxCategoryUnpublishable` | Set the application's Paddle product tax category before publishing. |
116
+ | `payments.configuration.sharedConnectionRequired` | Select a shared Stripe or Paddle connection with a backend API-key Env reference for {environment}. |
117
+ | `payments.configuration.paddleEnvironment` | Select a Paddle connection configured for {environment}. |
118
+ | `payments.configuration.paddleTaxCategory` | Choose the Paddle tax category for the products this app sells. |
119
+ | `payments.configuration.paddleClientToken` | Set the Env reference for this environment's Paddle public client token. |
120
+ | `payments.catalogue.reviewFirst` | Review the current catalogue and resolve provider drift or unfinished requests first. |
121
+ | `payments.catalogue.publicationChanged` | Another publication changed the catalogue; review again. |
122
+ | `payments.catalogue.planChanged` | This plan changed; review again. |
123
+ | `payments.catalogue.productChanged` | This product changed; review again. |
124
+ | `payments.catalogue.priceChanged` | This price changed; review again. |
125
+ | `payments.catalogue.pendingCatalogueRequestChanged` | The pending catalogue request changed. |
126
+ | `payments.catalogue.recoveryReviewChanged` | The recovery review changed. Inspect the current pending operation first. |
127
+ | `payments.catalogue.pendingRequestChanged` | The pending request changed. |
128
+ | `payments.catalogue.objectMismatch` | The provider object does not match the pending operation. |
@@ -2,7 +2,7 @@
2
2
 
3
3
  `contracts/conformance.json` is ordinary JSON, also exported as
4
4
  `@jskit-ai/payments-core/conformance.json`. Read the file as data; no Node process,
5
- JSKIT import, editor service or Genesis command is necessary to interpret it.
5
+ JSKIT import, editor service or project tool is necessary to interpret it.
6
6
  It accompanies the draft-07 configuration schema and the semantic contract.
7
7
  These are test examples, not a production workflow interpreter.
8
8
 
@@ -72,9 +72,9 @@ runtime. The application selects its billable model and schema; the fixture does
72
72
  not prescribe table names or copy JSKIT's storage internals into another framework.
73
73
 
74
74
  A Node CLI consumer can run the same semantic calls against its installed JSKIT
75
- services. Genesis may describe the project's test command, but these fixtures do
76
- not require Genesis to execute it. Vibe64 configuration must preserve these same
77
- values rather than inventing a different hosted payment format.
75
+ services. Project tooling may describe the project's test command, but these
76
+ fixtures do not require it to execute. A hosted editor's configuration must
77
+ preserve these same values rather than inventing a different hosted payment format.
78
78
 
79
79
  ## Evidence boundaries
80
80
 
package/docs/contract.md CHANGED
@@ -124,3 +124,11 @@ Configuration failures use `payment_configuration_invalid` (422). Field errors
124
124
  identify `extensions.payments/...` paths, including cross-reference failures at
125
125
  `environments/<environment>/integrationId` and missing Paddle token/tax-category
126
126
  fields. Messages explain the repair without echoing credential values.
127
+
128
+ Every error keeps its English `message` and its stable `payment_*` `code`. The
129
+ English of a code used for one sentence lives under `jskit.errors.<code>` in
130
+ `locales/en.json`. Where one code covers several sentences, the error also
131
+ carries `messageCode` and `messageParams` naming a `jskit.payments.*` message,
132
+ and binding field errors carry `code` and `params`, so a JSKIT application can
133
+ show them in the user's language. Other implementations may ignore these
134
+ fields.
@@ -249,7 +249,7 @@ An optional app-owned `inspectApplication` callback may supply `webhook`,
249
249
  `passed`, `failed`, `unknown` or `manual` and a safe explanation of at most 500
250
250
  characters. Record only observed checks. Never turn an unavailable check into
251
251
  success or claim provider approval from a fixture. This callback is a normal app
252
- function, not a Vibe64 API. Authorize any route/CLI wrapper exposing the service.
252
+ function, not a hosted API. Authorize any route/CLI wrapper exposing the service.
253
253
 
254
254
  Catalogue recovery requires a fresh `catalogue.preview()` and its `reviewId`:
255
255
  `catalogue.recover({reviewId, providerId})`. The adapter reads the candidate object;
@@ -0,0 +1,77 @@
1
+ {
2
+ "jskit": {
3
+ "errors": {
4
+ "payment_plan_unknown": "Select a published payment plan.",
5
+ "payment_readiness_invalid": "App readiness checks must return bounded status and safe explanatory text.",
6
+ "payment_forbidden": "You cannot manage this billable account.",
7
+ "payment_operation_conflict": "The pending payment operation changed.",
8
+ "payment_request_invalid": "Supply a stable checkout request identifier.",
9
+ "payment_subscription_exists": "Manage the existing subscription through billing instead of starting another checkout.",
10
+ "payment_history_unavailable": "Billing history could not be loaded. Check provider access and retry.",
11
+ "payment_customer_missing": "Create a billing account first.",
12
+ "payment_reconciliation_required": "Provider inspection did not resolve this request.",
13
+ "payment_customer_conflict": "This provider customer is already bound to a different billable subject.",
14
+ "payment_catalogue_pending": "Another catalogue request needs resolution.",
15
+ "payment_catalogue_uncertain": "Catalogue publication stopped. Inspect the pending provider request before retrying; completed items are preserved.",
16
+ "payment_balance_limit": "Credit balance exceeds the supported integer range.",
17
+ "payment_feature_required": "Your subscription does not include this feature.",
18
+ "payment_insufficient_credits": "Not enough credits for this action.",
19
+ "payment_debit_missing": "No debit exists for this reference.",
20
+ "payment_customer_unbound": "Bind this provider customer before processing its events."
21
+ },
22
+ "payments": {
23
+ "scope": {
24
+ "readinessMismatch": "Readiness returned a different payment account or environment.",
25
+ "catalogueMismatch": "Catalogue scope does not match its configured environment.",
26
+ "merchantMismatch": "The payment scope does not match this environment's merchant configuration."
27
+ },
28
+ "operation": {
29
+ "unfinished": "Inspect the unfinished provider request before retrying.",
30
+ "mayHaveSucceeded": "Inspect this account's provider activity before retrying; the request may have succeeded."
31
+ },
32
+ "input": {
33
+ "billingEmail": "Supply the authenticated billing contact's email.",
34
+ "historyPage": "Select billing subscriptions or transactions and a valid page cursor.",
35
+ "historyCollection": "Select a billing history collection.",
36
+ "recoveryObjectId": "Supply the exact provider object ID to recover.",
37
+ "reference": "Use a nonempty business reference of at most 150 characters.",
38
+ "creditQuantity": "Use a positive integer credit quantity, at most one billion.",
39
+ "timestamp": "Use an integer UTC timestamp in milliseconds."
40
+ },
41
+ "reference": {
42
+ "otherPlan": "The checkout request already selected another plan.",
43
+ "differentInputs": "This business reference was already used with different inputs."
44
+ },
45
+ "provider": {
46
+ "checkAccount": "Check the {provider} account, price mapping and subscription shape.",
47
+ "unsupportedEvent": "The event is not a supported subscription payment for this customer.",
48
+ "objectIdMissing": "The provider did not return an object ID.",
49
+ "subscriptionBinding": "Check the provider subscription and logical price binding.",
50
+ "renewalBinding": "Check the renewal's logical price binding."
51
+ },
52
+ "webhook": {
53
+ "rawBodyRequired": "Supply the unmodified webhook request bytes.",
54
+ "verificationFailed": "{provider} webhook verification failed."
55
+ },
56
+ "configuration": {
57
+ "invalid": "Check the application's payment configuration.",
58
+ "taxCategoryUnpublishable": "Set the application's Paddle product tax category before publishing.",
59
+ "sharedConnectionRequired": "Select a shared Stripe or Paddle connection with a backend API-key Env reference for {environment}.",
60
+ "paddleEnvironment": "Select a Paddle connection configured for {environment}.",
61
+ "paddleTaxCategory": "Choose the Paddle tax category for the products this app sells.",
62
+ "paddleClientToken": "Set the Env reference for this environment's Paddle public client token."
63
+ },
64
+ "catalogue": {
65
+ "reviewFirst": "Review the current catalogue and resolve provider drift or unfinished requests first.",
66
+ "publicationChanged": "Another publication changed the catalogue; review again.",
67
+ "planChanged": "This plan changed; review again.",
68
+ "productChanged": "This product changed; review again.",
69
+ "priceChanged": "This price changed; review again.",
70
+ "pendingCatalogueRequestChanged": "The pending catalogue request changed.",
71
+ "recoveryReviewChanged": "The recovery review changed. Inspect the current pending operation first.",
72
+ "pendingRequestChanged": "The pending request changed.",
73
+ "objectMismatch": "The provider object does not match the pending operation."
74
+ }
75
+ }
76
+ }
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/payments-core",
3
- "version": "0.1.62",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Application-owned payment configuration, subscription access and transactional usage credits.",
6
6
  "exports": {
@@ -15,7 +15,9 @@
15
15
  "./server/stripe": "./src/server/stripe.js",
16
16
  "./server/paddle": "./src/server/paddle.js",
17
17
  "./server/catalogue": "./src/server/catalogue.js",
18
- "./server/readiness": "./src/server/readiness.js"
18
+ "./server/readiness": "./src/server/readiness.js",
19
+ "./server/types": "./src/server/types.js",
20
+ "./locales/en.json": "./locales/en.json"
19
21
  },
20
22
  "scripts": {
21
23
  "test": "node --test --test-concurrency=1"
@@ -4,14 +4,23 @@ import { PaymentError, fingerprint } from "./errors.js";
4
4
  const priceFields = (plan) => ({ amount: plan.amount, currency: plan.currency, interval: plan.interval });
5
5
  const samePrice = (a, b) => fingerprint(priceFields(a)) === fingerprint(priceFields(b));
6
6
 
7
- // Trusted application service. Hosts must authorize catalogue management before
8
- // invoking it. No provider mutation happens while merely reading configuration.
7
+ /**
8
+ * Trusted application service. Hosts must authorize catalogue management before
9
+ * invoking it. No provider mutation happens while merely reading configuration.
10
+ * @param {object} dependencies
11
+ * @param {import("./types.js").PaymentStore} dependencies.store
12
+ * @param {import("./types.js").PaymentProviderAdapter} dependencies.adapter
13
+ * @param {import("./types.js").PaymentMerchantScope} dependencies.scope The merchant scope the catalogue belongs to.
14
+ * @param {import("./types.js").PaymentConfiguration} dependencies.configuration
15
+ */
9
16
  function createPaymentCatalogue({ store, adapter, scope, configuration }) {
10
17
  const plans = structuredClone(configuration.plans);
11
18
  const merchant = structuredClone(scope);
12
19
  const environment = configuration.environments[merchant.environment];
13
20
  if (!environment || environment.integrationId !== merchant.integrationId || environment.providerAccountId !== merchant.providerAccountId) {
14
- throw new PaymentError("payment_scope_invalid", "Catalogue scope does not match its configured environment.", 403);
21
+ throw new PaymentError("payment_scope_invalid", "Catalogue scope does not match its configured environment.", 403, {
22
+ messageCode: "payments.scope.catalogueMismatch"
23
+ });
15
24
  }
16
25
  async function preview() {
17
26
  const state = await store.inspectCatalogue(merchant);
@@ -38,19 +47,19 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
38
47
  }
39
48
  async function publish({ reviewId }) {
40
49
  const review = await preview();
41
- if (review.reviewId !== reviewId || review.drift.length || review.pending) throw new PaymentError("payment_catalogue_review_required", "Review the current catalogue and resolve provider drift or unfinished requests first.", 409);
50
+ if (review.reviewId !== reviewId || review.drift.length || review.pending) throw new PaymentError("payment_catalogue_review_required", "Review the current catalogue and resolve provider drift or unfinished requests first.", 409, { messageCode: "payments.catalogue.reviewFirst" });
42
51
  const completed = [];
43
52
  let expectedRevision = review.revision;
44
53
  for (const change of review.changes) {
45
54
  const token = randomUUID();
46
55
  const pending = await store.withCatalogue(merchant, async (state) => {
47
56
  if (state.pending) throw new PaymentError("payment_catalogue_pending", "Another catalogue request needs resolution.", 409);
48
- if ((state.revision || 0) !== expectedRevision) throw new PaymentError("payment_catalogue_review_required", "Another publication changed the catalogue; review again.", 409);
57
+ if ((state.revision || 0) !== expectedRevision) throw new PaymentError("payment_catalogue_review_required", "Another publication changed the catalogue; review again.", 409, { messageCode: "payments.catalogue.publicationChanged" });
49
58
  const current = state.plans[change.planId];
50
59
  // Reject another publisher changing this plan after the reviewed read.
51
- if (change.action === "create-product" && current) throw new PaymentError("payment_catalogue_review_required", "This plan changed; review again.", 409);
52
- if (change.action === "rename-product" && current?.productId !== change.productId) throw new PaymentError("payment_catalogue_review_required", "This product changed; review again.", 409);
53
- if (change.action === "create-price" && (!current?.productId || (current.priceId && samePrice(current, change)))) throw new PaymentError("payment_catalogue_review_required", "This price changed; review again.", 409);
60
+ if (change.action === "create-product" && current) throw new PaymentError("payment_catalogue_review_required", "This plan changed; review again.", 409, { messageCode: "payments.catalogue.planChanged" });
61
+ if (change.action === "rename-product" && current?.productId !== change.productId) throw new PaymentError("payment_catalogue_review_required", "This product changed; review again.", 409, { messageCode: "payments.catalogue.productChanged" });
62
+ if (change.action === "create-price" && (!current?.productId || (current.priceId && samePrice(current, change)))) throw new PaymentError("payment_catalogue_review_required", "This price changed; review again.", 409, { messageCode: "payments.catalogue.priceChanged" });
54
63
  const operation = { ...change, token, productId: current?.productId };
55
64
  state.pending = operation;
56
65
  return operation;
@@ -70,9 +79,9 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
70
79
  return { completed, removed: review.removed };
71
80
  }
72
81
  async function finish(pending, result) {
73
- if (typeof result?.id !== "string" || !result.id) throw new PaymentError("payment_provider_result_invalid", "The provider did not return an object ID.", 502);
82
+ if (typeof result?.id !== "string" || !result.id) throw new PaymentError("payment_provider_result_invalid", "The provider did not return an object ID.", 502, { messageCode: "payments.provider.objectIdMissing" });
74
83
  await store.withCatalogue(merchant, async (state) => {
75
- if (state.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending catalogue request changed.", 409);
84
+ if (state.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending catalogue request changed.", 409, { messageCode: "payments.catalogue.pendingCatalogueRequestChanged" });
76
85
  const current = state.plans[pending.planId];
77
86
  if (pending.action === "create-product") state.plans[pending.planId] = { productId: result.id, name: pending.name };
78
87
  else if (pending.action === "rename-product") current.name = pending.name;
@@ -86,31 +95,35 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
86
95
  }
87
96
  async function recover({ reviewId, providerId, confirmedNotCreated = false }) {
88
97
  const review = await preview();
89
- if (review.reviewId !== reviewId) throw new PaymentError("payment_catalogue_review_required", "The recovery review changed. Inspect the current pending operation first.", 409);
98
+ if (review.reviewId !== reviewId) throw new PaymentError("payment_catalogue_review_required", "The recovery review changed. Inspect the current pending operation first.", 409, { messageCode: "payments.catalogue.recoveryReviewChanged" });
90
99
  const pending = review.pending;
91
100
  if (!pending) return { pending: false };
92
101
  if (confirmedNotCreated) {
93
102
  // Trusted app administrator must supply actual provider inspection evidence.
94
103
  // This parameter must not be exposed as an unauthenticated browser command.
95
104
  await store.withCatalogue(merchant, async (current) => {
96
- if (current.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending request changed.", 409);
105
+ if (current.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending request changed.", 409, { messageCode: "payments.catalogue.pendingRequestChanged" });
97
106
  delete current.pending;
98
107
  });
99
108
  return { pending: false, retryAllowed: true };
100
109
  }
101
110
  if (typeof providerId !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(providerId)) {
102
- throw new PaymentError("payment_input_invalid", "Supply the exact provider object ID to recover.");
111
+ throw new PaymentError("payment_input_invalid", "Supply the exact provider object ID to recover.", 422, { messageCode: "payments.input.recoveryObjectId" });
103
112
  }
104
113
  const result = pending.action === "create-price" ? await adapter.readPrice(providerId) : await adapter.readProduct(providerId);
105
114
  if (!result || result.id !== providerId || (pending.action === "create-price" && (result.active !== true || result.productId !== pending.productId || !samePrice(result, pending))) ||
106
115
  (pending.action !== "create-price" && result.name !== pending.name) ||
107
116
  (pending.action === "rename-product" && providerId !== pending.productId)) {
108
- throw new PaymentError("payment_catalogue_review_required", "The provider object does not match the pending operation.", 409);
117
+ throw new PaymentError("payment_catalogue_review_required", "The provider object does not match the pending operation.", 409, { messageCode: "payments.catalogue.objectMismatch" });
109
118
  }
110
119
  await finish(pending, { id: providerId });
111
120
  return { pending: false };
112
121
  }
113
- return Object.freeze({ preview, publish, recover });
122
+ return Object.freeze({
123
+ preview,
124
+ publish,
125
+ recover
126
+ });
114
127
  }
115
128
 
116
129
  export { createPaymentCatalogue };
@@ -1,9 +1,19 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { PaymentError, fingerprint } from "./errors.js";
3
3
 
4
- // Application composition boundary: authorization is mandatory and supplied by
5
- // the app's framework. Actor and subject must come from its authenticated route.
4
+ /**
5
+ * Application composition boundary: authorization is mandatory and supplied by
6
+ * the app's framework. Actor and subject must come from its authenticated route.
7
+ * @param {object} dependencies
8
+ * @param {import("./types.js").PaymentProviderAdapter} dependencies.adapter
9
+ * @param {import("./types.js").PaymentStore} dependencies.store
10
+ * @param {import("./types.js").PaymentService} dependencies.payments
11
+ * @param {import("./types.js").PaymentMerchantScope} dependencies.merchantScope
12
+ * @param {string} dependencies.returnUrl
13
+ * @param {(actor: unknown, request: { subjectId: string, action: string }) => boolean | Promise<boolean>} dependencies.authorize
14
+ */
6
15
  function createPaymentCheckoutService({ adapter, store, payments, merchantScope, returnUrl, authorize }) {
16
+ // readable-check: allow method-probe - input validation: the application passes its billable-subject authorization
7
17
  if (typeof authorize !== "function") throw new TypeError("Supply the application's billable-subject authorization function.");
8
18
  const target = new URL(returnUrl);
9
19
  if ((target.protocol !== "https:" && !(target.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname))) || target.username || target.password || target.hash) {
@@ -19,7 +29,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
19
29
  async function perform(scope, operation, remote, complete) {
20
30
  const token = randomUUID();
21
31
  const prepared = await store.withAccount(scope, async (tx) => {
22
- if (tx.state.pendingPaymentOperation) throw new PaymentError("payment_operation_uncertain", "Inspect the unfinished provider request before retrying.", 409);
32
+ if (tx.state.pendingPaymentOperation) throw new PaymentError("payment_operation_uncertain", "Inspect the unfinished provider request before retrying.", 409, { messageCode: "payments.operation.unfinished" });
23
33
  const ready = await operation(tx);
24
34
  if (ready.cached) return ready;
25
35
  tx.state.pendingPaymentOperation = { token, ...ready };
@@ -30,7 +40,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
30
40
  // unresolved operation, rather than permitting a duplicate provider write.
31
41
  let result;
32
42
  try { result = await remote(prepared); }
33
- catch { throw new PaymentError("payment_operation_uncertain", "Inspect this account's provider activity before retrying; the request may have succeeded.", 409); }
43
+ catch { throw new PaymentError("payment_operation_uncertain", "Inspect this account's provider activity before retrying; the request may have succeeded.", 409, { messageCode: "payments.operation.mayHaveSucceeded" }); }
34
44
  return store.withAccount(scope, async (tx) => {
35
45
  if (tx.state.pendingPaymentOperation?.token !== token) throw new PaymentError("payment_operation_conflict", "The pending payment operation changed.", 409);
36
46
  const value = await complete(tx, result, prepared);
@@ -42,7 +52,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
42
52
  const scope = await allowed(actor, subjectId, "checkout");
43
53
  adapter.validatePlan(planId);
44
54
  if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{1,100}$/.test(requestId)) throw new PaymentError("payment_request_invalid", "Supply a stable checkout request identifier.");
45
- if (typeof email !== "string" || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new PaymentError("payment_input_invalid", "Supply the authenticated billing contact's email.");
55
+ if (typeof email !== "string" || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new PaymentError("payment_input_invalid", "Supply the authenticated billing contact's email.", 422, { messageCode: "payments.input.billingEmail" });
46
56
  const identity = fingerprint(scope);
47
57
  const customerId = await perform(scope, async (tx) => tx.state.customerId
48
58
  ? { cached: true, result: tx.state.customerId }
@@ -53,7 +63,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
53
63
  return perform(scope, async (tx) => {
54
64
  const saved = await tx.find(`checkout:${requestId}`);
55
65
  if (saved) {
56
- if (saved.planId !== planId) throw new PaymentError("payment_reference_conflict", "The checkout request already selected another plan.", 409);
66
+ if (saved.planId !== planId) throw new PaymentError("payment_reference_conflict", "The checkout request already selected another plan.", 409, { messageCode: "payments.reference.otherPlan" });
57
67
  return { cached: true, result: saved.result };
58
68
  }
59
69
  if (Object.values(tx.state.subscriptions).some((subscription) =>
@@ -75,7 +85,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
75
85
  const scope = await allowed(actor, subjectId, "history");
76
86
  if (!["subscriptions", "transactions"].includes(collection) ||
77
87
  (after !== null && (typeof after !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(after)))) {
78
- throw new PaymentError("payment_input_invalid", "Select billing subscriptions or transactions and a valid page cursor.");
88
+ throw new PaymentError("payment_input_invalid", "Select billing subscriptions or transactions and a valid page cursor.", 422, { messageCode: "payments.input.historyPage" });
79
89
  }
80
90
  // Never accept a customer identifier from the browser or editor caller.
81
91
  const { customerId } = await store.inspect(scope);
@@ -94,6 +104,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
94
104
  // provider dashboard/API and must return evidence, never browser-supplied data.
95
105
  async function reconcilePending({ actor, subjectId, inspectProvider }) {
96
106
  const scope = await allowed(actor, subjectId, "reconcile");
107
+ // readable-check: allow method-probe - input validation: the caller passes its server-side provider inspection
97
108
  if (typeof inspectProvider !== "function") throw new TypeError("Supply the server's provider inspection function.");
98
109
  return store.withAccount(scope, async (tx) => {
99
110
  const pending = tx.state.pendingPaymentOperation;
@@ -121,11 +132,18 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
121
132
  const { eventId, customerId } = identity;
122
133
  return payments.reconcileEvent(merchant, { eventId, customerId, load: async () => {
123
134
  const facts = await adapter.loadEvent(event);
124
- if (!facts || facts.customerId !== customerId) throw new PaymentError("payment_provider_result_invalid", "The event is not a supported subscription payment for this customer.", 422);
135
+ if (!facts || facts.customerId !== customerId) throw new PaymentError("payment_provider_result_invalid", "The event is not a supported subscription payment for this customer.", 422, { messageCode: "payments.provider.unsupportedEvent" });
125
136
  return facts;
126
137
  } });
127
138
  }
128
- return Object.freeze({ account, history, checkout, portal, webhook, reconcilePending });
139
+ return Object.freeze({
140
+ account,
141
+ history,
142
+ checkout,
143
+ portal,
144
+ webhook,
145
+ reconcilePending
146
+ });
129
147
  }
130
148
 
131
149
  export { createPaymentCheckoutService };
@@ -1,11 +1,17 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
+ // code is the stable payment_* code callers branch on; a code shared by several
4
+ // sentences adds a messageCode (and messageParams) naming this sentence.
3
5
  class PaymentError extends Error {
4
- constructor(code, message, statusCode = 422) {
6
+ constructor(code, message, statusCode = 422, { messageCode = "", messageParams = {} } = {}) {
5
7
  super(message);
6
8
  this.name = "PaymentError";
7
9
  this.code = code;
8
10
  this.statusCode = statusCode;
11
+ if (messageCode) {
12
+ this.messageCode = messageCode;
13
+ this.messageParams = { ...messageParams };
14
+ }
9
15
  }
10
16
  }
11
17
 
@@ -16,8 +16,12 @@ function identifier(value) {
16
16
  }
17
17
  const accountKey = (scope) => fingerprint([...merchant(scope), identifier(scope.subjectId)]);
18
18
 
19
+ /** @param {{ knex: import("@jskit-ai/database-runtime/server/types").RuntimeKnex }} dependencies */
19
20
  function createKnexPaymentStore({ knex }) {
20
- if (typeof knex !== "function" || typeof knex.transaction !== "function") throw new TypeError("Supply the application's transactional Knex client.");
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
+ }
21
25
 
22
26
  async function inspect(scope) {
23
27
  const row = await knex("payment_accounts").where({ account_key: accountKey(scope) }).first();
@@ -75,7 +79,14 @@ function createKnexPaymentStore({ knex }) {
75
79
  return result;
76
80
  });
77
81
  }
78
- return Object.freeze({ inspect, withAccount, bindCustomer, resolveCustomer, inspectCatalogue, withCatalogue });
82
+ return Object.freeze({
83
+ inspect,
84
+ withAccount,
85
+ bindCustomer,
86
+ resolveCustomer,
87
+ inspectCatalogue,
88
+ withCatalogue
89
+ });
79
90
  }
80
91
 
81
92
  export { createKnexPaymentStore };
@@ -2,8 +2,23 @@ import { Paddle, Environment } from "@paddle/paddle-node-sdk";
2
2
  import { PaymentError } from "./errors.js";
3
3
  import { createPriceBindings } from "./priceBindings.js";
4
4
 
5
- const invalid = () => { throw new PaymentError("payment_provider_result_invalid", "Check the Paddle account, price mapping and subscription shape.", 502); };
5
+ const invalid = () => {
6
+ throw new PaymentError("payment_provider_result_invalid", "Check the Paddle account, price mapping and subscription shape.", 502, {
7
+ messageCode: "payments.provider.checkAccount", messageParams: { provider: "Paddle" }
8
+ });
9
+ };
6
10
 
11
+ /**
12
+ * @param {object} options
13
+ * @param {string} [options.apiKey]
14
+ * @param {string} options.webhookSecret
15
+ * @param {"sandbox" | "live"} options.environment
16
+ * @param {Record<string, string>} options.priceBindings Each plan's current price id.
17
+ * @param {Record<string, string>} [options.historicalPriceBindings] The plan of each retired price id.
18
+ * @param {import("@paddle/paddle-node-sdk").TaxCategory} [options.taxCategory] The tax category of the products it publishes.
19
+ * @param {import("@paddle/paddle-node-sdk").Paddle} [options.client]
20
+ */
21
+ // readable-check: allow dependency-default - data: historicalPriceBindings maps retired price ids to plans, not a collaborator
7
22
  function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceBindings, taxCategory, historicalPriceBindings = {}, client = new Paddle(apiKey, { environment: environment === "live" ? Environment.production : Environment.sandbox }) }) {
8
23
  if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret) throw new TypeError("Supply Paddle credentials and environment.");
9
24
  const { validatePlan, priceFor, planFor } = createPriceBindings({ priceBindings, historicalPriceBindings, invalid });
@@ -14,9 +29,11 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
14
29
  return { environment, credentialsVerified: true, accountIdentityVerified: false, merchantApproval: "manual-check-required" };
15
30
  }
16
31
  async function verifyEvent(rawBody, signature) {
17
- if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400);
32
+ if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400, { messageCode: "payments.webhook.rawBodyRequired" });
18
33
  try { return await client.webhooks.unmarshal(rawBody.toString("utf8"), webhookSecret, signature); }
19
- catch { throw new PaymentError("payment_signature_invalid", "Paddle webhook verification failed.", 400); }
34
+ catch { throw new PaymentError("payment_signature_invalid", "Paddle webhook verification failed.", 400, {
35
+ messageCode: "payments.webhook.verificationFailed", messageParams: { provider: "Paddle" }
36
+ }); }
20
37
  }
21
38
  function eventIdentity(event) {
22
39
  if (event.eventType === "transaction.completed") {
@@ -65,7 +82,7 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
65
82
  return { id: result.id, url: result.urls.general.overview };
66
83
  }
67
84
  async function readHistory({ customerId, collection, after = null }) {
68
- if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.");
85
+ if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.", 422, { messageCode: "payments.input.historyCollection" });
69
86
  const query = { customerId: [customerId], perPage: 20, ...(after ? { after } : {}) };
70
87
  const page = collection === "subscriptions" ? client.subscriptions.list(query) : client.transactions.list(query);
71
88
  // Fetch one page only; never iterate the SDK collection over the account.
@@ -89,7 +106,9 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
89
106
  return { id: value.id, name: value.name };
90
107
  }
91
108
  async function createProduct({ name }) {
92
- if (!taxCategory) throw new PaymentError("payment_configuration_invalid", "Set the application's Paddle product tax category before publishing.");
109
+ if (!taxCategory) throw new PaymentError("payment_configuration_invalid", "Set the application's Paddle product tax category before publishing.", 422, {
110
+ messageCode: "payments.configuration.taxCategoryUnpublishable"
111
+ });
93
112
  return client.products.create({ name, taxCategory });
94
113
  }
95
114
  async function renameProduct({ id, name }) {
@@ -103,7 +122,22 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
103
122
  async function createPrice({ productId, amount, currency, interval }) {
104
123
  return client.prices.create({ productId, description: `${currency} ${amount} per ${interval}`, unitPrice: { amount: String(amount), currencyCode: currency }, billingCycle: { interval, frequency: 1 } });
105
124
  }
106
- return Object.freeze({ validatePlan, verifyAccount, verifyEvent, eventIdentity, loadEvent, createCustomer, createCheckout, createPortal, readHistory, readProduct, createProduct, renameProduct, readPrice, createPrice });
125
+ return Object.freeze({
126
+ validatePlan,
127
+ verifyAccount,
128
+ verifyEvent,
129
+ eventIdentity,
130
+ loadEvent,
131
+ createCustomer,
132
+ createCheckout,
133
+ createPortal,
134
+ readHistory,
135
+ readProduct,
136
+ createProduct,
137
+ renameProduct,
138
+ readPrice,
139
+ createPrice
140
+ });
107
141
  }
108
142
 
109
143
  export { createPaddlePaymentAdapter };
@@ -1,7 +1,13 @@
1
1
  import { PaymentError } from "./errors.js";
2
2
 
3
- // Plan-to-price bindings shared by the provider adapters. `invalid` throws the
4
- // adapter's own provider-result error for an unknown or ambiguous price.
3
+ /**
4
+ * Plan-to-price bindings shared by the provider adapters. `invalid` throws the
5
+ * adapter's own provider-result error for an unknown or ambiguous price.
6
+ * @param {object} bindings
7
+ * @param {Record<string, string>} bindings.priceBindings Each plan's current price id.
8
+ * @param {Record<string, string>} [bindings.historicalPriceBindings] The plan of each retired price id.
9
+ * @param {() => never} bindings.invalid
10
+ */
5
11
  function createPriceBindings({ priceBindings, historicalPriceBindings = {}, invalid }) {
6
12
  const prices = structuredClone(priceBindings);
7
13
  function validatePlan(planId) {
@@ -17,7 +23,11 @@ function createPriceBindings({ priceBindings, historicalPriceBindings = {}, inva
17
23
  if (matches.length !== 1) invalid();
18
24
  return matches[0][0];
19
25
  }
20
- return { validatePlan, priceFor, planFor };
26
+ return Object.freeze({
27
+ validatePlan,
28
+ priceFor,
29
+ planFor
30
+ });
21
31
  }
22
32
 
23
33
  export { createPriceBindings };
@@ -13,10 +13,19 @@ const DEFAULT_CHECKS = Object.freeze([
13
13
  ]);
14
14
  // Only these checks take their result from the application's own evidence.
15
15
  const APPLICATION_CHECKS = ["webhook", "checkout", "site", "deployment"];
16
+ // No application evidence: every application check keeps its default.
17
+ const inspectNoApplicationEvidence = async () => ({});
16
18
 
17
- // App-server inspection, invoked only after the host authorizes management.
18
- // Missing app evidence stays unknown; provider errors never become display text.
19
- function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication }) {
19
+ /**
20
+ * App-server inspection, invoked only after the host authorizes management.
21
+ * Missing app evidence stays unknown; provider errors never become display text.
22
+ * @param {object} dependencies
23
+ * @param {import("./types.js").PaymentProviderAdapter} dependencies.adapter
24
+ * @param {import("./types.js").PaymentCatalogue} dependencies.catalogue
25
+ * @param {import("./types.js").PaymentMerchantScope} dependencies.scope The merchant scope the readiness checks belong to.
26
+ * @param {() => unknown} [dependencies.inspectApplication] The application's own evidence, by check id.
27
+ */
28
+ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication = inspectNoApplicationEvidence }) {
20
29
  const merchant = structuredClone(scope);
21
30
  if (!["sandbox", "live"].includes(merchant?.environment) || !merchant.providerAccountId) throw new TypeError("Supply the configured payment merchant scope.");
22
31
  async function inspect() {
@@ -27,7 +36,9 @@ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication
27
36
  catch { set("credentials", "failed", "Provider verification failed. Check the account, credentials and environment."); }
28
37
  if (provider) {
29
38
  if (provider.environment !== merchant.environment || (provider.accountId && provider.accountId !== merchant.providerAccountId)) {
30
- throw new PaymentError("payment_scope_invalid", "Readiness returned a different payment account or environment.", 403);
39
+ throw new PaymentError("payment_scope_invalid", "Readiness returned a different payment account or environment.", 403, {
40
+ messageCode: "payments.scope.readinessMismatch"
41
+ });
31
42
  }
32
43
  const verified = provider.credentialsVerified === true || provider.accountId === merchant.providerAccountId;
33
44
  set("credentials", verified ? "passed" : "unknown", verified
@@ -48,18 +59,16 @@ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication
48
59
  unresolved ? "Catalogue changes, drift, removed plans or an unresolved write need review." : "Saved plans match their published provider products and prices.");
49
60
  } catch { set("catalogue", "failed", "Catalogue inspection failed. Review the application's catalogue command and provider access."); }
50
61
  }
51
- if (inspectApplication) {
52
- let evidence;
53
- try { evidence = await inspectApplication(); } catch { evidence = {}; }
54
- for (const id of APPLICATION_CHECKS) {
55
- const value = evidence?.[id];
56
- if (value === undefined) continue;
57
- if (!value || !["passed", "failed", "unknown", "manual"].includes(value.status) || typeof value.detail !== "string" ||
58
- !value.detail.trim() || value.detail.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.detail)) {
59
- throw new PaymentError("payment_readiness_invalid", "App readiness checks must return bounded status and safe explanatory text.");
60
- }
61
- set(id, value.status, value.detail);
62
+ let evidence;
63
+ try { evidence = await inspectApplication(); } catch { evidence = {}; }
64
+ for (const id of APPLICATION_CHECKS) {
65
+ const value = evidence?.[id];
66
+ if (value === undefined) continue;
67
+ if (!value || !["passed", "failed", "unknown", "manual"].includes(value.status) || typeof value.detail !== "string" ||
68
+ !value.detail.trim() || value.detail.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.detail)) {
69
+ throw new PaymentError("payment_readiness_invalid", "App readiness checks must return bounded status and safe explanatory text.");
62
70
  }
71
+ set(id, value.status, value.detail);
63
72
  }
64
73
  return { paymentEnvironment: merchant.environment, providerAccountId: merchant.providerAccountId, checks: [...checks.values()] };
65
74
  }
@@ -1,16 +1,16 @@
1
1
  import { PaymentError, fingerprint } from "./errors.js";
2
2
 
3
- const fail = (code, message, status) => { throw new PaymentError(code, message, status); };
3
+ const fail = (code, message, status, options) => { throw new PaymentError(code, message, status, options); };
4
4
  function reference(value) {
5
- if (typeof value !== "string" || !value || value.length > 150 || ["__proto__", "constructor", "prototype"].includes(value)) fail("payment_input_invalid", "Use a nonempty business reference of at most 150 characters.");
5
+ if (typeof value !== "string" || !value || value.length > 150 || ["__proto__", "constructor", "prototype"].includes(value)) fail("payment_input_invalid", "Use a nonempty business reference of at most 150 characters.", 422, { messageCode: "payments.input.reference" });
6
6
  return value;
7
7
  }
8
8
  function units(value) {
9
- if (!Number.isSafeInteger(value) || value < 1 || value > 1e9) fail("payment_input_invalid", "Use a positive integer credit quantity, at most one billion.");
9
+ if (!Number.isSafeInteger(value) || value < 1 || value > 1e9) fail("payment_input_invalid", "Use a positive integer credit quantity, at most one billion.", 422, { messageCode: "payments.input.creditQuantity" });
10
10
  return value;
11
11
  }
12
12
  function timestamp(value) {
13
- if (!Number.isSafeInteger(value) || value < 0) fail("payment_input_invalid", "Use an integer UTC timestamp in milliseconds.");
13
+ if (!Number.isSafeInteger(value) || value < 0) fail("payment_input_invalid", "Use an integer UTC timestamp in milliseconds.", 422, { messageCode: "payments.input.timestamp" });
14
14
  return value;
15
15
  }
16
16
  const unexpired = (lot, now) => lot.expiresAt === null || lot.expiresAt > now;
@@ -20,7 +20,7 @@ async function once(tx, key, input, work) {
20
20
  const digest = fingerprint(input);
21
21
  const previous = await tx.find(key);
22
22
  if (previous) {
23
- if (previous.digest !== digest) fail("payment_reference_conflict", "This business reference was already used with different inputs.", 409);
23
+ if (previous.digest !== digest) fail("payment_reference_conflict", "This business reference was already used with different inputs.", 409, { messageCode: "payments.reference.differentInputs" });
24
24
  return { ...previous.result, duplicate: true };
25
25
  }
26
26
  const result = await work();
@@ -39,16 +39,27 @@ async function grant(tx, { reference: id, units: quantity, expiresAt }, now) {
39
39
  });
40
40
  }
41
41
 
42
- // Server-side domain API. The application authorizes the billable subject before
43
- // invoking it; none of these methods is an HTTP endpoint or a browser authority.
42
+ /**
43
+ * Server-side domain API. The application authorizes the billable subject before
44
+ * invoking it; none of these methods is an HTTP endpoint or a browser authority.
45
+ * @param {object} dependencies
46
+ * @param {import("./types.js").PaymentStore} dependencies.store
47
+ * @param {import("./types.js").PaymentConfiguration} dependencies.configuration
48
+ * @param {() => number} [dependencies.clock]
49
+ */
44
50
  function createPaymentService({ store, configuration, clock = Date.now }) {
45
- if (!store?.withAccount || !store?.inspect || !configuration?.plans) 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 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.");
54
+ }
46
55
  const plans = structuredClone(configuration.plans);
47
56
  const environments = structuredClone(configuration.environments);
48
57
  function checkScope(scope) {
49
58
  const expected = environments[scope?.environment];
50
59
  if (!expected || expected.integrationId !== scope.integrationId || expected.providerAccountId !== scope.providerAccountId) {
51
- fail("payment_scope_invalid", "The payment scope does not match this environment's merchant configuration.", 403);
60
+ fail("payment_scope_invalid", "The payment scope does not match this environment's merchant configuration.", 403, {
61
+ messageCode: "payments.scope.merchantMismatch"
62
+ });
52
63
  }
53
64
  }
54
65
 
@@ -120,6 +131,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
120
131
  async function reconcileEvent(merchantScope, { eventId, customerId, load }) {
121
132
  checkScope(merchantScope);
122
133
  reference(eventId); reference(customerId);
134
+ // readable-check: allow method-probe - input validation: the caller passes its authenticated provider reconciliation
123
135
  if (typeof load !== "function") throw new TypeError("Supply an authenticated provider reconciliation function.");
124
136
  const subjectId = await store.resolveCustomer(merchantScope, customerId);
125
137
  if (!subjectId) fail("payment_customer_unbound", "Bind this provider customer before processing its events.", 409);
@@ -128,7 +140,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
128
140
  const { subscription, renewal } = await load();
129
141
  reference(subscription?.id);
130
142
  if (!["active", "trialing", "past_due", "paused", "canceled", "unpaid", "incomplete", "incomplete_expired"].includes(subscription.status) ||
131
- !Object.hasOwn(plans, subscription.planId)) fail("payment_provider_result_invalid", "Check the provider subscription and logical price binding.", 502);
143
+ !Object.hasOwn(plans, subscription.planId)) fail("payment_provider_result_invalid", "Check the provider subscription and logical price binding.", 502, { messageCode: "payments.provider.subscriptionBinding" });
132
144
  timestamp(subscription.periodEnd);
133
145
  tx.state.subscriptions[subscription.id] = {
134
146
  id: subscription.id, status: subscription.status, planId: subscription.planId, periodEnd: subscription.periodEnd
@@ -137,7 +149,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
137
149
  if (renewal) {
138
150
  reference(renewal.id);
139
151
  timestamp(renewal.periodEnd);
140
- if (!Object.hasOwn(plans, renewal.planId)) fail("payment_provider_result_invalid", "Check the renewal's logical price binding.", 502);
152
+ if (!Object.hasOwn(plans, renewal.planId)) fail("payment_provider_result_invalid", "Check the renewal's logical price binding.", 502, { messageCode: "payments.provider.renewalBinding" });
141
153
  const quantity = plans[renewal.planId].renewalCredits;
142
154
  // A provider invoice/transaction is the business key, not its delivery ID.
143
155
  if (quantity) creditGrant = await grant(tx, { reference: `renewal:${renewal.id}`, units: quantity, expiresAt: renewal.periodEnd }, timestamp(clock()));
@@ -146,7 +158,14 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
146
158
  }));
147
159
  }
148
160
 
149
- return Object.freeze({ inspect, requireFeature, grantCredits, debitCredits, refundDebit, reconcileEvent });
161
+ return Object.freeze({
162
+ inspect,
163
+ requireFeature,
164
+ grantCredits,
165
+ debitCredits,
166
+ refundDebit,
167
+ reconcileEvent
168
+ });
150
169
  }
151
170
 
152
171
  export { createPaymentService, PaymentError };
@@ -3,8 +3,23 @@ import { PaymentError } from "./errors.js";
3
3
  import { createPriceBindings } from "./priceBindings.js";
4
4
 
5
5
  const id = (value) => typeof value === "string" ? value : value?.id;
6
- const invalid = () => { throw new PaymentError("payment_provider_result_invalid", "Check the Stripe account, price mapping and subscription shape.", 502); };
6
+ const invalid = () => {
7
+ throw new PaymentError("payment_provider_result_invalid", "Check the Stripe account, price mapping and subscription shape.", 502, {
8
+ messageCode: "payments.provider.checkAccount", messageParams: { provider: "Stripe" }
9
+ });
10
+ };
7
11
 
12
+ /**
13
+ * @param {object} options
14
+ * @param {string} [options.apiKey]
15
+ * @param {string} options.webhookSecret
16
+ * @param {"sandbox" | "live"} options.environment
17
+ * @param {string} options.providerAccountId
18
+ * @param {Record<string, string>} options.priceBindings Each plan's current price id.
19
+ * @param {Record<string, string>} [options.historicalPriceBindings] The plan of each retired price id.
20
+ * @param {import("stripe").Stripe} [options.client]
21
+ */
22
+ // readable-check: allow dependency-default - data: historicalPriceBindings maps retired price ids to plans, not a collaborator
8
23
  function createStripePaymentAdapter({ apiKey, webhookSecret, environment, providerAccountId, priceBindings, historicalPriceBindings = {}, client = new Stripe(apiKey, { maxNetworkRetries: 0 }) }) {
9
24
  if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret || !providerAccountId) throw new TypeError("Supply Stripe credentials, environment and account identity.");
10
25
  const live = environment === "live";
@@ -16,10 +31,12 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
16
31
  return { accountId: account.id, environment, chargesEnabled: account.charges_enabled === true, payoutsEnabled: account.payouts_enabled === true };
17
32
  }
18
33
  async function verifyEvent(rawBody, signature) {
19
- if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400);
34
+ if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400, { messageCode: "payments.webhook.rawBodyRequired" });
20
35
  let event;
21
36
  try { event = client.webhooks.constructEvent(rawBody, signature, webhookSecret); }
22
- catch { throw new PaymentError("payment_signature_invalid", "Stripe webhook verification failed.", 400); }
37
+ catch { throw new PaymentError("payment_signature_invalid", "Stripe webhook verification failed.", 400, {
38
+ messageCode: "payments.webhook.verificationFailed", messageParams: { provider: "Stripe" }
39
+ }); }
23
40
  if (event.livemode !== live || (event.account && event.account !== providerAccountId)) invalid();
24
41
  return event;
25
42
  }
@@ -82,7 +99,7 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
82
99
  return { id: result.id, url: result.url };
83
100
  }
84
101
  async function readHistory({ customerId, collection, after = null }) {
85
- if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.");
102
+ if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.", 422, { messageCode: "payments.input.historyCollection" });
86
103
  await verifyAccount();
87
104
  const query = { customer: customerId, limit: 20, ...(after ? { starting_after: after } : {}) };
88
105
  const page = collection === "subscriptions"
@@ -120,11 +137,27 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
120
137
  if (value.recurring?.interval_count !== 1 || value.billing_scheme !== "per_unit") invalid();
121
138
  return { id: value.id, productId: id(value.product), amount: value.unit_amount, currency: value.currency.toUpperCase(), interval: value.recurring.interval, active: value.active };
122
139
  }
140
+ /** @param {{ productId: string, amount: number, currency: string, interval: string, requestId: string }} price */
123
141
  async function createPrice({ productId, amount, currency, interval, requestId }) {
124
142
  await verifyAccount();
125
143
  return client.prices.create({ product: productId, unit_amount: amount, currency: currency.toLowerCase(), recurring: { interval } }, { idempotencyKey: requestId });
126
144
  }
127
- return Object.freeze({ validatePlan, verifyAccount, verifyEvent, eventIdentity, loadEvent, createCustomer, createCheckout, createPortal, readHistory, readProduct, createProduct, renameProduct, readPrice, createPrice });
145
+ return Object.freeze({
146
+ validatePlan,
147
+ verifyAccount,
148
+ verifyEvent,
149
+ eventIdentity,
150
+ loadEvent,
151
+ createCustomer,
152
+ createCheckout,
153
+ createPortal,
154
+ readHistory,
155
+ readProduct,
156
+ createProduct,
157
+ renameProduct,
158
+ readPrice,
159
+ createPrice
160
+ });
128
161
  }
129
162
 
130
163
  export { createStripePaymentAdapter };
@@ -0,0 +1,105 @@
1
+ // The types of the payment objects an application composes, for code that receives them,
2
+ // and the contracts of the store and provider adapter it chooses.
3
+
4
+ /** @typedef {ReturnType<typeof import("./catalogue.js").createPaymentCatalogue>} PaymentCatalogue */
5
+ /** @typedef {ReturnType<typeof import("./checkout.js").createPaymentCheckoutService>} PaymentCheckoutService */
6
+ /** @typedef {ReturnType<typeof import("./readiness.js").createPaymentReadiness>} PaymentReadiness */
7
+ /** @typedef {ReturnType<typeof import("./service.js").createPaymentService>} PaymentService */
8
+ /** @typedef {ReturnType<typeof import("../shared/configuration.js").validatePaymentConfiguration>} PaymentConfiguration */
9
+
10
+ /**
11
+ * The merchant a payment record belongs to: one application's provider account
12
+ * in one environment.
13
+ * @typedef {object} PaymentMerchantScope
14
+ * @property {string} applicationId
15
+ * @property {string} integrationId
16
+ * @property {string} providerAccountId
17
+ * @property {"sandbox" | "live"} environment
18
+ */
19
+
20
+ /**
21
+ * One billable subject of a merchant.
22
+ * @typedef {PaymentMerchantScope & { subjectId: string }} PaymentAccountScope
23
+ */
24
+
25
+ /**
26
+ * One billable account's stored state.
27
+ * @typedef {object} PaymentAccountState
28
+ * @property {Array<{ id: string, remaining: number, expiresAt: number | null }>} lots The credit grants and what remains of each.
29
+ * @property {Record<string, { id: string, status: string, planId: string, periodEnd: number }>} subscriptions
30
+ * @property {string} [customerId] The provider customer bound to the account.
31
+ * @property {Record<string, any>} [pendingPaymentOperation] A provider request whose outcome is not recorded yet.
32
+ */
33
+
34
+ /**
35
+ * An account held under the store's lock while work runs: its state, which the
36
+ * work changes in place, and the entries recorded under business references.
37
+ * @typedef {object} PaymentAccountTransaction
38
+ * @property {PaymentAccountState} state
39
+ * @property {(reference: string) => Promise<any>} find The entry recorded under a reference, or null.
40
+ * @property {(reference: string, entry: any) => Promise<void>} record Records an entry under a new reference.
41
+ */
42
+
43
+ /**
44
+ * One merchant's published catalogue: each plan's provider product and price.
45
+ * @typedef {object} PaymentCatalogueState
46
+ * @property {Record<string, Record<string, any>>} plans
47
+ * @property {Array<Record<string, any>>} history The price bindings each plan had before its current one.
48
+ * @property {number} [revision]
49
+ * @property {Record<string, any>} [pending] A provider write whose outcome is not recorded yet.
50
+ */
51
+
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
55
+ * record's lock and saves the state the work leaves.
56
+ * @typedef {object} PaymentStore
57
+ * @property {(scope: PaymentAccountScope) => Promise<PaymentAccountState>} inspect
58
+ * @property {<T>(scope: PaymentAccountScope, work: (account: PaymentAccountTransaction) => Promise<T>) => Promise<T>} withAccount
59
+ * @property {(scope: PaymentMerchantScope, customerId: string, subjectId: string) => Promise<void>} bindCustomer
60
+ * Binds a provider customer to one billable subject, refusing a customer bound to another.
61
+ * @property {(scope: PaymentMerchantScope, customerId: string) => Promise<string | null>} resolveCustomer The subject a provider customer is bound to.
62
+ * @property {(scope: PaymentMerchantScope) => Promise<PaymentCatalogueState>} inspectCatalogue
63
+ * @property {<T>(scope: PaymentMerchantScope, work: (state: PaymentCatalogueState) => Promise<T>) => Promise<T>} withCatalogue
64
+ */
65
+
66
+ /**
67
+ * A provider's billing page: its id and the URL the customer opens.
68
+ * @typedef {{ id: string, url: string }} PaymentProviderPage
69
+ */
70
+
71
+ /**
72
+ * A price as the provider holds it.
73
+ * @typedef {object} PaymentProviderPrice
74
+ * @property {string} id
75
+ * @property {string} productId
76
+ * @property {number} amount In the currency's minor unit.
77
+ * @property {string} currency
78
+ * @property {string} interval
79
+ * @property {boolean} active
80
+ */
81
+
82
+ /**
83
+ * A payment provider's operations: createStripePaymentAdapter() or
84
+ * createPaddlePaymentAdapter().
85
+ * @typedef {object} PaymentProviderAdapter
86
+ * @property {(planId: string) => void} validatePlan Throws for a plan with no current price binding.
87
+ * @property {() => Promise<{ environment: string, accountId?: string, credentialsVerified?: boolean, chargesEnabled?: boolean, payoutsEnabled?: boolean }>} verifyAccount
88
+ * What the provider confirms about the configured account.
89
+ * @property {(rawBody: Buffer, signature: string) => Promise<any>} verifyEvent The webhook event, once its signature is verified.
90
+ * @property {(event: any) => { eventId: string, customerId: string } | null} eventIdentity The event and customer of a billing event, or null for an event to ignore.
91
+ * @property {(event: any) => Promise<{ customerId: string, subscription: Record<string, any>, renewal?: Record<string, any> } | null>} loadEvent
92
+ * The provider's current subscription facts for the event.
93
+ * @property {(request: { email: string, requestId: string }) => Promise<string>} createCustomer The new customer's id.
94
+ * @property {(request: { customerId: string, planId: string, returnUrl: string, requestId: string }) => Promise<PaymentProviderPage>} createCheckout
95
+ * @property {(request: { customerId: string, returnUrl: string }) => Promise<PaymentProviderPage>} createPortal
96
+ * @property {(request: { customerId: string, collection: string, after?: string | null }) => Promise<{ collection: string, items: Array<Record<string, any>>, nextCursor: string | null }>} readHistory
97
+ * One page of the customer's subscriptions or transactions.
98
+ * @property {(productId: string) => Promise<{ id: string, name: string }>} readProduct
99
+ * @property {(request: { name: string, requestId: string }) => Promise<{ id: string }>} createProduct
100
+ * @property {(request: { id: string, name: string, requestId: string }) => Promise<{ id: string }>} renameProduct
101
+ * @property {(priceId: string) => Promise<PaymentProviderPrice>} readPrice
102
+ * @property {(request: { productId: string, amount: number, currency: string, interval: string, requestId: string }) => Promise<{ id: string }>} createPrice
103
+ */
104
+
105
+ export {};
@@ -3,15 +3,33 @@ import schema from "../../contracts/configuration.schema.json" with { type: "jso
3
3
 
4
4
  const validate = new Ajv({ allErrors: true, strict: true }).compile(schema);
5
5
 
6
- function configurationError(message, fieldErrors) {
7
- return Object.assign(new Error(message), { code: "payment_configuration_invalid", statusCode: 422, fieldErrors });
6
+ /**
7
+ * A refused payment configuration: every field error has its path, English
8
+ * message, code and params, and the error's messageCode names its own sentence.
9
+ */
10
+ class PaymentConfigurationError extends Error {
11
+ /**
12
+ * @param {string} message
13
+ * @param {Array<{ path: string, message: string, code?: string, params?: Record<string, unknown> }>} fieldErrors
14
+ * @param {{ messageCode: string, messageParams?: Record<string, unknown> }} translation
15
+ */
16
+ constructor(message, fieldErrors, { messageCode, messageParams = {} }) {
17
+ super(message);
18
+ this.name = "PaymentConfigurationError";
19
+ this.code = "payment_configuration_invalid";
20
+ this.statusCode = 422;
21
+ this.fieldErrors = fieldErrors;
22
+ this.messageCode = messageCode;
23
+ this.messageParams = messageParams;
24
+ }
8
25
  }
9
26
 
10
27
  function validatePaymentConfiguration(document) {
11
28
  const value = document?.extensions?.payments;
12
29
  if (!validate(value)) {
13
- throw configurationError("Check the application's payment configuration.",
14
- validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message })));
30
+ throw new PaymentConfigurationError("Check the application's payment configuration.",
31
+ validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message })),
32
+ { messageCode: "payments.configuration.invalid" });
15
33
  }
16
34
  // Report every environment's binding problems at once, like the schema stage.
17
35
  const fieldErrors = [];
@@ -20,15 +38,27 @@ function validatePaymentConfiguration(document) {
20
38
  const path = `extensions.payments/environments/${environment}`;
21
39
  if (!slot || !["stripe", "paddle"].includes(slot.provider) || slot.accountMode !== "shared" ||
22
40
  slot.authentication?.method !== "api-key" || !/^env:[A-Z_][A-Z0-9_]*$/.test(slot.authentication.secretRef || "")) {
23
- fieldErrors.push({ path: `${path}/integrationId`, message: `Select a shared Stripe or Paddle connection with a backend API-key Env reference for ${environment}.` });
41
+ fieldErrors.push({ path: `${path}/integrationId`, message: `Select a shared Stripe or Paddle connection with a backend API-key Env reference for ${environment}.`,
42
+ code: "payments.configuration.sharedConnectionRequired", params: { environment } });
24
43
  }
25
44
  if (slot?.provider === "paddle") {
26
- if (slot.settings?.environment !== environment) fieldErrors.push({ path: `${path}/integrationId`, message: `Select a Paddle connection configured for ${environment}.` });
27
- if (!binding.taxCategory) fieldErrors.push({ path: `${path}/taxCategory`, message: "Choose the Paddle tax category for the products this app sells." });
28
- if (!binding.publicClientTokenRef) fieldErrors.push({ path: `${path}/publicClientTokenRef`, message: "Set the Env reference for this environment's Paddle public client token." });
45
+ if (slot.settings?.environment !== environment) {
46
+ fieldErrors.push({ path: `${path}/integrationId`, message: `Select a Paddle connection configured for ${environment}.`,
47
+ code: "payments.configuration.paddleEnvironment", params: { environment } });
48
+ }
49
+ if (!binding.taxCategory) {
50
+ fieldErrors.push({ path: `${path}/taxCategory`, message: "Choose the Paddle tax category for the products this app sells.",
51
+ code: "payments.configuration.paddleTaxCategory", params: {} });
52
+ }
53
+ if (!binding.publicClientTokenRef) {
54
+ fieldErrors.push({ path: `${path}/publicClientTokenRef`, message: "Set the Env reference for this environment's Paddle public client token.",
55
+ code: "payments.configuration.paddleClientToken", params: {} });
56
+ }
29
57
  }
30
58
  }
31
- if (fieldErrors.length) throw configurationError(fieldErrors[0].message, fieldErrors);
59
+ if (fieldErrors.length) throw new PaymentConfigurationError(fieldErrors[0].message, fieldErrors, {
60
+ messageCode: fieldErrors[0].code, messageParams: fieldErrors[0].params
61
+ });
32
62
  // The validated declaration is JSON data; callers may supply reactive proxies.
33
63
  return JSON.parse(JSON.stringify(value));
34
64
  }
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import { catalogMessage, formatMessage, messageKeyForCode } from "@jskit-ai/kernel/shared/i18n";
7
+ import { evaluateI18nCatalogContract } from "../../../tooling/test-support/i18nCatalogContract.mjs";
8
+ import { validatePaymentConfiguration } from "../src/shared/configuration.js";
9
+ import conformance from "../contracts/conformance.json" with { type: "json" };
10
+ import en from "../locales/en.json" with { type: "json" };
11
+
12
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
13
+ const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
14
+ const PACKAGE_DIR = path.join(REPO_ROOT, "packages", "payments-core");
15
+
16
+ function english(code, params) {
17
+ const template = catalogMessage(en, messageKeyForCode(code));
18
+ return template === undefined ? undefined : formatMessage(template, params);
19
+ }
20
+
21
+ test("payments-core English catalog matches its source", () => {
22
+ const { problems } = evaluateI18nCatalogContract({
23
+ repoRoot: REPO_ROOT,
24
+ packageDir: PACKAGE_DIR
25
+ });
26
+ assert.deepEqual(problems, []);
27
+ });
28
+
29
+ test("every payment error's catalog English equals the English it throws", () => {
30
+ const site = /(?:PaymentError|fail)\(\s*"(payment_[a-z_]+)",\s*"((?:[^"\\]|\\.)*)"(?:,\s*\d+)?(?:,\s*\{\s*messageCode:\s*"([^"]+)"(?:,\s*messageParams:\s*\{\s*provider:\s*"([^"]+)"\s*\})?\s*\})?/gu;
31
+ const sources = fs.readdirSync(path.join(PACKAGE_DIR, "src", "server"))
32
+ .map((file) => fs.readFileSync(path.join(PACKAGE_DIR, "src", "server", file), "utf8"));
33
+ const sites = sources.join("\n").match(/(?:PaymentError|fail)\("payment_/gu).length;
34
+ let checked = 0;
35
+ for (const source of sources) {
36
+ for (const [, code, message, messageCode, provider] of source.matchAll(site)) {
37
+ const expected = JSON.parse(`"${message}"`);
38
+ if (messageCode) {
39
+ assert.equal(english(messageCode, provider ? { provider } : {}), expected, messageCode);
40
+ } else {
41
+ assert.equal(english(code), expected, code);
42
+ }
43
+ checked += 1;
44
+ }
45
+ }
46
+ assert.equal(checked, sites);
47
+ });
48
+
49
+ test("configuration errors carry codes whose English matches each field error", () => {
50
+ const coded = new Set();
51
+ for (const item of conformance.configurationCases) {
52
+ const documents = [item.document];
53
+ if (item.id === "paddle-sandbox") {
54
+ const document = structuredClone(item.document);
55
+ delete document.extensions.payments.environments.sandbox.taxCategory;
56
+ documents.push(document);
57
+ }
58
+ for (const document of documents) {
59
+ let error = null;
60
+ try {
61
+ validatePaymentConfiguration(document);
62
+ } catch (caught) {
63
+ error = caught;
64
+ }
65
+ if (!error) continue;
66
+ assert.equal(english(error.messageCode, error.messageParams), error.message, item.id);
67
+ for (const entry of error.fieldErrors.filter(({ code }) => code)) {
68
+ assert.equal(english(entry.code, entry.params), entry.message, entry.code);
69
+ coded.add(entry.code);
70
+ }
71
+ }
72
+ }
73
+ assert.deepEqual([...coded].sort(), [
74
+ "payments.configuration.paddleClientToken",
75
+ "payments.configuration.paddleEnvironment",
76
+ "payments.configuration.paddleTaxCategory",
77
+ "payments.configuration.sharedConnectionRequired"
78
+ ]);
79
+ });
@@ -9,7 +9,7 @@ import { createPaymentService } from '@jskit-ai/payments-core/server';
9
9
  import { createKnexPaymentStore } from '@jskit-ai/payments-core/server/storage';
10
10
  import migration from '../migrations/payments_core_initial.cjs';
11
11
 
12
- // No editor, Genesis project, network or generated application is involved.
12
+ // No editor, project tooling, network or generated application is involved.
13
13
  test('a hand-written app reloads payment policy from disk while retaining its own billing state', async () => {
14
14
  const directory = await mkdtemp(join(tmpdir(), 'standalone-payments-'));
15
15
  const path = join(directory, 'integrations.json');