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