@jskit-ai/payments-core 0.1.61 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. |
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.
@@ -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.61",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Application-owned payment configuration, subscription access and transactional usage credits.",
6
6
  "exports": {
@@ -15,7 +15,8 @@
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
+ "./locales/en.json": "./locales/en.json"
19
20
  },
20
21
  "scripts": {
21
22
  "test": "node --test --test-concurrency=1"
@@ -1,7 +1,6 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { PaymentError } from "./service.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { PaymentError, fingerprint } from "./errors.js";
3
3
 
4
- const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
5
4
  const priceFields = (plan) => ({ amount: plan.amount, currency: plan.currency, interval: plan.interval });
6
5
  const samePrice = (a, b) => fingerprint(priceFields(a)) === fingerprint(priceFields(b));
7
6
 
@@ -12,7 +11,9 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
12
11
  const merchant = structuredClone(scope);
13
12
  const environment = configuration.environments[merchant.environment];
14
13
  if (!environment || environment.integrationId !== merchant.integrationId || environment.providerAccountId !== merchant.providerAccountId) {
15
- throw new PaymentError("payment_scope_invalid", "Catalogue scope does not match its configured environment.", 403);
14
+ throw new PaymentError("payment_scope_invalid", "Catalogue scope does not match its configured environment.", 403, {
15
+ messageCode: "payments.scope.catalogueMismatch"
16
+ });
16
17
  }
17
18
  async function preview() {
18
19
  const state = await store.inspectCatalogue(merchant);
@@ -39,19 +40,19 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
39
40
  }
40
41
  async function publish({ reviewId }) {
41
42
  const review = await preview();
42
- 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);
43
+ 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" });
43
44
  const completed = [];
44
45
  let expectedRevision = review.revision;
45
46
  for (const change of review.changes) {
46
47
  const token = randomUUID();
47
48
  const pending = await store.withCatalogue(merchant, async (state) => {
48
49
  if (state.pending) throw new PaymentError("payment_catalogue_pending", "Another catalogue request needs resolution.", 409);
49
- if ((state.revision || 0) !== expectedRevision) throw new PaymentError("payment_catalogue_review_required", "Another publication changed the catalogue; review again.", 409);
50
+ if ((state.revision || 0) !== expectedRevision) throw new PaymentError("payment_catalogue_review_required", "Another publication changed the catalogue; review again.", 409, { messageCode: "payments.catalogue.publicationChanged" });
50
51
  const current = state.plans[change.planId];
51
52
  // Reject another publisher changing this plan after the reviewed read.
52
- if (change.action === "create-product" && current) throw new PaymentError("payment_catalogue_review_required", "This plan changed; review again.", 409);
53
- if (change.action === "rename-product" && current?.productId !== change.productId) throw new PaymentError("payment_catalogue_review_required", "This product changed; review again.", 409);
54
- 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);
53
+ if (change.action === "create-product" && current) throw new PaymentError("payment_catalogue_review_required", "This plan changed; review again.", 409, { messageCode: "payments.catalogue.planChanged" });
54
+ 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" });
55
+ 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" });
55
56
  const operation = { ...change, token, productId: current?.productId };
56
57
  state.pending = operation;
57
58
  return operation;
@@ -71,9 +72,9 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
71
72
  return { completed, removed: review.removed };
72
73
  }
73
74
  async function finish(pending, result) {
74
- if (typeof result?.id !== "string" || !result.id) throw new PaymentError("payment_provider_result_invalid", "The provider did not return an object ID.", 502);
75
+ 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" });
75
76
  await store.withCatalogue(merchant, async (state) => {
76
- if (state.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending catalogue request changed.", 409);
77
+ if (state.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending catalogue request changed.", 409, { messageCode: "payments.catalogue.pendingCatalogueRequestChanged" });
77
78
  const current = state.plans[pending.planId];
78
79
  if (pending.action === "create-product") state.plans[pending.planId] = { productId: result.id, name: pending.name };
79
80
  else if (pending.action === "rename-product") current.name = pending.name;
@@ -87,26 +88,26 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
87
88
  }
88
89
  async function recover({ reviewId, providerId, confirmedNotCreated = false }) {
89
90
  const review = await preview();
90
- if (review.reviewId !== reviewId) throw new PaymentError("payment_catalogue_review_required", "The recovery review changed. Inspect the current pending operation first.", 409);
91
+ 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" });
91
92
  const pending = review.pending;
92
93
  if (!pending) return { pending: false };
93
94
  if (confirmedNotCreated) {
94
95
  // Trusted app administrator must supply actual provider inspection evidence.
95
96
  // This parameter must not be exposed as an unauthenticated browser command.
96
97
  await store.withCatalogue(merchant, async (current) => {
97
- if (current.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending request changed.", 409);
98
+ if (current.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending request changed.", 409, { messageCode: "payments.catalogue.pendingRequestChanged" });
98
99
  delete current.pending;
99
100
  });
100
101
  return { pending: false, retryAllowed: true };
101
102
  }
102
103
  if (typeof providerId !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(providerId)) {
103
- throw new PaymentError("payment_input_invalid", "Supply the exact provider object ID to recover.");
104
+ throw new PaymentError("payment_input_invalid", "Supply the exact provider object ID to recover.", 422, { messageCode: "payments.input.recoveryObjectId" });
104
105
  }
105
106
  const result = pending.action === "create-price" ? await adapter.readPrice(providerId) : await adapter.readProduct(providerId);
106
107
  if (!result || result.id !== providerId || (pending.action === "create-price" && (result.active !== true || result.productId !== pending.productId || !samePrice(result, pending))) ||
107
108
  (pending.action !== "create-price" && result.name !== pending.name) ||
108
109
  (pending.action === "rename-product" && providerId !== pending.productId)) {
109
- throw new PaymentError("payment_catalogue_review_required", "The provider object does not match the pending operation.", 409);
110
+ throw new PaymentError("payment_catalogue_review_required", "The provider object does not match the pending operation.", 409, { messageCode: "payments.catalogue.objectMismatch" });
110
111
  }
111
112
  await finish(pending, { id: providerId });
112
113
  return { pending: false };
@@ -1,5 +1,5 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { PaymentError } from "./service.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { PaymentError, fingerprint } from "./errors.js";
3
3
 
4
4
  // Application composition boundary: authorization is mandatory and supplied by
5
5
  // the app's framework. Actor and subject must come from its authenticated route.
@@ -19,7 +19,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
19
19
  async function perform(scope, operation, remote, complete) {
20
20
  const token = randomUUID();
21
21
  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);
22
+ if (tx.state.pendingPaymentOperation) throw new PaymentError("payment_operation_uncertain", "Inspect the unfinished provider request before retrying.", 409, { messageCode: "payments.operation.unfinished" });
23
23
  const ready = await operation(tx);
24
24
  if (ready.cached) return ready;
25
25
  tx.state.pendingPaymentOperation = { token, ...ready };
@@ -30,7 +30,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
30
30
  // unresolved operation, rather than permitting a duplicate provider write.
31
31
  let result;
32
32
  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); }
33
+ 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
34
  return store.withAccount(scope, async (tx) => {
35
35
  if (tx.state.pendingPaymentOperation?.token !== token) throw new PaymentError("payment_operation_conflict", "The pending payment operation changed.", 409);
36
36
  const value = await complete(tx, result, prepared);
@@ -42,8 +42,8 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
42
42
  const scope = await allowed(actor, subjectId, "checkout");
43
43
  adapter.validatePlan(planId);
44
44
  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.");
46
- const identity = createHash("sha256").update(JSON.stringify(scope)).digest("hex");
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.", 422, { messageCode: "payments.input.billingEmail" });
46
+ const identity = fingerprint(scope);
47
47
  const customerId = await perform(scope, async (tx) => tx.state.customerId
48
48
  ? { cached: true, result: tx.state.customerId }
49
49
  : { action: "customer", email },
@@ -53,7 +53,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
53
53
  return perform(scope, async (tx) => {
54
54
  const saved = await tx.find(`checkout:${requestId}`);
55
55
  if (saved) {
56
- if (saved.planId !== planId) throw new PaymentError("payment_reference_conflict", "The checkout request already selected another plan.", 409);
56
+ if (saved.planId !== planId) throw new PaymentError("payment_reference_conflict", "The checkout request already selected another plan.", 409, { messageCode: "payments.reference.otherPlan" });
57
57
  return { cached: true, result: saved.result };
58
58
  }
59
59
  if (Object.values(tx.state.subscriptions).some((subscription) =>
@@ -62,7 +62,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
62
62
  }
63
63
  return { action: "checkout", customerId, planId, requestId };
64
64
  }, () => adapter.createCheckout({ customerId, planId, returnUrl: target.href,
65
- requestId: `checkout-${createHash("sha256").update(JSON.stringify([scope, requestId])).digest("hex")}` }),
65
+ requestId: `checkout-${fingerprint([scope, requestId])}` }),
66
66
  async (tx, result) => { await tx.record(`checkout:${requestId}`, { planId, result }); return result; });
67
67
  }
68
68
  async function account({ actor, subjectId }) {
@@ -75,7 +75,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
75
75
  const scope = await allowed(actor, subjectId, "history");
76
76
  if (!["subscriptions", "transactions"].includes(collection) ||
77
77
  (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.");
78
+ throw new PaymentError("payment_input_invalid", "Select billing subscriptions or transactions and a valid page cursor.", 422, { messageCode: "payments.input.historyPage" });
79
79
  }
80
80
  // Never accept a customer identifier from the browser or editor caller.
81
81
  const { customerId } = await store.inspect(scope);
@@ -121,7 +121,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
121
121
  const { eventId, customerId } = identity;
122
122
  return payments.reconcileEvent(merchant, { eventId, customerId, load: async () => {
123
123
  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);
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, { messageCode: "payments.provider.unsupportedEvent" });
125
125
  return facts;
126
126
  } });
127
127
  }
@@ -0,0 +1,21 @@
1
+ import { createHash } from "node:crypto";
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.
5
+ class PaymentError extends Error {
6
+ constructor(code, message, statusCode = 422, { messageCode = "", messageParams = {} } = {}) {
7
+ super(message);
8
+ this.name = "PaymentError";
9
+ this.code = code;
10
+ this.statusCode = statusCode;
11
+ if (messageCode) {
12
+ this.messageCode = messageCode;
13
+ this.messageParams = { ...messageParams };
14
+ }
15
+ }
16
+ }
17
+
18
+ // Stable sha256 hex of a JSON value: idempotency digests, review IDs and store keys.
19
+ const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
20
+
21
+ export { PaymentError, fingerprint };
@@ -1,6 +1,5 @@
1
- import { createHash } from "node:crypto";
1
+ import { PaymentError, fingerprint } from "./errors.js";
2
2
 
3
- const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
4
3
  const empty = () => ({ lots: [], subscriptions: {} });
5
4
 
6
5
  function merchant(scope) {
@@ -15,7 +14,7 @@ function identifier(value) {
15
14
  if (typeof value !== "string" || !value || value.length > 200) throw new TypeError("Payment identity is required (maximum 200 characters).");
16
15
  return value;
17
16
  }
18
- const accountKey = (scope) => hash([...merchant(scope), identifier(scope.subjectId)]);
17
+ const accountKey = (scope) => fingerprint([...merchant(scope), identifier(scope.subjectId)]);
19
18
 
20
19
  function createKnexPaymentStore({ knex }) {
21
20
  if (typeof knex !== "function" || typeof knex.transaction !== "function") throw new TypeError("Supply the application's transactional Knex client.");
@@ -35,11 +34,11 @@ function createKnexPaymentStore({ knex }) {
35
34
  const result = await work({
36
35
  state,
37
36
  async find(reference) {
38
- const entry = await trx("payment_entries").where({ entry_key: hash([key, identifier(reference)]) }).first();
37
+ const entry = await trx("payment_entries").where({ entry_key: fingerprint([key, identifier(reference)]) }).first();
39
38
  return entry ? JSON.parse(entry.payload) : null;
40
39
  },
41
40
  async record(reference, payload) {
42
- await trx("payment_entries").insert({ entry_key: hash([key, identifier(reference)]), account_key: key, payload: JSON.stringify(payload) });
41
+ await trx("payment_entries").insert({ entry_key: fingerprint([key, identifier(reference)]), account_key: key, payload: JSON.stringify(payload) });
43
42
  }
44
43
  });
45
44
  await trx("payment_accounts").where({ account_key: key }).update({ payload: JSON.stringify(state) });
@@ -47,25 +46,25 @@ function createKnexPaymentStore({ knex }) {
47
46
  });
48
47
  }
49
48
  async function bindCustomer(scope, customerId, subjectId) {
50
- const key = hash([...merchant(scope), identifier(customerId)]);
49
+ const key = fingerprint([...merchant(scope), identifier(customerId)]);
51
50
  identifier(subjectId);
52
51
  await knex.transaction(async (trx) => {
53
52
  await trx("payment_customers").insert({ customer_key: key, subject_id: subjectId })
54
53
  .onConflict("customer_key").merge({ customer_key: key });
55
54
  const row = await trx("payment_customers").where({ customer_key: key }).first();
56
- if (row.subject_id !== subjectId) throw new Error("This provider customer is already bound to a different billable subject.");
55
+ if (row.subject_id !== subjectId) throw new PaymentError("payment_customer_conflict", "This provider customer is already bound to a different billable subject.", 409);
57
56
  });
58
57
  }
59
58
  async function resolveCustomer(scope, customerId) {
60
- const row = await knex("payment_customers").where({ customer_key: hash([...merchant(scope), identifier(customerId)]) }).first();
59
+ const row = await knex("payment_customers").where({ customer_key: fingerprint([...merchant(scope), identifier(customerId)]) }).first();
61
60
  return row?.subject_id ?? null;
62
61
  }
63
62
  async function inspectCatalogue(scope) {
64
- const row = await knex("payment_catalogues").where({ catalogue_key: hash(merchant(scope)) }).first();
63
+ const row = await knex("payment_catalogues").where({ catalogue_key: fingerprint(merchant(scope)) }).first();
65
64
  return row ? JSON.parse(row.payload) : { plans: {}, history: [] };
66
65
  }
67
66
  async function withCatalogue(scope, work) {
68
- const key = hash(merchant(scope));
67
+ const key = fingerprint(merchant(scope));
69
68
  return knex.transaction(async (trx) => {
70
69
  await trx("payment_catalogues").insert({ catalogue_key: key, payload: JSON.stringify({ plans: {}, history: [] }) })
71
70
  .onConflict("catalogue_key").merge({ catalogue_key: key });
@@ -1,20 +1,16 @@
1
1
  import { Paddle, Environment } from "@paddle/paddle-node-sdk";
2
- import { PaymentError } from "./service.js";
2
+ import { PaymentError } from "./errors.js";
3
+ import { createPriceBindings } from "./priceBindings.js";
3
4
 
4
- 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
+ };
5
10
 
6
11
  function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceBindings, taxCategory, historicalPriceBindings = {}, client = new Paddle(apiKey, { environment: environment === "live" ? Environment.production : Environment.sandbox }) }) {
7
12
  if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret) throw new TypeError("Supply Paddle credentials and environment.");
8
- const prices = structuredClone(priceBindings);
9
- const planFor = (priceId) => {
10
- const matches = Object.entries(prices).filter(([, value]) => value === priceId);
11
- if (!matches.length && Object.hasOwn(historicalPriceBindings, priceId)) return historicalPriceBindings[priceId];
12
- if (matches.length !== 1) invalid();
13
- return matches[0][0];
14
- };
15
- function validatePlan(planId) {
16
- if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
17
- }
13
+ const { validatePlan, priceFor, planFor } = createPriceBindings({ priceBindings, historicalPriceBindings, invalid });
18
14
  async function verifyAccount() {
19
15
  await client.products.list({ perPage: 1 }).next();
20
16
  // A successful catalogue read cannot prove merchant verification or identify
@@ -22,9 +18,11 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
22
18
  return { environment, credentialsVerified: true, accountIdentityVerified: false, merchantApproval: "manual-check-required" };
23
19
  }
24
20
  async function verifyEvent(rawBody, signature) {
25
- if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400);
21
+ if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400, { messageCode: "payments.webhook.rawBodyRequired" });
26
22
  try { return await client.webhooks.unmarshal(rawBody.toString("utf8"), webhookSecret, signature); }
27
- catch { throw new PaymentError("payment_signature_invalid", "Paddle webhook verification failed.", 400); }
23
+ catch { throw new PaymentError("payment_signature_invalid", "Paddle webhook verification failed.", 400, {
24
+ messageCode: "payments.webhook.verificationFailed", messageParams: { provider: "Paddle" }
25
+ }); }
28
26
  }
29
27
  function eventIdentity(event) {
30
28
  if (event.eventType === "transaction.completed") {
@@ -61,10 +59,10 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
61
59
  return customer.id;
62
60
  }
63
61
  async function createCheckout({ customerId, planId, returnUrl }) {
64
- if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
62
+ const priceId = priceFor(planId);
65
63
  // This URL is an app-owned Paddle.js checkout page, approved in Paddle.
66
64
  const result = await client.transactions.create({ customerId, collectionMode: "automatic",
67
- items: [{ priceId: prices[planId], quantity: 1 }], checkout: { url: returnUrl } });
65
+ items: [{ priceId, quantity: 1 }], checkout: { url: returnUrl } });
68
66
  if (typeof result.checkout?.url !== "string") invalid();
69
67
  return { id: result.id, url: result.checkout.url };
70
68
  }
@@ -73,7 +71,7 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
73
71
  return { id: result.id, url: result.urls.general.overview };
74
72
  }
75
73
  async function readHistory({ customerId, collection, after = null }) {
76
- if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.");
74
+ if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.", 422, { messageCode: "payments.input.historyCollection" });
77
75
  const query = { customerId: [customerId], perPage: 20, ...(after ? { after } : {}) };
78
76
  const page = collection === "subscriptions" ? client.subscriptions.list(query) : client.transactions.list(query);
79
77
  // Fetch one page only; never iterate the SDK collection over the account.
@@ -97,7 +95,9 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
97
95
  return { id: value.id, name: value.name };
98
96
  }
99
97
  async function createProduct({ name }) {
100
- if (!taxCategory) throw new PaymentError("payment_configuration_invalid", "Set the application's Paddle product tax category before publishing.");
98
+ if (!taxCategory) throw new PaymentError("payment_configuration_invalid", "Set the application's Paddle product tax category before publishing.", 422, {
99
+ messageCode: "payments.configuration.taxCategoryUnpublishable"
100
+ });
101
101
  return client.products.create({ name, taxCategory });
102
102
  }
103
103
  async function renameProduct({ id, name }) {
@@ -0,0 +1,23 @@
1
+ import { PaymentError } from "./errors.js";
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.
5
+ function createPriceBindings({ priceBindings, historicalPriceBindings = {}, invalid }) {
6
+ const prices = structuredClone(priceBindings);
7
+ function validatePlan(planId) {
8
+ if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
9
+ }
10
+ function priceFor(planId) {
11
+ validatePlan(planId);
12
+ return prices[planId];
13
+ }
14
+ function planFor(priceId) {
15
+ const matches = Object.entries(prices).filter(([, value]) => value === priceId);
16
+ if (!matches.length && Object.hasOwn(historicalPriceBindings, priceId)) return historicalPriceBindings[priceId];
17
+ if (matches.length !== 1) invalid();
18
+ return matches[0][0];
19
+ }
20
+ return { validatePlan, priceFor, planFor };
21
+ }
22
+
23
+ export { createPriceBindings };
@@ -1,4 +1,18 @@
1
- import { PaymentError } from "./service.js";
1
+ import { PaymentError } from "./errors.js";
2
+
3
+ const DEFAULT_CHECKS = Object.freeze([
4
+ { id: "credentials", status: "unknown", detail: "Check the provider credentials for this environment." },
5
+ { id: "account", status: "unknown", detail: "Confirm the configured merchant owns these credentials." },
6
+ { id: "charges", status: "manual", detail: "Confirm payment acceptance is enabled in the provider dashboard." },
7
+ { id: "payouts", status: "manual", detail: "Confirm payout eligibility in the provider dashboard." },
8
+ { id: "catalogue", status: "unknown", detail: "Inspect the saved products and prices." },
9
+ { id: "webhook", status: "unknown", detail: "Verify the deployed webhook route, signing secret and subscribed events." },
10
+ { id: "checkout", status: "unknown", detail: "Verify app checkout and portal routes, authorization and return handling." },
11
+ { id: "site", status: "manual", detail: "Review required site content and provider domain or website approval." },
12
+ { id: "deployment", status: "unknown", detail: "Verify the intended release uses this configuration and environment." }
13
+ ]);
14
+ // Only these checks take their result from the application's own evidence.
15
+ const APPLICATION_CHECKS = ["webhook", "checkout", "site", "deployment"];
2
16
 
3
17
  // App-server inspection, invoked only after the host authorizes management.
4
18
  // Missing app evidence stays unknown; provider errors never become display text.
@@ -6,56 +20,50 @@ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication
6
20
  const merchant = structuredClone(scope);
7
21
  if (!["sandbox", "live"].includes(merchant?.environment) || !merchant.providerAccountId) throw new TypeError("Supply the configured payment merchant scope.");
8
22
  async function inspect() {
9
- const checks = [
10
- { id: "credentials", status: "unknown", detail: "Check the provider credentials for this environment." },
11
- { id: "account", status: "unknown", detail: "Confirm the configured merchant owns these credentials." },
12
- { id: "charges", status: "manual", detail: "Confirm payment acceptance is enabled in the provider dashboard." },
13
- { id: "payouts", status: "manual", detail: "Confirm payout eligibility in the provider dashboard." },
14
- { id: "catalogue", status: "unknown", detail: "Inspect the saved products and prices." },
15
- { id: "webhook", status: "unknown", detail: "Verify the deployed webhook route, signing secret and subscribed events." },
16
- { id: "checkout", status: "unknown", detail: "Verify app checkout and portal routes, authorization and return handling." },
17
- { id: "site", status: "manual", detail: "Review required site content and provider domain or website approval." },
18
- { id: "deployment", status: "unknown", detail: "Verify the intended release uses this configuration and environment." }
19
- ];
23
+ const checks = new Map(DEFAULT_CHECKS.map((check) => [check.id, { ...check }]));
24
+ const set = (id, status, detail) => Object.assign(checks.get(id), { status, detail });
20
25
  let provider;
21
26
  try { provider = await adapter.verifyAccount(); }
22
- catch { checks[0] = { id: "credentials", status: "failed", detail: "Provider verification failed. Check the account, credentials and environment." }; }
27
+ catch { set("credentials", "failed", "Provider verification failed. Check the account, credentials and environment."); }
23
28
  if (provider) {
24
29
  if (provider.environment !== merchant.environment || (provider.accountId && provider.accountId !== merchant.providerAccountId)) {
25
- throw new PaymentError("payment_scope_invalid", "Readiness returned a different payment account or environment.", 403);
30
+ throw new PaymentError("payment_scope_invalid", "Readiness returned a different payment account or environment.", 403, {
31
+ messageCode: "payments.scope.readinessMismatch"
32
+ });
26
33
  }
27
34
  const verified = provider.credentialsVerified === true || provider.accountId === merchant.providerAccountId;
28
- checks[0] = { id: "credentials", status: verified ? "passed" : "unknown", detail: verified
29
- ? "The provider accepted credential verification for this environment." : "The provider returned no affirmative credential verification." };
30
- checks[1] = { id: "account", status: provider.accountId === merchant.providerAccountId ? "passed" : "manual",
31
- detail: provider.accountId === merchant.providerAccountId ? "The provider account matches the saved merchant identity." : "This provider check does not establish merchant identity. Confirm it in the provider dashboard." };
32
- for (const [index, property] of [[2, "chargesEnabled"], [3, "payoutsEnabled"]]) {
33
- if (typeof provider[property] === "boolean") checks[index] = { id: checks[index].id, status: provider[property] ? "passed" : "failed",
34
- detail: `The provider reports ${checks[index].id} ${provider[property] ? "enabled" : "not enabled"}.` };
35
+ set("credentials", verified ? "passed" : "unknown", verified
36
+ ? "The provider accepted credential verification for this environment." : "The provider returned no affirmative credential verification.");
37
+ const accountMatches = provider.accountId === merchant.providerAccountId;
38
+ set("account", accountMatches ? "passed" : "manual", accountMatches
39
+ ? "The provider account matches the saved merchant identity." : "This provider check does not establish merchant identity. Confirm it in the provider dashboard.");
40
+ for (const [id, property] of [["charges", "chargesEnabled"], ["payouts", "payoutsEnabled"]]) {
41
+ if (typeof provider[property] === "boolean") {
42
+ set(id, provider[property] ? "passed" : "failed", `The provider reports ${id} ${provider[property] ? "enabled" : "not enabled"}.`);
43
+ }
35
44
  }
36
45
  try {
37
46
  const review = await catalogue.preview();
38
47
  if (review.environment !== merchant.environment || review.providerAccountId !== merchant.providerAccountId) throw new Error("scope");
39
48
  const unresolved = Boolean(review.pending || review.changes.length || review.drift.length || review.removed.length);
40
- checks[4] = { id: "catalogue", status: unresolved ? "failed" : "passed",
41
- detail: unresolved ? "Catalogue changes, drift, removed plans or an unresolved write need review." : "Saved plans match their published provider products and prices." };
42
- } catch { checks[4] = { id: "catalogue", status: "failed", detail: "Catalogue inspection failed. Review the application's catalogue command and provider access." }; }
49
+ set("catalogue", unresolved ? "failed" : "passed",
50
+ unresolved ? "Catalogue changes, drift, removed plans or an unresolved write need review." : "Saved plans match their published provider products and prices.");
51
+ } catch { set("catalogue", "failed", "Catalogue inspection failed. Review the application's catalogue command and provider access."); }
43
52
  }
44
53
  if (inspectApplication) {
45
54
  let evidence;
46
55
  try { evidence = await inspectApplication(); } catch { evidence = {}; }
47
- for (const check of checks.slice(5)) {
48
- const value = evidence?.[check.id];
56
+ for (const id of APPLICATION_CHECKS) {
57
+ const value = evidence?.[id];
49
58
  if (value === undefined) continue;
50
59
  if (!value || !["passed", "failed", "unknown", "manual"].includes(value.status) || typeof value.detail !== "string" ||
51
60
  !value.detail.trim() || value.detail.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.detail)) {
52
61
  throw new PaymentError("payment_readiness_invalid", "App readiness checks must return bounded status and safe explanatory text.");
53
62
  }
54
- check.status = value.status;
55
- check.detail = value.detail;
63
+ set(id, value.status, value.detail);
56
64
  }
57
65
  }
58
- return { paymentEnvironment: merchant.environment, providerAccountId: merchant.providerAccountId, checks };
66
+ return { paymentEnvironment: merchant.environment, providerAccountId: merchant.providerAccountId, checks: [...checks.values()] };
59
67
  }
60
68
  return Object.freeze({ inspect });
61
69
  }
@@ -1,26 +1,18 @@
1
- import { createHash } from "node:crypto";
1
+ import { PaymentError, fingerprint } from "./errors.js";
2
2
 
3
- class PaymentError extends Error {
4
- constructor(code, message, statusCode = 422) {
5
- super(message);
6
- this.code = code;
7
- this.statusCode = statusCode;
8
- }
9
- }
10
- 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); };
11
4
  function reference(value) {
12
- 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" });
13
6
  return value;
14
7
  }
15
8
  function units(value) {
16
- 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" });
17
10
  return value;
18
11
  }
19
12
  function timestamp(value) {
20
- 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" });
21
14
  return value;
22
15
  }
23
- const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
24
16
  const unexpired = (lot, now) => lot.expiresAt === null || lot.expiresAt > now;
25
17
  const available = (lots, now) => lots.reduce((sum, lot) => sum + (unexpired(lot, now) ? lot.remaining : 0), 0);
26
18
 
@@ -28,7 +20,7 @@ async function once(tx, key, input, work) {
28
20
  const digest = fingerprint(input);
29
21
  const previous = await tx.find(key);
30
22
  if (previous) {
31
- 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" });
32
24
  return { ...previous.result, duplicate: true };
33
25
  }
34
26
  const result = await work();
@@ -56,7 +48,9 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
56
48
  function checkScope(scope) {
57
49
  const expected = environments[scope?.environment];
58
50
  if (!expected || expected.integrationId !== scope.integrationId || expected.providerAccountId !== scope.providerAccountId) {
59
- fail("payment_scope_invalid", "The payment scope does not match this environment's merchant configuration.", 403);
51
+ fail("payment_scope_invalid", "The payment scope does not match this environment's merchant configuration.", 403, {
52
+ messageCode: "payments.scope.merchantMismatch"
53
+ });
60
54
  }
61
55
  }
62
56
 
@@ -136,7 +130,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
136
130
  const { subscription, renewal } = await load();
137
131
  reference(subscription?.id);
138
132
  if (!["active", "trialing", "past_due", "paused", "canceled", "unpaid", "incomplete", "incomplete_expired"].includes(subscription.status) ||
139
- !Object.hasOwn(plans, subscription.planId)) fail("payment_provider_result_invalid", "Check the provider subscription and logical price binding.", 502);
133
+ !Object.hasOwn(plans, subscription.planId)) fail("payment_provider_result_invalid", "Check the provider subscription and logical price binding.", 502, { messageCode: "payments.provider.subscriptionBinding" });
140
134
  timestamp(subscription.periodEnd);
141
135
  tx.state.subscriptions[subscription.id] = {
142
136
  id: subscription.id, status: subscription.status, planId: subscription.planId, periodEnd: subscription.periodEnd
@@ -145,7 +139,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
145
139
  if (renewal) {
146
140
  reference(renewal.id);
147
141
  timestamp(renewal.periodEnd);
148
- if (!Object.hasOwn(plans, renewal.planId)) fail("payment_provider_result_invalid", "Check the renewal's logical price binding.", 502);
142
+ if (!Object.hasOwn(plans, renewal.planId)) fail("payment_provider_result_invalid", "Check the renewal's logical price binding.", 502, { messageCode: "payments.provider.renewalBinding" });
149
143
  const quantity = plans[renewal.planId].renewalCredits;
150
144
  // A provider invoice/transaction is the business key, not its delivery ID.
151
145
  if (quantity) creditGrant = await grant(tx, { reference: `renewal:${renewal.id}`, units: quantity, expiresAt: renewal.periodEnd }, timestamp(clock()));
@@ -1,22 +1,18 @@
1
1
  import Stripe from "stripe";
2
- import { PaymentError } from "./service.js";
2
+ import { PaymentError } from "./errors.js";
3
+ import { createPriceBindings } from "./priceBindings.js";
3
4
 
4
5
  const id = (value) => typeof value === "string" ? value : value?.id;
5
- 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
+ };
6
11
 
7
12
  function createStripePaymentAdapter({ apiKey, webhookSecret, environment, providerAccountId, priceBindings, historicalPriceBindings = {}, client = new Stripe(apiKey, { maxNetworkRetries: 0 }) }) {
8
13
  if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret || !providerAccountId) throw new TypeError("Supply Stripe credentials, environment and account identity.");
9
14
  const live = environment === "live";
10
- const prices = structuredClone(priceBindings);
11
- const planFor = (priceId) => {
12
- const matches = Object.entries(prices).filter(([, value]) => value === priceId);
13
- if (!matches.length && Object.hasOwn(historicalPriceBindings, priceId)) return historicalPriceBindings[priceId];
14
- if (matches.length !== 1) invalid();
15
- return matches[0][0];
16
- };
17
- function validatePlan(planId) {
18
- if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
19
- }
15
+ const { validatePlan, priceFor, planFor } = createPriceBindings({ priceBindings, historicalPriceBindings, invalid });
20
16
  async function verifyAccount() {
21
17
  const account = await client.accounts.retrieve();
22
18
  const balance = await client.balance.retrieve();
@@ -24,10 +20,12 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
24
20
  return { accountId: account.id, environment, chargesEnabled: account.charges_enabled === true, payoutsEnabled: account.payouts_enabled === true };
25
21
  }
26
22
  async function verifyEvent(rawBody, signature) {
27
- if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400);
23
+ if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400, { messageCode: "payments.webhook.rawBodyRequired" });
28
24
  let event;
29
25
  try { event = client.webhooks.constructEvent(rawBody, signature, webhookSecret); }
30
- catch { throw new PaymentError("payment_signature_invalid", "Stripe webhook verification failed.", 400); }
26
+ catch { throw new PaymentError("payment_signature_invalid", "Stripe webhook verification failed.", 400, {
27
+ messageCode: "payments.webhook.verificationFailed", messageParams: { provider: "Stripe" }
28
+ }); }
31
29
  if (event.livemode !== live || (event.account && event.account !== providerAccountId)) invalid();
32
30
  return event;
33
31
  }
@@ -76,10 +74,10 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
76
74
  return customer.id;
77
75
  }
