@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 +128 -0
- package/docs/contract.md +8 -0
- package/locales/en.json +77 -0
- package/package.json +3 -2
- package/src/server/catalogue.js +14 -12
- package/src/server/checkout.js +6 -6
- package/src/server/errors.js +7 -1
- package/src/server/paddle.js +13 -5
- package/src/server/readiness.js +3 -1
- package/src/server/service.js +10 -8
- package/src/server/stripe.js +10 -4
- package/src/shared/configuration.js +24 -8
- package/test/i18nCatalog.test.js +79 -0
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.
|
package/locales/en.json
ADDED
|
@@ -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.
|
|
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"
|
package/src/server/catalogue.js
CHANGED
|
@@ -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 };
|
package/src/server/checkout.js
CHANGED
|
@@ -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
|
}
|
package/src/server/errors.js
CHANGED
|
@@ -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
|
|
package/src/server/paddle.js
CHANGED
|
@@ -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 = () => {
|
|
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 }) {
|
package/src/server/readiness.js
CHANGED
|
@@ -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
|
package/src/server/service.js
CHANGED
|
@@ -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()));
|
package/src/server/stripe.js
CHANGED
|
@@ -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 = () => {
|
|
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
|
-
|
|
7
|
-
|
|
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)
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
});
|