@jskit-ai/payments-core 0.2.0 → 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/docs/conformance.md +4 -4
- package/docs/standalone.md +1 -1
- package/package.json +2 -1
- package/src/server/catalogue.js +14 -3
- package/src/server/checkout.js +21 -3
- package/src/server/knexStore.js +13 -2
- package/src/server/paddle.js +27 -1
- package/src/server/priceBindings.js +13 -3
- package/src/server/readiness.js +21 -14
- package/src/server/service.js +21 -4
- package/src/server/stripe.js +28 -1
- package/src/server/types.js +105 -0
- package/src/shared/configuration.js +21 -7
- package/test/standalone.test.js +1 -1
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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/payments-core",
|
|
3
|
-
"version": "0.2.
|
|
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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"./server/paddle": "./src/server/paddle.js",
|
|
17
17
|
"./server/catalogue": "./src/server/catalogue.js",
|
|
18
18
|
"./server/readiness": "./src/server/readiness.js",
|
|
19
|
+
"./server/types": "./src/server/types.js",
|
|
19
20
|
"./locales/en.json": "./locales/en.json"
|
|
20
21
|
},
|
|
21
22
|
"scripts": {
|
package/src/server/catalogue.js
CHANGED
|
@@ -4,8 +4,15 @@ 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);
|
|
@@ -112,7 +119,11 @@ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
|
|
|
112
119
|
await finish(pending, { id: providerId });
|
|
113
120
|
return { pending: false };
|
|
114
121
|
}
|
|
115
|
-
return Object.freeze({
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
preview,
|
|
124
|
+
publish,
|
|
125
|
+
recover
|
|
126
|
+
});
|
|
116
127
|
}
|
|
117
128
|
|
|
118
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) {
|
|
@@ -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;
|
|
@@ -125,7 +136,14 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
|
|
|
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/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
|
@@ -8,6 +8,17 @@ const invalid = () => {
|
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
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
|
|
11
22
|
function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceBindings, taxCategory, historicalPriceBindings = {}, client = new Paddle(apiKey, { environment: environment === "live" ? Environment.production : Environment.sandbox }) }) {
|
|
12
23
|
if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret) throw new TypeError("Supply Paddle credentials and environment.");
|
|
13
24
|
const { validatePlan, priceFor, planFor } = createPriceBindings({ priceBindings, historicalPriceBindings, invalid });
|
|
@@ -111,7 +122,22 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
|
|
|
111
122
|
async function createPrice({ productId, amount, currency, interval }) {
|
|
112
123
|
return client.prices.create({ productId, description: `${currency} ${amount} per ${interval}`, unitPrice: { amount: String(amount), currencyCode: currency }, billingCycle: { interval, frequency: 1 } });
|
|
113
124
|
}
|
|
114
|
-
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
|
+
});
|
|
115
141
|
}
|
|
116
142
|
|
|
117
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() {
|
|
@@ -50,18 +59,16 @@ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication
|
|
|
50
59
|
unresolved ? "Catalogue changes, drift, removed plans or an unresolved write need review." : "Saved plans match their published provider products and prices.");
|
|
51
60
|
} catch { set("catalogue", "failed", "Catalogue inspection failed. Review the application's catalogue command and provider access."); }
|
|
52
61
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
throw new PaymentError("payment_readiness_invalid", "App readiness checks must return bounded status and safe explanatory text.");
|
|
62
|
-
}
|
|
63
|
-
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.");
|
|
64
70
|
}
|
|
71
|
+
set(id, value.status, value.detail);
|
|
65
72
|
}
|
|
66
73
|
return { paymentEnvironment: merchant.environment, providerAccountId: merchant.providerAccountId, checks: [...checks.values()] };
|
|
67
74
|
}
|
package/src/server/service.js
CHANGED
|
@@ -39,10 +39,19 @@ 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) {
|
|
@@ -122,6 +131,7 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
|
|
|
122
131
|
async function reconcileEvent(merchantScope, { eventId, customerId, load }) {
|
|
123
132
|
checkScope(merchantScope);
|
|
124
133
|
reference(eventId); reference(customerId);
|
|
134
|
+
// readable-check: allow method-probe - input validation: the caller passes its authenticated provider reconciliation
|
|
125
135
|
if (typeof load !== "function") throw new TypeError("Supply an authenticated provider reconciliation function.");
|
|
126
136
|
const subjectId = await store.resolveCustomer(merchantScope, customerId);
|
|
127
137
|
if (!subjectId) fail("payment_customer_unbound", "Bind this provider customer before processing its events.", 409);
|
|
@@ -148,7 +158,14 @@ function createPaymentService({ store, configuration, clock = Date.now }) {
|
|
|
148
158
|
}));
|
|
149
159
|
}
|
|
150
160
|
|
|
151
|
-
return Object.freeze({
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
inspect,
|
|
163
|
+
requireFeature,
|
|
164
|
+
grantCredits,
|
|
165
|
+
debitCredits,
|
|
166
|
+
refundDebit,
|
|
167
|
+
reconcileEvent
|
|
168
|
+
});
|
|
152
169
|
}
|
|
153
170
|
|
|
154
171
|
export { createPaymentService, PaymentError };
|
package/src/server/stripe.js
CHANGED
|
@@ -9,6 +9,17 @@ const invalid = () => {
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
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
|
|
12
23
|
function createStripePaymentAdapter({ apiKey, webhookSecret, environment, providerAccountId, priceBindings, historicalPriceBindings = {}, client = new Stripe(apiKey, { maxNetworkRetries: 0 }) }) {
|
|
13
24
|
if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret || !providerAccountId) throw new TypeError("Supply Stripe credentials, environment and account identity.");
|
|
14
25
|
const live = environment === "live";
|
|
@@ -126,11 +137,27 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
|
|
|
126
137
|
if (value.recurring?.interval_count !== 1 || value.billing_scheme !== "per_unit") invalid();
|
|
127
138
|
return { id: value.id, productId: id(value.product), amount: value.unit_amount, currency: value.currency.toUpperCase(), interval: value.recurring.interval, active: value.active };
|
|
128
139
|
}
|
|
140
|
+
/** @param {{ productId: string, amount: number, currency: string, interval: string, requestId: string }} price */
|
|
129
141
|
async function createPrice({ productId, amount, currency, interval, requestId }) {
|
|
130
142
|
await verifyAccount();
|
|
131
143
|
return client.prices.create({ product: productId, unit_amount: amount, currency: currency.toLowerCase(), recurring: { interval } }, { idempotencyKey: requestId });
|
|
132
144
|
}
|
|
133
|
-
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
|
+
});
|
|
134
161
|
}
|
|
135
162
|
|
|
136
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,17 +3,31 @@ 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
}
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
function validatePaymentConfiguration(document) {
|
|
14
28
|
const value = document?.extensions?.payments;
|
|
15
29
|
if (!validate(value)) {
|
|
16
|
-
throw
|
|
30
|
+
throw new PaymentConfigurationError("Check the application's payment configuration.",
|
|
17
31
|
validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message })),
|
|
18
32
|
{ messageCode: "payments.configuration.invalid" });
|
|
19
33
|
}
|
|
@@ -42,7 +56,7 @@ function validatePaymentConfiguration(document) {
|
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
|
-
if (fieldErrors.length) throw
|
|
59
|
+
if (fieldErrors.length) throw new PaymentConfigurationError(fieldErrors[0].message, fieldErrors, {
|
|
46
60
|
messageCode: fieldErrors[0].code, messageParams: fieldErrors[0].params
|
|
47
61
|
});
|
|
48
62
|
// The validated declaration is JSON data; callers may supply reactive proxies.
|
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');
|