@jskit-ai/payments-core 0.1.62 → 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.62",
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"
@@ -11,7 +11,9 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
11
11
  const merchant = structuredClone(scope);
12
12
  const environment = configuration.environments[merchant.environment];
13
13
  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);
14
+ throw new PaymentError("payment_scope_invalid", "Catalogue scope does not match its configured environment.", 403, {
15
+ messageCode: "payments.scope.catalogueMismatch"
16
+ });
15
17
  }
16
18
  async function preview() {
17
19
  const state = await store.inspectCatalogue(merchant);
@@ -38,19 +40,19 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
38
40
  }
39
41
  async function publish({ reviewId }) {
40
42
  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);
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" });
42
44
  const completed = [];
43
45
  let expectedRevision = review.revision;
44
46
  for (const change of review.changes) {
45
47
  const token = randomUUID();
46
48
  const pending = await store.withCatalogue(merchant, async (state) => {
47
49
  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);
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" });
49
51
  const current = state.plans[change.planId];
50
52
  // 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);
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" });
54
56
  const operation = { ...change, token, productId: current?.productId };
55
57
  state.pending = operation;
56
58
  return operation;
@@ -70,9 +72,9 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
70
72
  return { completed, removed: review.removed };
71
73
  }
72
74
  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);
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" });
74
76
  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);
77
+ if (state.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending catalogue request changed.", 409, { messageCode: "payments.catalogue.pendingCatalogueRequestChanged" });
76
78
  const current = state.plans[pending.planId];
77
79
  if (pending.action === "create-product") state.plans[pending.planId] = { productId: result.id, name: pending.name };
78
80
  else if (pending.action === "rename-product") current.name = pending.name;
@@ -86,26 +88,26 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
86
88
  }
87
89
  async function recover({ reviewId, providerId, confirmedNotCreated = false }) {
88
90
  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);
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" });
90
92
  const pending = review.pending;
91
93
  if (!pending) return { pending: false };
92
94
  if (confirmedNotCreated) {
93
95
  // Trusted app administrator must supply actual provider inspection evidence.
94
96
  // This parameter must not be exposed as an unauthenticated browser command.
95
97
  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);
98
+ if (current.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending request changed.", 409, { messageCode: "payments.catalogue.pendingRequestChanged" });
97
99
  delete current.pending;
98
100
  });
99
101
  return { pending: false, retryAllowed: true };
100
102
  }
101
103
  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.");
104
+ throw new PaymentError("payment_input_invalid", "Supply the exact provider object ID to recover.", 422, { messageCode: "payments.input.recoveryObjectId" });
103
105
  }
104
106
  const result = pending.action === "create-price" ? await adapter.readPrice(providerId) : await adapter.readProduct(providerId);
105
107
  if (!result || result.id !== providerId || (pending.action === "create-price" && (result.active !== true || result.productId !== pending.productId || !samePrice(result, pending))) ||
106
108
  (pending.action !== "create-price" && result.name !== pending.name) ||
107
109
  (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);
110
+ throw new PaymentError("payment_catalogue_review_required", "The provider object does not match the pending operation.", 409, { messageCode: "payments.catalogue.objectMismatch" });
109
111
  }
110
112
  await finish(pending, { id: providerId });
111
113
  return { pending: false };
@@ -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,7 +42,7 @@ 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.");
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
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 }
@@ -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) =>
@@ -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
  }
@@ -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
 
@@ -2,7 +2,11 @@ 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
 
7
11
  function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceBindings, taxCategory, historicalPriceBindings = {}, client = new Paddle(apiKey, { environment: environment === "live" ? Environment.production : Environment.sandbox }) }) {
8
12
  if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret) throw new TypeError("Supply Paddle credentials and environment.");
@@ -14,9 +18,11 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
14
18
  return { environment, credentialsVerified: true, accountIdentityVerified: false, merchantApproval: "manual-check-required" };
15
19
  }
16
20
  async function verifyEvent(rawBody, signature) {
17
- 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" });
18
22
  try { return await client.webhooks.unmarshal(rawBody.toString("utf8"), webhookSecret, signature); }
19
- 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
+ }); }
20
26
  }
21
27
  function eventIdentity(event) {
22
28
  if (event.eventType === "transaction.completed") {
@@ -65,7 +71,7 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
65
71
  return { id: result.id, url: result.urls.general.overview };
66
72
  }
67
73
  async function readHistory({ customerId, collection, after = null }) {
68
- 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" });
69
75
  const query = { customerId: [customerId], perPage: 20, ...(after ? { after } : {}) };
70
76
  const page = collection === "subscriptions" ? client.subscriptions.list(query) : client.transactions.list(query);
71
77
  // Fetch one page only; never iterate the SDK collection over the account.
@@ -89,7 +95,9 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
89
95
  return { id: value.id, name: value.name };
90
96
  }
91
97
  async function createProduct({ name }) {
92
- 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
+ });
93
101
  return client.products.create({ name, taxCategory });