78
76
  async function createCheckout({ customerId, planId, returnUrl, requestId }) {
79
- if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
77
+ const price = priceFor(planId);
80
78
  await verifyAccount();
81
79
  const result = await client.checkout.sessions.create({ mode: "subscription", customer: customerId,
82
- line_items: [{ price: prices[planId], quantity: 1 }], success_url: returnUrl, cancel_url: returnUrl
80
+ line_items: [{ price, quantity: 1 }], success_url: returnUrl, cancel_url: returnUrl
83
81
  }, { idempotencyKey: requestId });
84
82
  if (typeof result.url !== "string") invalid();
85
83
  return { id: result.id, url: result.url };
@@ -90,7 +88,7 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
90
88
  return { id: result.id, url: result.url };
91
89
  }
92
90
  async function readHistory({ customerId, collection, after = null }) {
93
- if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.");
91
+ if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.", 422, { messageCode: "payments.input.historyCollection" });
94
92
  await verifyAccount();
95
93
  const query = { customer: customerId, limit: 20, ...(after ? { starting_after: after } : {}) };
96
94
  const page = collection === "subscriptions"
@@ -3,36 +3,48 @@ 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
+ // Each JSKIT field error also has a code and params; the error's messageCode names its sentence.
7
+ function configurationError(message, fieldErrors, { messageCode, messageParams = {} }) {
8
+ return Object.assign(new Error(message), {
9
+ code: "payment_configuration_invalid", statusCode: 422, fieldErrors, messageCode, messageParams
10
+ });
11
+ }
12
+
6
13
  function validatePaymentConfiguration(document) {
7
14
  const value = document?.extensions?.payments;
8
15
  if (!validate(value)) {
9
- const error = new Error("Check the application's payment configuration.");
10
- error.code = "payment_configuration_invalid";
11
- error.statusCode = 422;
12
- error.fieldErrors = validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message }));
13
- throw error;
16
+ throw configurationError("Check the application's payment configuration.",
17
+ validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message })),
18
+ { messageCode: "payments.configuration.invalid" });
14
19
  }
