@jskit-ai/payments-core 0.1.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/README.md +147 -0
- package/contracts/configuration.schema.json +148 -0
- package/contracts/conformance.json +718 -0
- package/docs/conformance.md +91 -0
- package/docs/contract.md +126 -0
- package/docs/standalone.md +259 -0
- package/migrations/payments_core_initial.cjs +26 -0
- package/package.json +68 -0
- package/src/server/catalogue.js +117 -0
- package/src/server/checkout.js +131 -0
- package/src/server/knexStore.js +82 -0
- package/src/server/paddle.js +117 -0
- package/src/server/readiness.js +63 -0
- package/src/server/service.js +160 -0
- package/src/server/stripe.js +138 -0
- package/src/shared/configuration.js +40 -0
- package/test/catalogue.test.js +112 -0
- package/test/checkout.test.js +229 -0
- package/test/conformance.test.js +77 -0
- package/test/history.test.js +105 -0
- package/test/readiness.test.js +55 -0
- package/test/service.test.js +118 -0
- package/test/standalone.test.js +61 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
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
|
+
const fail = (code, message, status) => { throw new PaymentError(code, message, status); };
|
|
11
|
+
function reference(value) {
|
|
12
|
+
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.");
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function units(value) {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 1e9) fail("payment_input_invalid", "Use a positive integer credit quantity, at most one billion.");
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function timestamp(value) {
|
|
20
|
+
if (!Number.isSafeInteger(value) || value < 0) fail("payment_input_invalid", "Use an integer UTC timestamp in milliseconds.");
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
24
|
+
const unexpired = (lot, now) => lot.expiresAt === null || lot.expiresAt > now;
|
|
25
|
+
const available = (lots, now) => lots.reduce((sum, lot) => sum + (unexpired(lot, now) ? lot.remaining : 0), 0);
|
|
26
|
+
|
|
27
|
+
async function once(tx, key, input, work) {
|
|
28
|
+
const digest = fingerprint(input);
|
|
29
|
+
const previous = await tx.find(key);
|
|
30
|
+
if (previous) {
|
|
31
|
+
if (previous.digest !== digest) fail("payment_reference_conflict", "This business reference was already used with different inputs.", 409);
|
|
32
|
+
return { ...previous.result, duplicate: true };
|
|
33
|
+
}
|
|
34
|
+
const result = await work();
|
|
35
|
+
await tx.record(key, { digest, input, result });
|
|
36
|
+
return { ...result, duplicate: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function grant(tx, { reference: id, units: quantity, expiresAt }, now) {
|
|
40
|
+
reference(id); units(quantity);
|
|
41
|
+
if (expiresAt !== null) timestamp(expiresAt);
|
|
42
|
+
return once(tx, `grant:${id}`, [quantity, expiresAt], async () => {
|
|
43
|
+
if (!Number.isSafeInteger(available(tx.state.lots, now) + quantity)) fail("payment_balance_limit", "Credit balance exceeds the supported integer range.");
|
|
44
|
+
tx.state.lots = tx.state.lots.filter((lot) => unexpired(lot, now));
|
|
45
|
+
tx.state.lots.push({ id, remaining: quantity, expiresAt });
|
|
46
|
+
return { granted: quantity, expiresAt };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Server-side domain API. The application authorizes the billable subject before
|
|
51
|
+
// invoking it; none of these methods is an HTTP endpoint or a browser authority.
|
|
52
|
+
function createPaymentService({ store, configuration, clock = Date.now }) {
|
|
53
|
+
if (!store?.withAccount || !store?.inspect || !configuration?.plans) throw new TypeError("Supply payment storage and validated payment configuration.");
|
|
54
|
+
const plans = structuredClone(configuration.plans);
|
|
55
|
+
const environments = structuredClone(configuration.environments);
|
|
56
|
+
function checkScope(scope) {
|
|
57
|
+
const expected = environments[scope?.environment];
|
|
58
|
+
if (!expected || expected.integrationId !== scope.integrationId || expected.providerAccountId !== scope.providerAccountId) {
|
|
59
|
+
fail("payment_scope_invalid", "The payment scope does not match this environment's merchant configuration.", 403);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function inspect(scope) {
|
|
64
|
+
checkScope(scope);
|
|
65
|
+
const state = await store.inspect(scope);
|
|
66
|
+
const now = timestamp(clock());
|
|
67
|
+
const subscriptions = Object.values(state.subscriptions);
|
|
68
|
+
const features = [...new Set(subscriptions.filter((subscription) =>
|
|
69
|
+
subscription.status === "active" && subscription.periodEnd > now && plans[subscription.planId])
|
|
70
|
+
.flatMap((subscription) => plans[subscription.planId].features))];
|
|
71
|
+
return { balance: available(state.lots, now), features, subscriptions };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function requireFeature(scope, feature) {
|
|
75
|
+
if (!(await inspect(scope)).features.includes(feature)) fail("payment_feature_required", "Your subscription does not include this feature.", 403);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function grantCredits(scope, input) {
|
|
79
|
+
checkScope(scope);
|
|
80
|
+
return store.withAccount(scope, (tx) => grant(tx, input, timestamp(clock())));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function debitCredits(scope, { reference: id, units: quantity }) {
|
|
84
|
+
checkScope(scope);
|
|
85
|
+
reference(id); units(quantity);
|
|
86
|
+
return store.withAccount(scope, (tx) => once(tx, `debit:${id}`, [quantity], async () => {
|
|
87
|
+
const now = timestamp(clock());
|
|
88
|
+
if (available(tx.state.lots, now) < quantity) fail("payment_insufficient_credits", "Not enough credits for this action.", 409);
|
|
89
|
+
let remaining = quantity;
|
|
90
|
+
const allocations = [];
|
|
91
|
+
const lots = tx.state.lots.filter((lot) => unexpired(lot, now))
|
|
92
|
+
.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
|
|
93
|
+
for (const lot of lots) {
|
|
94
|
+
const taken = Math.min(remaining, lot.remaining);
|
|
95
|
+
if (!taken) continue;
|
|
96
|
+
lot.remaining -= taken;
|
|
97
|
+
remaining -= taken;
|
|
98
|
+
allocations.push({ grantId: lot.id, units: taken });
|
|
99
|
+
if (!remaining) break;
|
|
100
|
+
}
|
|
101
|
+
return { debited: quantity, allocations };
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function refundDebit(scope, { debitReference }) {
|
|
106
|
+
checkScope(scope);
|
|
107
|
+
reference(debitReference);
|
|
108
|
+
return store.withAccount(scope, (tx) => once(tx, `refund:${debitReference}`, [debitReference], async () => {
|
|
109
|
+
const debit = await tx.find(`debit:${debitReference}`);
|
|
110
|
+
if (!debit) fail("payment_debit_missing", "No debit exists for this reference.", 404);
|
|
111
|
+
const now = timestamp(clock());
|
|
112
|
+
let restored = 0;
|
|
113
|
+
for (const allocation of debit.result.allocations) {
|
|
114
|
+
const lot = tx.state.lots.find((item) => item.id === allocation.grantId);
|
|
115
|
+
if (lot && unexpired(lot, now)) {
|
|
116
|
+
lot.remaining += allocation.units;
|
|
117
|
+
restored += allocation.units;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { restored, expired: debit.result.debited - restored };
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Called only after provider signature/account verification. Resolve customer
|
|
125
|
+
// from a server-created binding; never use a subject ID from webhook metadata.
|
|
126
|
+
// Load fresh provider facts under the account lock, so reordered events cannot
|
|
127
|
+
// overwrite newer state. A failed load rolls back the event receipt and grants.
|
|
128
|
+
async function reconcileEvent(merchantScope, { eventId, customerId, load }) {
|
|
129
|
+
checkScope(merchantScope);
|
|
130
|
+
reference(eventId); reference(customerId);
|
|
131
|
+
if (typeof load !== "function") throw new TypeError("Supply an authenticated provider reconciliation function.");
|
|
132
|
+
const subjectId = await store.resolveCustomer(merchantScope, customerId);
|
|
133
|
+
if (!subjectId) fail("payment_customer_unbound", "Bind this provider customer before processing its events.", 409);
|
|
134
|
+
const scope = { ...merchantScope, subjectId };
|
|
135
|
+
return store.withAccount(scope, (tx) => once(tx, `event:${eventId}`, [customerId], async () => {
|
|
136
|
+
const { subscription, renewal } = await load();
|
|
137
|
+
reference(subscription?.id);
|
|
138
|
+
if (!["active", "trialing", "past_due", "paused", "canceled", "unpaid", "incomplete", "incomplete_expired"].includes(subscription.status) ||
|
|
139
|
+
!Object.hasOwn(plans, subscription.planId)) fail("payment_provider_result_invalid", "Check the provider subscription and logical price binding.", 502);
|
|
140
|
+
timestamp(subscription.periodEnd);
|
|
141
|
+
tx.state.subscriptions[subscription.id] = {
|
|
142
|
+
id: subscription.id, status: subscription.status, planId: subscription.planId, periodEnd: subscription.periodEnd
|
|
143
|
+
};
|
|
144
|
+
let creditGrant = null;
|
|
145
|
+
if (renewal) {
|
|
146
|
+
reference(renewal.id);
|
|
147
|
+
timestamp(renewal.periodEnd);
|
|
148
|
+
if (!Object.hasOwn(plans, renewal.planId)) fail("payment_provider_result_invalid", "Check the renewal's logical price binding.", 502);
|
|
149
|
+
const quantity = plans[renewal.planId].renewalCredits;
|
|
150
|
+
// A provider invoice/transaction is the business key, not its delivery ID.
|
|
151
|
+
if (quantity) creditGrant = await grant(tx, { reference: `renewal:${renewal.id}`, units: quantity, expiresAt: renewal.periodEnd }, timestamp(clock()));
|
|
152
|
+
}
|
|
153
|
+
return { subscriptionId: subscription.id, creditGrant };
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return Object.freeze({ inspect, requireFeature, grantCredits, debitCredits, refundDebit, reconcileEvent });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { createPaymentService, PaymentError };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import { PaymentError } from "./service.js";
|
|
3
|
+
|
|
4
|
+
const id = (value) => typeof value === "string" ? value : value?.id;
|
|
5
|
+
const invalid = () => { throw new PaymentError("payment_provider_result_invalid", "Check the Stripe account, price mapping and subscription shape.", 502); };
|
|
6
|
+
|
|
7
|
+
function createStripePaymentAdapter({ apiKey, webhookSecret, environment, providerAccountId, priceBindings, historicalPriceBindings = {}, client = new Stripe(apiKey, { maxNetworkRetries: 0 }) }) {
|
|
8
|
+
if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret || !providerAccountId) throw new TypeError("Supply Stripe credentials, environment and account identity.");
|
|
9
|
+
const live = environment === "live";
|
|
10
|
+
const prices = structuredClone(priceBindings);
|
|
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
|
+
}
|
|
20
|
+
async function verifyAccount() {
|
|
21
|
+
const account = await client.accounts.retrieve();
|
|
22
|
+
const balance = await client.balance.retrieve();
|
|
23
|
+
if (account.id !== providerAccountId || balance.livemode !== live) invalid();
|
|
24
|
+
return { accountId: account.id, environment, chargesEnabled: account.charges_enabled === true, payoutsEnabled: account.payouts_enabled === true };
|
|
25
|
+
}
|
|
26
|
+
async function verifyEvent(rawBody, signature) {
|
|
27
|
+
if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400);
|
|
28
|
+
let event;
|
|
29
|
+
try { event = client.webhooks.constructEvent(rawBody, signature, webhookSecret); }
|
|
30
|
+
catch { throw new PaymentError("payment_signature_invalid", "Stripe webhook verification failed.", 400); }
|
|
31
|
+
if (event.livemode !== live || (event.account && event.account !== providerAccountId)) invalid();
|
|
32
|
+
return event;
|
|
33
|
+
}
|
|
34
|
+
function eventIdentity(event) {
|
|
35
|
+
if (["invoice.paid", "invoice.payment_failed", "invoice.payment_action_required"].includes(event.type)) {
|
|
36
|
+
const invoice = event.data?.object;
|
|
37
|
+
// A signed standalone invoice has no subscription parent. Ignore it before
|
|
38
|
+
// looking up an app customer; it does not belong to this billing contract.
|
|
39
|
+
if (invoice?.parent === null) return null;
|
|
40
|
+
if (!id(invoice?.parent?.subscription_details?.subscription)) invalid();
|
|
41
|
+
} else if (!event.type?.startsWith("customer.subscription.")) return null;
|
|
42
|
+
const customerId = id(event.data?.object?.customer);
|
|
43
|
+
if (!event.id || !customerId) invalid();
|
|
44
|
+
return { eventId: event.id, customerId };
|
|
45
|
+
}
|
|
46
|
+
async function loadEvent(event) {
|
|
47
|
+
await verifyAccount();
|
|
48
|
+
let invoice = null;
|
|
49
|
+
let subscriptionId;
|
|
50
|
+
if (["invoice.paid", "invoice.payment_failed", "invoice.payment_action_required"].includes(event.type)) {
|
|
51
|
+
invoice = await client.invoices.retrieve(event.data.object.id);
|
|
52
|
+
subscriptionId = id(invoice.parent?.subscription_details?.subscription);
|
|
53
|
+
if (!subscriptionId) return null;
|
|
54
|
+
} else if (event.type.startsWith("customer.subscription.")) {
|
|
55
|
+
subscriptionId = event.data.object.id;
|
|
56
|
+
} else return null;
|
|
57
|
+
const subscription = await client.subscriptions.retrieve(subscriptionId);
|
|
58
|
+
if (subscription.livemode !== live || subscription.items?.has_more || subscription.items?.data?.length !== 1) invalid();
|
|
59
|
+
const item = subscription.items.data[0];
|
|
60
|
+
if (item.quantity !== 1) invalid();
|
|
61
|
+
const result = {
|
|
62
|
+
subscription: { id: subscription.id, status: subscription.status, planId: planFor(item.price.id), periodEnd: item.current_period_end * 1000 }
|
|
63
|
+
};
|
|
64
|
+
if (invoice && (invoice.livemode !== live || id(invoice.customer) !== id(subscription.customer))) invalid();
|
|
65
|
+
if (invoice?.status === "paid" && ["subscription_create", "subscription_cycle"].includes(invoice.billing_reason)) {
|
|
66
|
+
if (invoice.lines?.has_more || invoice.lines?.data?.length !== 1) invalid();
|
|
67
|
+
const line = invoice.lines.data[0];
|
|
68
|
+
if (line.quantity !== 1 || line.parent?.subscription_item_details?.proration !== false) invalid();
|
|
69
|
+
result.renewal = { id: invoice.id, planId: planFor(id(line.pricing?.price_details?.price)), periodEnd: line.period.end * 1000 };
|
|
70
|
+
}
|
|
71
|
+
return { customerId: id(subscription.customer), ...result };
|
|
72
|
+
}
|
|
73
|
+
async function createCustomer({ email, requestId }) {
|
|
74
|
+
await verifyAccount();
|
|
75
|
+
const customer = await client.customers.create({ email }, { idempotencyKey: requestId });
|
|
76
|
+
return customer.id;
|
|
77
|
+
}
|
|
78
|
+
async function createCheckout({ customerId, planId, returnUrl, requestId }) {
|
|
79
|
+
if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
|
|
80
|
+
await verifyAccount();
|
|
81
|
+
const result = await client.checkout.sessions.create({ mode: "subscription", customer: customerId,
|
|
82
|
+
line_items: [{ price: prices[planId], quantity: 1 }], success_url: returnUrl, cancel_url: returnUrl
|
|
83
|
+
}, { idempotencyKey: requestId });
|
|
84
|
+
if (typeof result.url !== "string") invalid();
|
|
85
|
+
return { id: result.id, url: result.url };
|
|
86
|
+
}
|
|
87
|
+
async function createPortal({ customerId, returnUrl }) {
|
|
88
|
+
await verifyAccount();
|
|
89
|
+
const result = await client.billingPortal.sessions.create({ customer: customerId, return_url: returnUrl });
|
|
90
|
+
return { id: result.id, url: result.url };
|
|
91
|
+
}
|
|
92
|
+
async function readHistory({ customerId, collection, after = null }) {
|
|
93
|
+
if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.");
|
|
94
|
+
await verifyAccount();
|
|
95
|
+
const query = { customer: customerId, limit: 20, ...(after ? { starting_after: after } : {}) };
|
|
96
|
+
const page = collection === "subscriptions"
|
|
97
|
+
? await client.subscriptions.list({ ...query, status: "all" })
|
|
98
|
+
: await client.invoices.list(query);
|
|
99
|
+
if (!Array.isArray(page.data) || page.data.length > 20 || typeof page.has_more !== "boolean" || (page.has_more && !page.data.length)) invalid();
|
|
100
|
+
const items = page.data.map((value) => {
|
|
101
|
+
if (id(value.customer) !== customerId || value.livemode !== live || typeof value.id !== "string" ||
|
|
102
|
+
typeof value.status !== "string" || !Number.isSafeInteger(value.created)) invalid();
|
|
103
|
+
const base = { id: value.id, kind: collection === "subscriptions" ? "subscription" : "invoice",
|
|
104
|
+
status: value.status, createdAt: new Date(value.created * 1000).toISOString() };
|
|
105
|
+
if (collection === "subscriptions") return base;
|
|
106
|
+
if (!Number.isSafeInteger(value.total) || !Number.isSafeInteger(value.amount_paid) ||
|
|
107
|
+
typeof value.currency !== "string" || !/^[a-z]{3}$/.test(value.currency)) invalid();
|
|
108
|
+
return { ...base, currency: value.currency.toUpperCase(), totalMinor: String(value.total), paidMinor: String(value.amount_paid) };
|
|
109
|
+
});
|
|
110
|
+
return { collection, items, nextCursor: page.has_more ? items.at(-1).id : null };
|
|
111
|
+
}
|
|
112
|
+
async function readProduct(productId) {
|
|
113
|
+
await verifyAccount();
|
|
114
|
+
const value = await client.products.retrieve(productId);
|
|
115
|
+
return { id: value.id, name: value.name };
|
|
116
|
+
}
|
|
117
|
+
async function createProduct({ name, requestId }) {
|
|
118
|
+
await verifyAccount();
|
|
119
|
+
return client.products.create({ name }, { idempotencyKey: requestId });
|
|
120
|
+
}
|
|
121
|
+
async function renameProduct({ id, name, requestId }) {
|
|
122
|
+
await verifyAccount();
|
|
123
|
+
return client.products.update(id, { name }, { idempotencyKey: requestId });
|
|
124
|
+
}
|
|
125
|
+
async function readPrice(priceId) {
|
|
126
|
+
await verifyAccount();
|
|
127
|
+
const value = await client.prices.retrieve(priceId);
|
|
128
|
+
if (value.recurring?.interval_count !== 1 || value.billing_scheme !== "per_unit") invalid();
|
|
129
|
+
return { id: value.id, productId: id(value.product), amount: value.unit_amount, currency: value.currency.toUpperCase(), interval: value.recurring.interval, active: value.active };
|
|
130
|
+
}
|
|
131
|
+
async function createPrice({ productId, amount, currency, interval, requestId }) {
|
|
132
|
+
await verifyAccount();
|
|
133
|
+
return client.prices.create({ product: productId, unit_amount: amount, currency: currency.toLowerCase(), recurring: { interval } }, { idempotencyKey: requestId });
|
|
134
|
+
}
|
|
135
|
+
return Object.freeze({ validatePlan, verifyAccount, verifyEvent, eventIdentity, loadEvent, createCustomer, createCheckout, createPortal, readHistory, readProduct, createProduct, renameProduct, readPrice, createPrice });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { createStripePaymentAdapter };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
import schema from "../../contracts/configuration.schema.json" with { type: "json" };
|
|
3
|
+
|
|
4
|
+
const validate = new Ajv({ allErrors: true, strict: true }).compile(schema);
|
|
5
|
+
|
|
6
|
+
function validatePaymentConfiguration(document) {
|
|
7
|
+
const value = document?.extensions?.payments;
|
|
8
|
+
if (!validate(value)) {
|
|
9
|
+
const error = new Error("Check the application's payment configuration.");
|
|
10
|
+
error.code = "payment_configuration_invalid";
|
|
11
|
+
error.statusCode = 422;
|
|
12
|
+
error.fieldErrors = validate.errors.map(({ instancePath, message }) => ({ path: `extensions.payments${instancePath}`, message }));
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
for (const [environment, binding] of Object.entries(value.environments)) {
|
|
16
|
+
const slot = document.integrations?.[binding.integrationId];
|
|
17
|
+
const path = `extensions.payments/environments/${environment}`;
|
|
18
|
+
const fieldErrors = [];
|
|
19
|
+
if (!slot || !["stripe", "paddle"].includes(slot.provider) || slot.accountMode !== "shared" ||
|
|
20
|
+
slot.authentication?.method !== "api-key" || !/^env:[A-Z_][A-Z0-9_]*$/.test(slot.authentication.secretRef || "")) {
|
|
21
|
+
fieldErrors.push({ path: `${path}/integrationId`, message: `Select a shared Stripe or Paddle connection with a backend API-key Env reference for ${environment}.` });
|
|
22
|
+
}
|
|
23
|
+
if (slot?.provider === "paddle") {
|
|
24
|
+
if (slot.settings?.environment !== environment) fieldErrors.push({ path: `${path}/integrationId`, message: `Select a Paddle connection configured for ${environment}.` });
|
|
25
|
+
if (!binding.taxCategory) fieldErrors.push({ path: `${path}/taxCategory`, message: "Choose the Paddle tax category for the products this app sells." });
|
|
26
|
+
if (!binding.publicClientTokenRef) fieldErrors.push({ path: `${path}/publicClientTokenRef`, message: "Set the Env reference for this environment's Paddle public client token." });
|
|
27
|
+
}
|
|
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
|
+
}
|
|
36
|
+
// The validated declaration is JSON data; callers may supply reactive proxies.
|
|
37
|
+
return JSON.parse(JSON.stringify(value));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { validatePaymentConfiguration };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import knex from 'knex';
|
|
4
|
+
import migration from '../migrations/payments_core_initial.cjs';
|
|
5
|
+
import { createKnexPaymentStore } from '../src/server/knexStore.js';
|
|
6
|
+
import { createPaymentCatalogue } from '../src/server/catalogue.js';
|
|
7
|
+
|
|
8
|
+
const scope = { applicationId: 'app', integrationId: 'billing', providerAccountId: 'acct_a', environment: 'sandbox' };
|
|
9
|
+
const config = { environments: { sandbox: { integrationId: 'billing', providerAccountId: 'acct_a' } }, plans: {
|
|
10
|
+
pro: { name: 'Pro', amount: 1200, currency: 'USD', interval: 'month', features: [], renewalCredits: 10 }
|
|
11
|
+
} };
|
|
12
|
+
|
|
13
|
+
test('reviewed catalogue publication preserves IDs, handles drift, and recovers a partial write', async () => {
|
|
14
|
+
const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
|
|
15
|
+
await migration.up(db);
|
|
16
|
+
const store = createKnexPaymentStore({ knex: db });
|
|
17
|
+
const products = new Map(); const prices = new Map();
|
|
18
|
+
let writes = 0; let failPrice = false;
|
|
19
|
+
const adapter = {
|
|
20
|
+
async createProduct({ name }) { const value = { id: `product-${products.size}`, name }; products.set(value.id, value); writes++; return value; },
|
|
21
|
+
async renameProduct({ id, name }) { const value = { id, name }; products.set(id, value); writes++; return value; },
|
|
22
|
+
async readProduct(id) { return products.get(id); },
|
|
23
|
+
async createPrice(input) {
|
|
24
|
+
const value = { ...input, active: true, id: `price-${prices.size}` }; prices.set(value.id, value); writes++;
|
|
25
|
+
assert.equal((await store.inspectCatalogue(scope)).pending.action, 'create-price');
|
|
26
|
+
if (failPrice) throw new Error('response lost after provider accepted price');
|
|
27
|
+
return value;
|
|
28
|
+
},
|
|
29
|
+
async readPrice(id) { return prices.get(id); }
|
|
30
|
+
};
|
|
31
|
+
const make = (configuration = config) => createPaymentCatalogue({ store, adapter, scope, configuration });
|
|
32
|
+
try {
|
|
33
|
+
const first = await make().preview();
|
|
34
|
+
assert.deepEqual(first.changes.map((item) => item.action), ['create-product', 'create-price']);
|
|
35
|
+
assert.equal(writes, 0);
|
|
36
|
+
await assert.rejects(make().publish({ reviewId: 'stale' }), { code: 'payment_catalogue_review_required' });
|
|
37
|
+
await make().publish({ reviewId: first.reviewId });
|
|
38
|
+
const stable = await make().preview();
|
|
39
|
+
assert.deepEqual(stable.changes, []);
|
|
40
|
+
await make().publish({ reviewId: stable.reviewId });
|
|
41
|
+
assert.equal(writes, 2);
|
|
42
|
+
const changed = structuredClone(config); changed.plans.pro.amount = 1500;
|
|
43
|
+
failPrice = true;
|
|
44
|
+
const review = await make(changed).preview();
|
|
45
|
+
await assert.rejects(make(changed).publish({ reviewId: review.reviewId }), { code: 'payment_catalogue_uncertain' });
|
|
46
|
+
const pending = await make(changed).preview();
|
|
47
|
+
assert.equal(pending.pending.action, 'create-price');
|
|
48
|
+
await assert.rejects(make(changed).publish({ reviewId: pending.reviewId }), { code: 'payment_catalogue_review_required' });
|
|
49
|
+
assert.equal(writes, 3);
|
|
50
|
+
await assert.rejects(make(changed).recover({ reviewId: pending.reviewId, providerId: 'price-0' }), { code: 'payment_catalogue_review_required' });
|
|
51
|
+
await assert.rejects(make(changed).recover({ reviewId: review.reviewId, providerId: 'price-1' }), { code: 'payment_catalogue_review_required' });
|
|
52
|
+
prices.get('price-1').active = false;
|
|
53
|
+
await assert.rejects(make(changed).recover({ reviewId: pending.reviewId, providerId: 'price-1' }), { code: 'payment_catalogue_review_required' });
|
|
54
|
+
prices.get('price-1').active = true;
|
|
55
|
+
await make(changed).recover({ reviewId: pending.reviewId, providerId: 'price-1' });
|
|
56
|
+
const state = await store.inspectCatalogue(scope);
|
|
57
|
+
assert.equal(state.plans.pro.priceId, 'price-1');
|
|
58
|
+
assert.equal(state.history[0].priceId, 'price-0');
|
|
59
|
+
assert.deepEqual((await make(changed).preview()).changes, []);
|
|
60
|
+
prices.get('price-1').active = false;
|
|
61
|
+
const drift = await make(changed).preview();
|
|
62
|
+
assert.equal(drift.drift.length, 1);
|
|
63
|
+
await assert.rejects(make(changed).publish({ reviewId: drift.reviewId }), { code: 'payment_catalogue_review_required' });
|
|
64
|
+
assert.deepEqual((await store.inspectCatalogue({ ...scope, environment: 'live' })).plans, {});
|
|
65
|
+
} finally { await migration.down(db); await db.destroy(); }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('sandbox and live publish independently and cannot reuse each other’s review', async () => {
|
|
69
|
+
const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
|
|
70
|
+
await migration.up(db);
|
|
71
|
+
const store = createKnexPaymentStore({ knex: db });
|
|
72
|
+
const configuration = structuredClone(config);
|
|
73
|
+
configuration.environments.live = { integrationId: 'billing-live', providerAccountId: 'acct_live' };
|
|
74
|
+
const liveScope = { ...scope, environment: 'live', ...configuration.environments.live };
|
|
75
|
+
const writes = [];
|
|
76
|
+
const services = {};
|
|
77
|
+
for (const selected of [scope, liveScope]) {
|
|
78
|
+
const objects = new Map();
|
|
79
|
+
const save = (kind, input) => {
|
|
80
|
+
const value = { ...input, id: `${selected.environment}-${kind}`, active: true };
|
|
81
|
+
objects.set(value.id, value);
|
|
82
|
+
writes.push(value.id);
|
|
83
|
+
return value;
|
|
84
|
+
};
|
|
85
|
+
services[selected.environment] = createPaymentCatalogue({ store, scope: selected, configuration, adapter: {
|
|
86
|
+
async createProduct(input) { return save('product', input); },
|
|
87
|
+
async createPrice(input) { return save('price', input); },
|
|
88
|
+
async readProduct(id) { return objects.get(id); },
|
|
89
|
+
async readPrice(id) { return objects.get(id); }
|
|
90
|
+
} });
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const sandboxReview = await services.sandbox.preview();
|
|
94
|
+
const liveReview = await services.live.preview();
|
|
95
|
+
assert.notEqual(sandboxReview.reviewId, liveReview.reviewId);
|
|
96
|
+
await assert.rejects(services.live.publish({ reviewId: sandboxReview.reviewId }), { code: 'payment_catalogue_review_required' });
|
|
97
|
+
assert.deepEqual(writes, []);
|
|
98
|
+
await services.sandbox.publish({ reviewId: sandboxReview.reviewId });
|
|
99
|
+
const sandboxState = await store.inspectCatalogue(scope);
|
|
100
|
+
assert.deepEqual((await store.inspectCatalogue(liveScope)).plans, {});
|
|
101
|
+
await services.live.publish({ reviewId: liveReview.reviewId });
|
|
102
|
+
assert.deepEqual(await store.inspectCatalogue(scope), sandboxState);
|
|
103
|
+
assert.equal(sandboxState.plans.pro.priceId, 'sandbox-price');
|
|
104
|
+
assert.equal((await store.inspectCatalogue(liveScope)).plans.pro.priceId, 'live-price');
|
|
105
|
+
for (const service of Object.values(services)) {
|
|
106
|
+
const review = await service.preview();
|
|
107
|
+
assert.deepEqual(review.changes, []);
|
|
108
|
+
await service.publish({ reviewId: review.reviewId });
|
|
109
|
+
}
|
|
110
|
+
assert.deepEqual(writes, ['sandbox-product', 'sandbox-price', 'live-product', 'live-price']);
|
|
111
|
+
} finally { await migration.down(db); await db.destroy(); }
|
|
112
|
+
});
|