94
102
  }
95
103
  async function renameProduct({ id, name }) {
@@ -27,7 +27,9 @@ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication
27
27
  catch { set("credentials", "failed", "Provider verification failed. Check the account, credentials and environment."); }
28
28
  if (provider) {
29
29
  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);
30
+ throw new PaymentError("payment_scope_invalid", "Readiness returned a different payment account or environment.", 403, {
31
+ messageCode: "payments.scope.readinessMismatch"
32
+ });
31
33
  }
32
34
  const verified = provider.credentialsVerified === true || provider.accountId === merchant.providerAccountId;
33
35
  set("credentials", verified ? "passed" : "unknown", verified
@@ -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();
@@ -48,7 +48,9 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
48
48
  function checkScope(scope) {
49
49
  const expected = environments[scope?.environment];
50
50
  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);
51
+ fail("payment_scope_invalid", "The payment scope does not match this environment's merchant configuration.", 403, {
52
+ messageCode: "payments.scope.merchantMismatch"
53
+ });
52
54
  }
53
55
  }
54
56
 
@@ -128,7 +130,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
128
130
  const { subscription, renewal } = await load();
129
131
  reference(subscription?.id);
130
132
  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);
133
+ !Object.hasOwn(plans, subscription.planId)) fail("payment_provider_result_invalid", "Check the provider subscription and logical price binding.", 502, { messageCode: "payments.provider.subscriptionBinding" });
132
134
  timestamp(subscription.periodEnd);
133
135
  tx.state.subscriptions[subscription.id] = {
134
136
  id: subscription.id, status: subscription.status, planId: subscription.planId, periodEnd: subscription.periodEnd
@@ -137,7 +139,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
137
139
  if (renewal) {
138
140
  reference(renewal.id);
139
141
  timestamp(renewal.periodEnd);
140
- 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" });
141
143
  const quantity = plans[renewal.planId].renewalCredits;
142
144
  // A provider invoice/transaction is the business key, not its delivery ID.
143
145
  if (quantity) creditGrant = await grant(tx, { reference: `renewal:${renewal.id}`, units: quantity, expiresAt: renewal.periodEnd }, timestamp(clock()));
@@ -3,7 +3,11 @@ 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
 
8
12
  function createStripePaymentAdapter({ apiKey, webhookSecret, environment, providerAccountId, priceBindings, historicalPriceBindings = {}, client = new Stripe(apiKey, { maxNetworkRetries: 0 }) }) {
9
13
  if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret || !providerAccountId) throw new TypeError("Supply Stripe credentials, environment and account identity.");
@@ -16,10 +20,12 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
16
20
  return { accountId: account.id, environment, chargesEnabled: account.charges_enabled === true, payoutsEnabled: account.payouts_enabled === true };
17
21
  }
18
22
  async function verifyEvent(rawBody, signature) {
19
- 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" });
20
24
  let event;
21
25
  try { event = client.webhooks.constructEvent(rawBody, signature, webhookSecret); }
22
- 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
+ }); }
23
29
  if (event.livemode !== live || (event.account && event.account !== providerAccountId)) invalid();
24
30
  return event;
25
31
  }
@@ -82,7 +88,7 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
82
88
  return { id: result.id, url: result.url };
83
89
  }
84
90
  async function readHistory({ customerId, collection, after = null }) {
85
- 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" });
86
92
  await verifyAccount();
87
93
  const query = { customer: customerId, limit: 20, ...(after ? { starting_after: after } : {}) };
88
94
  const page = collection === "subscriptions"
@@ -3,15 +3,19 @@ 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
+ // 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
+ });
8
11
  }
9
12
 
10
13
  function validatePaymentConfiguration(document) {
11
14
  const value = document?.extensions?.payments;
12
15
  if (!validate(value)) {
13
16
  throw configurationError("Check the application's payment configuration.",
14
- validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message })));
17
+ validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message })),
18
+ { messageCode: "payments.configuration.invalid" });
15
19
  }
16
20
  // Report every environment's binding problems at once, like the schema stage.
17
21
  const fieldErrors = [];
@@ -20,15 +24,27 @@ function validatePaymentConfiguration(document) {
20
24
  const path = `extensions.payments/environments/${environment}`;
21
25
  if (!slot || !["stripe", "paddle"].includes(slot.provider) || slot.accountMode !== "shared" ||
22
26
  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}.` });
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 } });
24
29
  }
25
30
  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." });
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
+ }
29
43
  }
30
44
  }
31
- if (fieldErrors.length) throw configurationError(fieldErrors[0].message, fieldErrors);
45
+ if (fieldErrors.length) throw configurationError(fieldErrors[0].message, fieldErrors, {
46
+ messageCode: fieldErrors[0].code, messageParams: fieldErrors[0].params
47
+ });
32
48
  // The validated declaration is JSON data; callers may supply reactive proxies.
33
49
  return JSON.parse(JSON.stringify(value));
34
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
+ });