20
+ // Report every environment's binding problems at once, like the schema stage.
21
+ const fieldErrors = [];
15
22
  for (const [environment, binding] of Object.entries(value.environments)) {
16
23
  const slot = document.integrations?.[binding.integrationId];
17
24
  const path = `extensions.payments/environments/${environment}`;
18
- const fieldErrors = [];
19
25
  if (!slot || !["stripe", "paddle"].includes(slot.provider) || slot.accountMode !== "shared" ||
20
26
  slot.authentication?.method !== "api-key" || !/^env:[A-Z_][A-Z0-9_]*$/.test(slot.authentication.secretRef || "")) {
21
- fieldErrors.push({ path: `${path}/integrationId`, message: `Select a shared Stripe or Paddle connection with a backend API-key Env reference for ${environment}.` });
27
+ fieldErrors.push({ path: `${path}/integrationId`, message: `Select a shared Stripe or Paddle connection with a backend API-key Env reference for ${environment}.`,
28
+ code: "payments.configuration.sharedConnectionRequired", params: { environment } });
22
29
  }
23
30
  if (slot?.provider === "paddle") {
24
- if (slot.settings?.environment !== environment) fieldErrors.push({ path: `${path}/integrationId`, message: `Select a Paddle connection configured for ${environment}.` });
25
- if (!binding.taxCategory) fieldErrors.push({ path: `${path}/taxCategory`, message: "Choose the Paddle tax category for the products this app sells." });
26
- if (!binding.publicClientTokenRef) fieldErrors.push({ path: `${path}/publicClientTokenRef`, message: "Set the Env reference for this environment's Paddle public client token." });
27
- }
28
- if (fieldErrors.length) {
29
- const error = new Error(fieldErrors[0].message);
30
- error.code = "payment_configuration_invalid";
31
- error.statusCode = 422;
32
- error.fieldErrors = fieldErrors;
33
- throw error;
31
+ if (slot.settings?.environment !== environment) {
32
+ fieldErrors.push({ path: `${path}/integrationId`, message: `Select a Paddle connection configured for ${environment}.`,
33
+ code: "payments.configuration.paddleEnvironment", params: { environment } });
34
+ }
35
+ if (!binding.taxCategory) {
36
+ fieldErrors.push({ path: `${path}/taxCategory`, message: "Choose the Paddle tax category for the products this app sells.",
37
+ code: "payments.configuration.paddleTaxCategory", params: {} });
38
+ }
39
+ if (!binding.publicClientTokenRef) {
40
+ fieldErrors.push({ path: `${path}/publicClientTokenRef`, message: "Set the Env reference for this environment's Paddle public client token.",
41
+ code: "payments.configuration.paddleClientToken", params: {} });
42
+ }
34
43
  }
35
44
  }
