@jskit-ai/payments-core 0.1.61 → 0.1.62
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/package.json +1 -1
- package/src/server/catalogue.js +2 -3
- package/src/server/checkout.js +4 -4
- package/src/server/errors.js +15 -0
- package/src/server/knexStore.js +9 -10
- package/src/server/paddle.js +5 -13
- package/src/server/priceBindings.js +23 -0
- package/src/server/readiness.js +34 -28
- package/src/server/service.js +1 -9
- package/src/server/stripe.js +5 -13
- package/src/shared/configuration.js +9 -13
- package/test/service.test.js +16 -1
package/package.json
CHANGED
package/src/server/catalogue.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { PaymentError } from "./
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { PaymentError, fingerprint } from "./errors.js";
|
|
3
3
|
|
|
4
|
-
const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
5
4
|
const priceFields = (plan) => ({ amount: plan.amount, currency: plan.currency, interval: plan.interval });
|
|
6
5
|
const samePrice = (a, b) => fingerprint(priceFields(a)) === fingerprint(priceFields(b));
|
|
7
6
|
|
package/src/server/checkout.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { PaymentError } from "./
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { PaymentError, fingerprint } from "./errors.js";
|
|
3
3
|
|
|
4
4
|
// Application composition boundary: authorization is mandatory and supplied by
|
|
5
5
|
// the app's framework. Actor and subject must come from its authenticated route.
|
|
@@ -43,7 +43,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
|
|
|
43
43
|
adapter.validatePlan(planId);
|
|
44
44
|
if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{1,100}$/.test(requestId)) throw new PaymentError("payment_request_invalid", "Supply a stable checkout request identifier.");
|
|
45
45
|
if (typeof email !== "string" || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new PaymentError("payment_input_invalid", "Supply the authenticated billing contact's email.");
|
|
46
|
-
const identity =
|
|
46
|
+
const identity = fingerprint(scope);
|
|
47
47
|
const customerId = await perform(scope, async (tx) => tx.state.customerId
|
|
48
48
|
? { cached: true, result: tx.state.customerId }
|
|
49
49
|
: { action: "customer", email },
|
|
@@ -62,7 +62,7 @@ function createPaymentCheckoutService({ adapter, store, payments, merchantScope,
|
|
|
62
62
|
}
|
|
63
63
|
return { action: "checkout", customerId, planId, requestId };
|
|
64
64
|
}, () => adapter.createCheckout({ customerId, planId, returnUrl: target.href,
|
|
65
|
-
requestId: `checkout-${
|
|
65
|
+
requestId: `checkout-${fingerprint([scope, requestId])}` }),
|
|
66
66
|
async (tx, result) => { await tx.record(`checkout:${requestId}`, { planId, result }); return result; });
|
|
67
67
|
}
|
|
68
68
|
async function account({ actor, subjectId }) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
class PaymentError extends Error {
|
|
4
|
+
constructor(code, message, statusCode = 422) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "PaymentError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.statusCode = statusCode;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Stable sha256 hex of a JSON value: idempotency digests, review IDs and store keys.
|
|
13
|
+
const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
14
|
+
|
|
15
|
+
export { PaymentError, fingerprint };
|
package/src/server/knexStore.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PaymentError, fingerprint } from "./errors.js";
|
|
2
2
|
|
|
3
|
-
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
4
3
|
const empty = () => ({ lots: [], subscriptions: {} });
|
|
5
4
|
|
|
6
5
|
function merchant(scope) {
|
|
@@ -15,7 +14,7 @@ function identifier(value) {
|
|
|
15
14
|
if (typeof value !== "string" || !value || value.length > 200) throw new TypeError("Payment identity is required (maximum 200 characters).");
|
|
16
15
|
return value;
|
|
17
16
|
}
|
|
18
|
-
const accountKey = (scope) =>
|
|
17
|
+
const accountKey = (scope) => fingerprint([...merchant(scope), identifier(scope.subjectId)]);
|
|
19
18
|
|
|
20
19
|
function createKnexPaymentStore({ knex }) {
|
|
21
20
|
if (typeof knex !== "function" || typeof knex.transaction !== "function") throw new TypeError("Supply the application's transactional Knex client.");
|
|
@@ -35,11 +34,11 @@ function createKnexPaymentStore({ knex }) {
|
|
|
35
34
|
const result = await work({
|
|
36
35
|
state,
|
|
37
36
|
async find(reference) {
|
|
38
|
-
const entry = await trx("payment_entries").where({ entry_key:
|
|
37
|
+
const entry = await trx("payment_entries").where({ entry_key: fingerprint([key, identifier(reference)]) }).first();
|
|
39
38
|
return entry ? JSON.parse(entry.payload) : null;
|
|
40
39
|
},
|
|
41
40
|
async record(reference, payload) {
|
|
42
|
-
await trx("payment_entries").insert({ entry_key:
|
|
41
|
+
await trx("payment_entries").insert({ entry_key: fingerprint([key, identifier(reference)]), account_key: key, payload: JSON.stringify(payload) });
|
|
43
42
|
}
|
|
44
43
|
});
|
|
45
44
|
await trx("payment_accounts").where({ account_key: key }).update({ payload: JSON.stringify(state) });
|
|
@@ -47,25 +46,25 @@ function createKnexPaymentStore({ knex }) {
|
|
|
47
46
|
});
|
|
48
47
|
}
|
|
49
48
|
async function bindCustomer(scope, customerId, subjectId) {
|
|
50
|
-
const key =
|
|
49
|
+
const key = fingerprint([...merchant(scope), identifier(customerId)]);
|
|
51
50
|
identifier(subjectId);
|
|
52
51
|
await knex.transaction(async (trx) => {
|
|
53
52
|
await trx("payment_customers").insert({ customer_key: key, subject_id: subjectId })
|
|
54
53
|
.onConflict("customer_key").merge({ customer_key: key });
|
|
55
54
|
const row = await trx("payment_customers").where({ customer_key: key }).first();
|
|
56
|
-
if (row.subject_id !== subjectId) throw new
|
|
55
|
+
if (row.subject_id !== subjectId) throw new PaymentError("payment_customer_conflict", "This provider customer is already bound to a different billable subject.", 409);
|
|
57
56
|
});
|
|
58
57
|
}
|
|
59
58
|
async function resolveCustomer(scope, customerId) {
|
|
60
|
-
const row = await knex("payment_customers").where({ customer_key:
|
|
59
|
+
const row = await knex("payment_customers").where({ customer_key: fingerprint([...merchant(scope), identifier(customerId)]) }).first();
|
|
61
60
|
return row?.subject_id ?? null;
|
|
62
61
|
}
|
|
63
62
|
async function inspectCatalogue(scope) {
|
|
64
|
-
const row = await knex("payment_catalogues").where({ catalogue_key:
|
|
63
|
+
const row = await knex("payment_catalogues").where({ catalogue_key: fingerprint(merchant(scope)) }).first();
|
|
65
64
|
return row ? JSON.parse(row.payload) : { plans: {}, history: [] };
|
|
66
65
|
}
|
|
67
66
|
async function withCatalogue(scope, work) {
|
|
68
|
-
const key =
|
|
67
|
+
const key = fingerprint(merchant(scope));
|
|
69
68
|
return knex.transaction(async (trx) => {
|
|
70
69
|
await trx("payment_catalogues").insert({ catalogue_key: key, payload: JSON.stringify({ plans: {}, history: [] }) })
|
|
71
70
|
.onConflict("catalogue_key").merge({ catalogue_key: key });
|
package/src/server/paddle.js
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { Paddle, Environment } from "@paddle/paddle-node-sdk";
|
|
2
|
-
import { PaymentError } from "./
|
|
2
|
+
import { PaymentError } from "./errors.js";
|
|
3
|
+
import { createPriceBindings } from "./priceBindings.js";
|
|
3
4
|
|
|
4
5
|
const invalid = () => { throw new PaymentError("payment_provider_result_invalid", "Check the Paddle account, price mapping and subscription shape.", 502); };
|
|
5
6
|
|
|
6
7
|
function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceBindings, taxCategory, historicalPriceBindings = {}, client = new Paddle(apiKey, { environment: environment === "live" ? Environment.production : Environment.sandbox }) }) {
|
|
7
8
|
if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret) throw new TypeError("Supply Paddle credentials and environment.");
|
|
8
|
-
const
|
|
9
|
-
const planFor = (priceId) => {
|
|
10
|
-
const matches = Object.entries(prices).filter(([, value]) => value === priceId);
|
|
11
|
-
if (!matches.length && Object.hasOwn(historicalPriceBindings, priceId)) return historicalPriceBindings[priceId];
|
|
12
|
-
if (matches.length !== 1) invalid();
|
|
13
|
-
return matches[0][0];
|
|
14
|
-
};
|
|
15
|
-
function validatePlan(planId) {
|
|
16
|
-
if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
|
|
17
|
-
}
|
|
9
|
+
const { validatePlan, priceFor, planFor } = createPriceBindings({ priceBindings, historicalPriceBindings, invalid });
|
|
18
10
|
async function verifyAccount() {
|
|
19
11
|
await client.products.list({ perPage: 1 }).next();
|
|
20
12
|
// A successful catalogue read cannot prove merchant verification or identify
|
|
@@ -61,10 +53,10 @@ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceB
|
|
|
61
53
|
return customer.id;
|
|
62
54
|
}
|
|
63
55
|
async function createCheckout({ customerId, planId, returnUrl }) {
|
|
64
|
-
|
|
56
|
+
const priceId = priceFor(planId);
|
|
65
57
|
// This URL is an app-owned Paddle.js checkout page, approved in Paddle.
|
|
66
58
|
const result = await client.transactions.create({ customerId, collectionMode: "automatic",
|
|
67
|
-
items: [{ priceId
|
|
59
|
+
items: [{ priceId, quantity: 1 }], checkout: { url: returnUrl } });
|
|
68
60
|
if (typeof result.checkout?.url !== "string") invalid();
|
|
69
61
|
return { id: result.id, url: result.checkout.url };
|
|
70
62
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PaymentError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
// Plan-to-price bindings shared by the provider adapters. `invalid` throws the
|
|
4
|
+
// adapter's own provider-result error for an unknown or ambiguous price.
|
|
5
|
+
function createPriceBindings({ priceBindings, historicalPriceBindings = {}, invalid }) {
|
|
6
|
+
const prices = structuredClone(priceBindings);
|
|
7
|
+
function validatePlan(planId) {
|
|
8
|
+
if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
|
|
9
|
+
}
|
|
10
|
+
function priceFor(planId) {
|
|
11
|
+
validatePlan(planId);
|
|
12
|
+
return prices[planId];
|
|
13
|
+
}
|
|
14
|
+
function planFor(priceId) {
|
|
15
|
+
const matches = Object.entries(prices).filter(([, value]) => value === priceId);
|
|
16
|
+
if (!matches.length && Object.hasOwn(historicalPriceBindings, priceId)) return historicalPriceBindings[priceId];
|
|
17
|
+
if (matches.length !== 1) invalid();
|
|
18
|
+
return matches[0][0];
|
|
19
|
+
}
|
|
20
|
+
return { validatePlan, priceFor, planFor };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { createPriceBindings };
|
package/src/server/readiness.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import { PaymentError } from "./
|
|
1
|
+
import { PaymentError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CHECKS = Object.freeze([
|
|
4
|
+
{ id: "credentials", status: "unknown", detail: "Check the provider credentials for this environment." },
|
|
5
|
+
{ id: "account", status: "unknown", detail: "Confirm the configured merchant owns these credentials." },
|
|
6
|
+
{ id: "charges", status: "manual", detail: "Confirm payment acceptance is enabled in the provider dashboard." },
|
|
7
|
+
{ id: "payouts", status: "manual", detail: "Confirm payout eligibility in the provider dashboard." },
|
|
8
|
+
{ id: "catalogue", status: "unknown", detail: "Inspect the saved products and prices." },
|
|
9
|
+
{ id: "webhook", status: "unknown", detail: "Verify the deployed webhook route, signing secret and subscribed events." },
|
|
10
|
+
{ id: "checkout", status: "unknown", detail: "Verify app checkout and portal routes, authorization and return handling." },
|
|
11
|
+
{ id: "site", status: "manual", detail: "Review required site content and provider domain or website approval." },
|
|
12
|
+
{ id: "deployment", status: "unknown", detail: "Verify the intended release uses this configuration and environment." }
|
|
13
|
+
]);
|
|
14
|
+
// Only these checks take their result from the application's own evidence.
|
|
15
|
+
const APPLICATION_CHECKS = ["webhook", "checkout", "site", "deployment"];
|
|
2
16
|
|
|
3
17
|
// App-server inspection, invoked only after the host authorizes management.
|
|
4
18
|
// Missing app evidence stays unknown; provider errors never become display text.
|
|
@@ -6,56 +20,48 @@ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication
|
|
|
6
20
|
const merchant = structuredClone(scope);
|
|
7
21
|
if (!["sandbox", "live"].includes(merchant?.environment) || !merchant.providerAccountId) throw new TypeError("Supply the configured payment merchant scope.");
|
|
8
22
|
async function inspect() {
|
|
9
|
-
const checks = [
|
|
10
|
-
|
|
11
|
-
{ id: "account", status: "unknown", detail: "Confirm the configured merchant owns these credentials." },
|
|
12
|
-
{ id: "charges", status: "manual", detail: "Confirm payment acceptance is enabled in the provider dashboard." },
|
|
13
|
-
{ id: "payouts", status: "manual", detail: "Confirm payout eligibility in the provider dashboard." },
|
|
14
|
-
{ id: "catalogue", status: "unknown", detail: "Inspect the saved products and prices." },
|
|
15
|
-
{ id: "webhook", status: "unknown", detail: "Verify the deployed webhook route, signing secret and subscribed events." },
|
|
16
|
-
{ id: "checkout", status: "unknown", detail: "Verify app checkout and portal routes, authorization and return handling." },
|
|
17
|
-
{ id: "site", status: "manual", detail: "Review required site content and provider domain or website approval." },
|
|
18
|
-
{ id: "deployment", status: "unknown", detail: "Verify the intended release uses this configuration and environment." }
|
|
19
|
-
];
|
|
23
|
+
const checks = new Map(DEFAULT_CHECKS.map((check) => [check.id, { ...check }]));
|
|
24
|
+
const set = (id, status, detail) => Object.assign(checks.get(id), { status, detail });
|
|
20
25
|
let provider;
|
|
21
26
|
try { provider = await adapter.verifyAccount(); }
|
|
22
|
-
catch {
|
|
27
|
+
catch { set("credentials", "failed", "Provider verification failed. Check the account, credentials and environment."); }
|
|
23
28
|
if (provider) {
|
|
24
29
|
if (provider.environment !== merchant.environment || (provider.accountId && provider.accountId !== merchant.providerAccountId)) {
|
|
25
30
|
throw new PaymentError("payment_scope_invalid", "Readiness returned a different payment account or environment.", 403);
|
|
26
31
|
}
|
|
27
32
|
const verified = provider.credentialsVerified === true || provider.accountId === merchant.providerAccountId;
|
|
28
|
-
|
|
29
|
-
? "The provider accepted credential verification for this environment." : "The provider returned no affirmative credential verification."
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
set("credentials", verified ? "passed" : "unknown", verified
|
|
34
|
+
? "The provider accepted credential verification for this environment." : "The provider returned no affirmative credential verification.");
|
|
35
|
+
const accountMatches = provider.accountId === merchant.providerAccountId;
|
|
36
|
+
set("account", accountMatches ? "passed" : "manual", accountMatches
|
|
37
|
+
? "The provider account matches the saved merchant identity." : "This provider check does not establish merchant identity. Confirm it in the provider dashboard.");
|
|
38
|
+
for (const [id, property] of [["charges", "chargesEnabled"], ["payouts", "payoutsEnabled"]]) {
|
|
39
|
+
if (typeof provider[property] === "boolean") {
|
|
40
|
+
set(id, provider[property] ? "passed" : "failed", `The provider reports ${id} ${provider[property] ? "enabled" : "not enabled"}.`);
|
|
41
|
+
}
|
|
35
42
|
}
|
|
36
43
|
try {
|
|
37
44
|
const review = await catalogue.preview();
|
|
38
45
|
if (review.environment !== merchant.environment || review.providerAccountId !== merchant.providerAccountId) throw new Error("scope");
|
|
39
46
|
const unresolved = Boolean(review.pending || review.changes.length || review.drift.length || review.removed.length);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} catch {
|
|
47
|
+
set("catalogue", unresolved ? "failed" : "passed",
|
|
48
|
+
unresolved ? "Catalogue changes, drift, removed plans or an unresolved write need review." : "Saved plans match their published provider products and prices.");
|
|
49
|
+
} catch { set("catalogue", "failed", "Catalogue inspection failed. Review the application's catalogue command and provider access."); }
|
|
43
50
|
}
|
|
44
51
|
if (inspectApplication) {
|
|
45
52
|
let evidence;
|
|
46
53
|
try { evidence = await inspectApplication(); } catch { evidence = {}; }
|
|
47
|
-
for (const
|
|
48
|
-
const value = evidence?.[
|
|
54
|
+
for (const id of APPLICATION_CHECKS) {
|
|
55
|
+
const value = evidence?.[id];
|
|
49
56
|
if (value === undefined) continue;
|
|
50
57
|
if (!value || !["passed", "failed", "unknown", "manual"].includes(value.status) || typeof value.detail !== "string" ||
|
|
51
58
|
!value.detail.trim() || value.detail.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.detail)) {
|
|
52
59
|
throw new PaymentError("payment_readiness_invalid", "App readiness checks must return bounded status and safe explanatory text.");
|
|
53
60
|
}
|
|
54
|
-
|
|
55
|
-
check.detail = value.detail;
|
|
61
|
+
set(id, value.status, value.detail);
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
|
-
return { paymentEnvironment: merchant.environment, providerAccountId: merchant.providerAccountId, checks };
|
|
64
|
+
return { paymentEnvironment: merchant.environment, providerAccountId: merchant.providerAccountId, checks: [...checks.values()] };
|
|
59
65
|
}
|
|
60
66
|
return Object.freeze({ inspect });
|
|
61
67
|
}
|
package/src/server/service.js
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PaymentError, fingerprint } from "./errors.js";
|
|
2
2
|
|
|
3
|
-
class PaymentError extends Error {
|
|
4
|
-
constructor(code, message, statusCode = 422) {
|
|
5
|
-
super(message);
|
|
6
|
-
this.code = code;
|
|
7
|
-
this.statusCode = statusCode;
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
3
|
const fail = (code, message, status) => { throw new PaymentError(code, message, status); };
|
|
11
4
|
function reference(value) {
|
|
12
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.");
|
|
@@ -20,7 +13,6 @@ function timestamp(value) {
|
|
|
20
13
|
if (!Number.isSafeInteger(value) || value < 0) fail("payment_input_invalid", "Use an integer UTC timestamp in milliseconds.");
|
|
21
14
|
return value;
|
|
22
15
|
}
|
|
23
|
-
const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
24
16
|
const unexpired = (lot, now) => lot.expiresAt === null || lot.expiresAt > now;
|
|
25
17
|
const available = (lots, now) => lots.reduce((sum, lot) => sum + (unexpired(lot, now) ? lot.remaining : 0), 0);
|
|
26
18
|
|
package/src/server/stripe.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Stripe from "stripe";
|
|
2
|
-
import { PaymentError } from "./
|
|
2
|
+
import { PaymentError } from "./errors.js";
|
|
3
|
+
import { createPriceBindings } from "./priceBindings.js";
|
|
3
4
|
|
|
4
5
|
const id = (value) => typeof value === "string" ? value : value?.id;
|
|
5
6
|
const invalid = () => { throw new PaymentError("payment_provider_result_invalid", "Check the Stripe account, price mapping and subscription shape.", 502); };
|
|
@@ -7,16 +8,7 @@ const invalid = () => { throw new PaymentError("payment_provider_result_invalid"
|
|
|
7
8
|
function createStripePaymentAdapter({ apiKey, webhookSecret, environment, providerAccountId, priceBindings, historicalPriceBindings = {}, client = new Stripe(apiKey, { maxNetworkRetries: 0 }) }) {
|
|
8
9
|
if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret || !providerAccountId) throw new TypeError("Supply Stripe credentials, environment and account identity.");
|
|
9
10
|
const live = environment === "live";
|
|
10
|
-
const
|
|
11
|
-
const planFor = (priceId) => {
|
|
12
|
-
const matches = Object.entries(prices).filter(([, value]) => value === priceId);
|
|
13
|
-
if (!matches.length && Object.hasOwn(historicalPriceBindings, priceId)) return historicalPriceBindings[priceId];
|
|
14
|
-
if (matches.length !== 1) invalid();
|
|
15
|
-
return matches[0][0];
|
|
16
|
-
};
|
|
17
|
-
function validatePlan(planId) {
|
|
18
|
-
if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
|
|
19
|
-
}
|
|
11
|
+
const { validatePlan, priceFor, planFor } = createPriceBindings({ priceBindings, historicalPriceBindings, invalid });
|
|
20
12
|
async function verifyAccount() {
|
|
21
13
|
const account = await client.accounts.retrieve();
|
|
22
14
|
const balance = await client.balance.retrieve();
|
|
@@ -76,10 +68,10 @@ function createStripePaymentAdapter({ apiKey, webhookSecret, environment, provid
|
|
|
76
68
|
return customer.id;
|
|
77
69
|
}
|
|
78
70
|
async function createCheckout({ customerId, planId, returnUrl, requestId }) {
|
|
79
|
-
|
|
71
|
+
const price = priceFor(planId);
|
|
80
72
|
await verifyAccount();
|
|
81
73
|
const result = await client.checkout.sessions.create({ mode: "subscription", customer: customerId,
|
|
82
|
-
line_items: [{ price
|
|
74
|
+
line_items: [{ price, quantity: 1 }], success_url: returnUrl, cancel_url: returnUrl
|
|
83
75
|
}, { idempotencyKey: requestId });
|
|
84
76
|
if (typeof result.url !== "string") invalid();
|
|
85
77
|
return { id: result.id, url: result.url };
|
|
@@ -3,19 +3,21 @@ import schema from "../../contracts/configuration.schema.json" with { type: "jso
|
|
|
3
3
|
|
|
4
4
|
const validate = new Ajv({ allErrors: true, strict: true }).compile(schema);
|
|
5
5
|
|
|
6
|
+
function configurationError(message, fieldErrors) {
|
|
7
|
+
return Object.assign(new Error(message), { code: "payment_configuration_invalid", statusCode: 422, fieldErrors });
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
function validatePaymentConfiguration(document) {
|
|
7
11
|
const value = document?.extensions?.payments;
|
|
8
12
|
if (!validate(value)) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
error.statusCode = 422;
|
|
12
|
-
error.fieldErrors = validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message }));
|
|
13
|
-
throw error;
|
|
13
|
+
throw configurationError("Check the application's payment configuration.",
|
|
14
|
+
validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message })));
|
|
14
15
|
}
|
|
16
|
+
// Report every environment's binding problems at once, like the schema stage.
|
|
17
|
+
const fieldErrors = [];
|
|
15
18
|
for (const [environment, binding] of Object.entries(value.environments)) {
|
|
16
19
|
const slot = document.integrations?.[binding.integrationId];
|
|
17
20
|
const path = `extensions.payments/environments/${environment}`;
|
|
18
|
-
const fieldErrors = [];
|
|
19
21
|
if (!slot || !["stripe", "paddle"].includes(slot.provider) || slot.accountMode !== "shared" ||
|
|
20
22
|
slot.authentication?.method !== "api-key" || !/^env:[A-Z_][A-Z0-9_]*$/.test(slot.authentication.secretRef || "")) {
|
|
21
23
|
fieldErrors.push({ path: `${path}/integrationId`, message: `Select a shared Stripe or Paddle connection with a backend API-key Env reference for ${environment}.` });
|
|
@@ -25,14 +27,8 @@ function validatePaymentConfiguration(document) {
|
|
|
25
27
|
if (!binding.taxCategory) fieldErrors.push({ path: `${path}/taxCategory`, message: "Choose the Paddle tax category for the products this app sells." });
|
|
26
28
|
if (!binding.publicClientTokenRef) fieldErrors.push({ path: `${path}/publicClientTokenRef`, message: "Set the Env reference for this environment's Paddle public client token." });
|
|
27
29
|
}
|
|
28
|
-
if (fieldErrors.length) {
|
|
29
|
-
const error = new Error(fieldErrors[0].message);
|
|
30
|
-
error.code = "payment_configuration_invalid";
|
|
31
|
-
error.statusCode = 422;
|
|
32
|
-
error.fieldErrors = fieldErrors;
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
30
|
}
|
|
31
|
+
if (fieldErrors.length) throw configurationError(fieldErrors[0].message, fieldErrors);
|
|
36
32
|
// The validated declaration is JSON data; callers may supply reactive proxies.
|
|
37
33
|
return JSON.parse(JSON.stringify(value));
|
|
38
34
|
}
|
package/test/service.test.js
CHANGED
|
@@ -36,6 +36,21 @@ test("portable configuration rejects secret literals, invalid prices and mismatc
|
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
test("configuration reports binding problems in every environment at once", () => {
|
|
40
|
+
const value = structuredClone(document);
|
|
41
|
+
value.integrations.billing.accountMode = "per-user";
|
|
42
|
+
value.integrations.production.authentication.secretRef = "sk_live_pasted";
|
|
43
|
+
assert.throws(() => validatePaymentConfiguration(value), (error) => {
|
|
44
|
+
assert.equal(error.code, "payment_configuration_invalid");
|
|
45
|
+
assert.equal(error.statusCode, 422);
|
|
46
|
+
assert.deepEqual(error.fieldErrors.map(({ path }) => path), [
|
|
47
|
+
"extensions.payments/environments/sandbox/integrationId", "extensions.payments/environments/live/integrationId"
|
|
48
|
+
]);
|
|
49
|
+
assert.equal(error.message, error.fieldErrors[0].message);
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
39
54
|
test("configuration accepts reactive objects and returns an independent plain snapshot", () => {
|
|
40
55
|
const input = structuredClone(document);
|
|
41
56
|
input.extensions.payments = new Proxy(input.extensions.payments, {});
|
|
@@ -84,7 +99,7 @@ test("transactional payment state isolates subjects, grants once, prevents overs
|
|
|
84
99
|
});
|
|
85
100
|
await t.test("customer bindings cannot be stolen or resolved across environments", async () => {
|
|
86
101
|
await store.bindCustomer(scope, "cus_a", "tenant-a");
|
|
87
|
-
await assert.rejects(store.bindCustomer(scope, "cus_a", "tenant-b"), /already bound/);
|
|
102
|
+
await assert.rejects(store.bindCustomer(scope, "cus_a", "tenant-b"), { name: "PaymentError", code: "payment_customer_conflict", statusCode: 409, message: /already bound/ });
|
|
88
103
|
await assert.rejects(service.reconcileEvent(scope, { eventId: "evt_unbound", customerId: "cus_b", load: async () => {} }), { code: "payment_customer_unbound" });
|
|
89
104
|
});
|
|
90
105
|
await t.test("event failure rolls back; retries grant once per renewal rather than event", async () => {
|