45
+ if (fieldErrors.length) throw configurationError(fieldErrors[0].message, fieldErrors, {
46
+ messageCode: fieldErrors[0].code, messageParams: fieldErrors[0].params
47
+ });
36
48
  // The validated declaration is JSON data; callers may supply reactive proxies.
37
49
  return JSON.parse(JSON.stringify(value));
38
50
  }
@@ -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
+ });
@@ -36,6 +36,21 @@ test("portable configuration rejects secret literals, invalid prices and mismatc
36
36
  }
37
37
  });
38
38
 
39
+ test("configuration reports binding problems in every environment at once", () => {
40
+ const value = structuredClone(document);
41
+ value.integrations.billing.accountMode = "per-user";
42
+ value.integrations.production.authentication.secretRef = "sk_live_pasted";
43
+ assert.throws(() => validatePaymentConfiguration(value), (error) => {
44
+ assert.equal(error.code, "payment_configuration_invalid");
45
+ assert.equal(error.statusCode, 422);
46
+ assert.deepEqual(error.fieldErrors.map(({ path }) => path), [
47
+ "extensions.payments/environments/sandbox/integrationId", "extensions.payments/environments/live/integrationId"
48
+ ]);
49
+ assert.equal(error.message, error.fieldErrors[0].message);
50
+ return true;
51
+ });
52
+ });
53
+
39
54
  test("configuration accepts reactive objects and returns an independent plain snapshot", () => {
40
55
  const input = structuredClone(document);
41
56
  input.extensions.payments = new Proxy(input.extensions.payments, {});
@@ -84,7 +99,7 @@ test("transactional payment state isolates subjects, grants once, prevents overs
84
99
  });
85
100
  await t.test("customer bindings cannot be stolen or resolved across environments", async () => {
86
101
  await store.bindCustomer(scope, "cus_a", "tenant-a");
87
- await assert.rejects(store.bindCustomer(scope, "cus_a", "tenant-b"), /already bound/);
102
+ await assert.rejects(store.bindCustomer(scope, "cus_a", "tenant-b"), { name: "PaymentError", code: "payment_customer_conflict", statusCode: 409, message: /already bound/ });
88
103
  await assert.rejects(service.reconcileEvent(scope, { eventId: "evt_unbound", customerId: "cus_b", load: async () => {} }), { code: "payment_customer_unbound" });
89
104
  });
90
105
  await t.test("event failure rolls back; retries grant once per renewal rather than event", async () => {