@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.
@@ -0,0 +1,117 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { PaymentError } from "./service.js";
3
+
4
+ const fingerprint = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
5
+ const priceFields = (plan) => ({ amount: plan.amount, currency: plan.currency, interval: plan.interval });
6
+ const samePrice = (a, b) => fingerprint(priceFields(a)) === fingerprint(priceFields(b));
7
+
8
+ // Trusted application service. Hosts must authorize catalogue management before
9
+ // invoking it. No provider mutation happens while merely reading configuration.
10
+ function createPaymentCatalogue({ store, adapter, scope, configuration }) {
11
+ const plans = structuredClone(configuration.plans);
12
+ const merchant = structuredClone(scope);
13
+ const environment = configuration.environments[merchant.environment];
14
+ if (!environment || environment.integrationId !== merchant.integrationId || environment.providerAccountId !== merchant.providerAccountId) {
15
+ throw new PaymentError("payment_scope_invalid", "Catalogue scope does not match its configured environment.", 403);
16
+ }
17
+ async function preview() {
18
+ const state = await store.inspectCatalogue(merchant);
19
+ const changes = [];
20
+ const drift = [];
21
+ for (const [planId, plan] of Object.entries(plans)) {
22
+ const binding = state.plans[planId];
23
+ if (!binding) changes.push({ action: "create-product", planId, name: plan.name });
24
+ else {
25
+ const actualProduct = await adapter.readProduct(binding.productId);
26
+ if (actualProduct.name !== binding.name) drift.push({ planId, reason: "The product name changed in the provider dashboard." });
27
+ if (binding.name !== plan.name) changes.push({ action: "rename-product", planId, productId: binding.productId, name: plan.name });
28
+ if (binding.priceId) {
29
+ const actual = await adapter.readPrice(binding.priceId);
30
+ if (actual.productId !== binding.productId || !samePrice(actual, binding) || actual.active !== true) drift.push({ planId, reason: "The provider price differs from its saved binding." });
31
+ }
32
+ }
33
+ if (!binding?.priceId || !samePrice(binding, plan)) changes.push({ action: "create-price", planId, ...priceFields(plan) });
34
+ }
35
+ const removed = Object.keys(state.plans).filter((planId) => !Object.hasOwn(plans, planId));
36
+ const result = { environment: merchant.environment, providerAccountId: merchant.providerAccountId, changes, drift, removed, revision: state.revision || 0,
37
+ pending: state.pending || null };
38
+ return { ...result, reviewId: fingerprint(result) };
39
+ }
40
+ async function publish({ reviewId }) {
41
+ const review = await preview();
42
+ if (review.reviewId !== reviewId || review.drift.length || review.pending) throw new PaymentError("payment_catalogue_review_required", "Review the current catalogue and resolve provider drift or unfinished requests first.", 409);
43
+ const completed = [];
44
+ let expectedRevision = review.revision;
45
+ for (const change of review.changes) {
46
+ const token = randomUUID();
47
+ const pending = await store.withCatalogue(merchant, async (state) => {
48
+ if (state.pending) throw new PaymentError("payment_catalogue_pending", "Another catalogue request needs resolution.", 409);
49
+ if ((state.revision || 0) !== expectedRevision) throw new PaymentError("payment_catalogue_review_required", "Another publication changed the catalogue; review again.", 409);
50
+ const current = state.plans[change.planId];
51
+ // Reject another publisher changing this plan after the reviewed read.
52
+ if (change.action === "create-product" && current) throw new PaymentError("payment_catalogue_review_required", "This plan changed; review again.", 409);
53
+ if (change.action === "rename-product" && current?.productId !== change.productId) throw new PaymentError("payment_catalogue_review_required", "This product changed; review again.", 409);
54
+ 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);
55
+ const operation = { ...change, token, productId: current?.productId };
56
+ state.pending = operation;
57
+ return operation;
58
+ });
59
+ let result;
60
+ try {
61
+ if (pending.action === "create-product") result = await adapter.createProduct({ name: pending.name, requestId: token });
62
+ else if (pending.action === "rename-product") result = await adapter.renameProduct({ id: pending.productId, name: pending.name, requestId: token });
63
+ else result = await adapter.createPrice({ ...pending, requestId: token });
64
+ } catch {
65
+ throw new PaymentError("payment_catalogue_uncertain", "Catalogue publication stopped. Inspect the pending provider request before retrying; completed items are preserved.", 409);
66
+ }
67
+ await finish(pending, result);
68
+ expectedRevision++;
69
+ completed.push({ ...change, id: result.id });
70
+ }
71
+ return { completed, removed: review.removed };
72
+ }
73
+ async function finish(pending, result) {
74
+ if (typeof result?.id !== "string" || !result.id) throw new PaymentError("payment_provider_result_invalid", "The provider did not return an object ID.", 502);
75
+ await store.withCatalogue(merchant, async (state) => {
76
+ if (state.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending catalogue request changed.", 409);
77
+ const current = state.plans[pending.planId];
78
+ if (pending.action === "create-product") state.plans[pending.planId] = { productId: result.id, name: pending.name };
79
+ else if (pending.action === "rename-product") current.name = pending.name;
80
+ else {
81
+ if (current.priceId) state.history.push({ planId: pending.planId, ...current });
82
+ state.plans[pending.planId] = { ...current, priceId: result.id, ...priceFields(pending) };
83
+ }
84
+ state.revision = (state.revision || 0) + 1;
85
+ delete state.pending;
86
+ });
87
+ }
88
+ async function recover({ reviewId, providerId, confirmedNotCreated = false }) {
89
+ const review = await preview();
90
+ if (review.reviewId !== reviewId) throw new PaymentError("payment_catalogue_review_required", "The recovery review changed. Inspect the current pending operation first.", 409);
91
+ const pending = review.pending;
92
+ if (!pending) return { pending: false };
93
+ if (confirmedNotCreated) {
94
+ // Trusted app administrator must supply actual provider inspection evidence.
95
+ // This parameter must not be exposed as an unauthenticated browser command.
96
+ await store.withCatalogue(merchant, async (current) => {
97
+ if (current.pending?.token !== pending.token) throw new PaymentError("payment_catalogue_review_required", "The pending request changed.", 409);
98
+ delete current.pending;
99
+ });
100
+ return { pending: false, retryAllowed: true };
101
+ }
102
+ if (typeof providerId !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(providerId)) {
103
+ throw new PaymentError("payment_input_invalid", "Supply the exact provider object ID to recover.");
104
+ }
105
+ const result = pending.action === "create-price" ? await adapter.readPrice(providerId) : await adapter.readProduct(providerId);
106
+ if (!result || result.id !== providerId || (pending.action === "create-price" && (result.active !== true || result.productId !== pending.productId || !samePrice(result, pending))) ||
107
+ (pending.action !== "create-price" && result.name !== pending.name) ||
108
+ (pending.action === "rename-product" && providerId !== pending.productId)) {
109
+ throw new PaymentError("payment_catalogue_review_required", "The provider object does not match the pending operation.", 409);
110
+ }
111
+ await finish(pending, { id: providerId });
112
+ return { pending: false };
113
+ }
114
+ return Object.freeze({ preview, publish, recover });
115
+ }
116
+
117
+ export { createPaymentCatalogue };
@@ -0,0 +1,131 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { PaymentError } from "./service.js";
3
+
4
+ // Application composition boundary: authorization is mandatory and supplied by
5
+ // the app's framework. Actor and subject must come from its authenticated route.
6
+ function createPaymentCheckoutService({ adapter, store, payments, merchantScope, returnUrl, authorize }) {
7
+ if (typeof authorize !== "function") throw new TypeError("Supply the application's billable-subject authorization function.");
8
+ const target = new URL(returnUrl);
9
+ if ((target.protocol !== "https:" && !(target.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname))) || target.username || target.password || target.hash) {
10
+ throw new TypeError("Configure an HTTPS application return/checkout URL (HTTP is allowed only for local development).");
11
+ }
12
+ const merchant = structuredClone(merchantScope);
13
+ async function allowed(actor, subjectId, action) {
14
+ if (typeof subjectId !== "string" || !subjectId || subjectId.length > 200 || await authorize(actor, { subjectId, action }) !== true) {
15
+ throw new PaymentError("payment_forbidden", "You cannot manage this billable account.", 403);
16
+ }
17
+ return { ...merchant, subjectId };
18
+ }
19
+ async function perform(scope, operation, remote, complete) {
20
+ const token = randomUUID();
21
+ const prepared = await store.withAccount(scope, async (tx) => {
22
+ if (tx.state.pendingPaymentOperation) throw new PaymentError("payment_operation_uncertain", "Inspect the unfinished provider request before retrying.", 409);
23
+ const ready = await operation(tx);
24
+ if (ready.cached) return ready;
25
+ tx.state.pendingPaymentOperation = { token, ...ready };
26
+ return ready;
27
+ });
28
+ if (prepared.cached) return prepared.result;
29
+ // Intent commits BEFORE the network request. A killed process leaves an
30
+ // unresolved operation, rather than permitting a duplicate provider write.
31
+ let result;
32
+ try { result = await remote(prepared); }
33
+ catch { throw new PaymentError("payment_operation_uncertain", "Inspect this account's provider activity before retrying; the request may have succeeded.", 409); }
34
+ return store.withAccount(scope, async (tx) => {
35
+ if (tx.state.pendingPaymentOperation?.token !== token) throw new PaymentError("payment_operation_conflict", "The pending payment operation changed.", 409);
36
+ const value = await complete(tx, result, prepared);
37
+ delete tx.state.pendingPaymentOperation;
38
+ return value;
39
+ });
40
+ }
41
+ async function checkout({ actor, subjectId, email, planId, requestId }) {
42
+ const scope = await allowed(actor, subjectId, "checkout");
43
+ adapter.validatePlan(planId);
44
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{1,100}$/.test(requestId)) throw new PaymentError("payment_request_invalid", "Supply a stable checkout request identifier.");
45
+ if (typeof email !== "string" || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new PaymentError("payment_input_invalid", "Supply the authenticated billing contact's email.");
46
+ const identity = createHash("sha256").update(JSON.stringify(scope)).digest("hex");
47
+ const customerId = await perform(scope, async (tx) => tx.state.customerId
48
+ ? { cached: true, result: tx.state.customerId }
49
+ : { action: "customer", email },
50
+ () => adapter.createCustomer({ email, requestId: `customer-${identity}` }),
51
+ async (tx, value) => { tx.state.customerId = value; return value; });
52
+ await store.bindCustomer(merchant, customerId, subjectId);
53
+ return perform(scope, async (tx) => {
54
+ const saved = await tx.find(`checkout:${requestId}`);
55
+ if (saved) {
56
+ if (saved.planId !== planId) throw new PaymentError("payment_reference_conflict", "The checkout request already selected another plan.", 409);
57
+ return { cached: true, result: saved.result };
58
+ }
59
+ if (Object.values(tx.state.subscriptions).some((subscription) =>
60
+ !["canceled", "incomplete_expired"].includes(subscription.status))) {
61
+ throw new PaymentError("payment_subscription_exists", "Manage the existing subscription through billing instead of starting another checkout.", 409);
62
+ }
63
+ return { action: "checkout", customerId, planId, requestId };
64
+ }, () => adapter.createCheckout({ customerId, planId, returnUrl: target.href,
65
+ requestId: `checkout-${createHash("sha256").update(JSON.stringify([scope, requestId])).digest("hex")}` }),
66
+ async (tx, result) => { await tx.record(`checkout:${requestId}`, { planId, result }); return result; });
67
+ }
68
+ async function account({ actor, subjectId }) {
69
+ const scope = await allowed(actor, subjectId, "account");
70
+ const state = await payments.inspect(scope);
71
+ const customer = await store.inspect(scope);
72
+ return { ...state, hasCustomer: Boolean(customer.customerId) };
73
+ }
74
+ async function history({ actor, subjectId, collection, after = null }) {
75
+ const scope = await allowed(actor, subjectId, "history");
76
+ if (!["subscriptions", "transactions"].includes(collection) ||
77
+ (after !== null && (typeof after !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(after)))) {
78
+ throw new PaymentError("payment_input_invalid", "Select billing subscriptions or transactions and a valid page cursor.");
79
+ }
80
+ // Never accept a customer identifier from the browser or editor caller.
81
+ const { customerId } = await store.inspect(scope);
82
+ if (!customerId) return { collection, items: [], nextCursor: null };
83
+ try { return await adapter.readHistory({ customerId, collection, after }); }
84
+ catch { throw new PaymentError("payment_history_unavailable", "Billing history could not be loaded. Check provider access and retry.", 502); }
85
+ }
86
+ async function portal({ actor, subjectId }) {
87
+ const scope = await allowed(actor, subjectId, "portal");
88
+ return perform(scope, async (tx) => {
89
+ if (!tx.state.customerId) throw new PaymentError("payment_customer_missing", "Create a billing account first.", 404);
90
+ return { action: "portal", customerId: tx.state.customerId };
91
+ }, ({ customerId }) => adapter.createPortal({ customerId, returnUrl: target.href }), async (_tx, result) => result);
92
+ }
93
+ // Reconciliation is an app-server operation. The trusted callback inspects the
94
+ // provider dashboard/API and must return evidence, never browser-supplied data.
95
+ async function reconcilePending({ actor, subjectId, inspectProvider }) {
96
+ const scope = await allowed(actor, subjectId, "reconcile");
97
+ if (typeof inspectProvider !== "function") throw new TypeError("Supply the server's provider inspection function.");
98
+ return store.withAccount(scope, async (tx) => {
99
+ const pending = tx.state.pendingPaymentOperation;
100
+ if (!pending) return { pending: false };
101
+ const evidence = await inspectProvider(structuredClone(pending));
102
+ if (evidence?.confirmedNotCreated === true) {
103
+ delete tx.state.pendingPaymentOperation;
104
+ return { pending: false, retryAllowed: true };
105
+ }
106
+ const result = evidence?.result;
107
+ if (pending.action === "customer" && typeof result === "string" && result) tx.state.customerId = result;
108
+ else if (pending.action === "checkout" && typeof result?.id === "string" && typeof result.url === "string") {
109
+ await tx.record(`checkout:${pending.requestId}`, { planId: pending.planId, result });
110
+ } else if (!(pending.action === "portal" && typeof result?.url === "string")) {
111
+ throw new PaymentError("payment_reconciliation_required", "Provider inspection did not resolve this request.", 409);
112
+ }
113
+ delete tx.state.pendingPaymentOperation;
114
+ return { pending: false, result };
115
+ });
116
+ }
117
+ async function webhook({ rawBody, signature }) {
118
+ const event = await adapter.verifyEvent(rawBody, signature);
119
+ const identity = adapter.eventIdentity(event);
120
+ if (!identity) return { ignored: true };
121
+ const { eventId, customerId } = identity;
122
+ return payments.reconcileEvent(merchant, { eventId, customerId, load: async () => {
123
+ const facts = await adapter.loadEvent(event);
124
+ if (!facts || facts.customerId !== customerId) throw new PaymentError("payment_provider_result_invalid", "The event is not a supported subscription payment for this customer.", 422);
125
+ return facts;
126
+ } });
127
+ }
128
+ return Object.freeze({ account, history, checkout, portal, webhook, reconcilePending });
129
+ }
130
+
131
+ export { createPaymentCheckoutService };
@@ -0,0 +1,82 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
4
+ const empty = () => ({ lots: [], subscriptions: {} });
5
+
6
+ function merchant(scope) {
7
+ const fields = ["applicationId", "integrationId", "providerAccountId", "environment"];
8
+ for (const field of fields) {
9
+ if (typeof scope?.[field] !== "string" || !scope[field] || scope[field].length > 200) throw new TypeError(`Payment scope requires ${field}.`);
10
+ }
11
+ if (!["sandbox", "live"].includes(scope.environment)) throw new TypeError("Payment scope requires sandbox or live.");
12
+ return fields.map((field) => scope[field]);
13
+ }
14
+ function identifier(value) {
15
+ if (typeof value !== "string" || !value || value.length > 200) throw new TypeError("Payment identity is required (maximum 200 characters).");
16
+ return value;
17
+ }
18
+ const accountKey = (scope) => hash([...merchant(scope), identifier(scope.subjectId)]);
19
+
20
+ function createKnexPaymentStore({ knex }) {
21
+ if (typeof knex !== "function" || typeof knex.transaction !== "function") throw new TypeError("Supply the application's transactional Knex client.");
22
+
23
+ async function inspect(scope) {
24
+ const row = await knex("payment_accounts").where({ account_key: accountKey(scope) }).first();
25
+ return row ? JSON.parse(row.payload) : empty();
26
+ }
27
+ async function withAccount(scope, work) {
28
+ const key = accountKey(scope);
29
+ return knex.transaction(async (trx) => {
30
+ // The retained account row serializes grants, usage, refunds and event handling.
31
+ await trx("payment_accounts").insert({ account_key: key, payload: JSON.stringify(empty()) })
32
+ .onConflict("account_key").merge({ account_key: key });
33
+ const row = await trx("payment_accounts").where({ account_key: key }).forUpdate().first();
34
+ const state = JSON.parse(row.payload);
35
+ const result = await work({
36
+ state,
37
+ async find(reference) {
38
+ const entry = await trx("payment_entries").where({ entry_key: hash([key, identifier(reference)]) }).first();
39
+ return entry ? JSON.parse(entry.payload) : null;
40
+ },
41
+ async record(reference, payload) {
42
+ await trx("payment_entries").insert({ entry_key: hash([key, identifier(reference)]), account_key: key, payload: JSON.stringify(payload) });
43
+ }
44
+ });
45
+ await trx("payment_accounts").where({ account_key: key }).update({ payload: JSON.stringify(state) });
46
+ return result;
47
+ });
48
+ }
49
+ async function bindCustomer(scope, customerId, subjectId) {
50
+ const key = hash([...merchant(scope), identifier(customerId)]);
51
+ identifier(subjectId);
52
+ await knex.transaction(async (trx) => {
53
+ await trx("payment_customers").insert({ customer_key: key, subject_id: subjectId })
54
+ .onConflict("customer_key").merge({ customer_key: key });
55
+ const row = await trx("payment_customers").where({ customer_key: key }).first();
56
+ if (row.subject_id !== subjectId) throw new Error("This provider customer is already bound to a different billable subject.");
57
+ });
58
+ }
59
+ async function resolveCustomer(scope, customerId) {
60
+ const row = await knex("payment_customers").where({ customer_key: hash([...merchant(scope), identifier(customerId)]) }).first();
61
+ return row?.subject_id ?? null;
62
+ }
63
+ async function inspectCatalogue(scope) {
64
+ const row = await knex("payment_catalogues").where({ catalogue_key: hash(merchant(scope)) }).first();
65
+ return row ? JSON.parse(row.payload) : { plans: {}, history: [] };
66
+ }
67
+ async function withCatalogue(scope, work) {
68
+ const key = hash(merchant(scope));
69
+ return knex.transaction(async (trx) => {
70
+ await trx("payment_catalogues").insert({ catalogue_key: key, payload: JSON.stringify({ plans: {}, history: [] }) })
71
+ .onConflict("catalogue_key").merge({ catalogue_key: key });
72
+ const row = await trx("payment_catalogues").where({ catalogue_key: key }).forUpdate().first();
73
+ const state = JSON.parse(row.payload);
74
+ const result = await work(state);
75
+ await trx("payment_catalogues").where({ catalogue_key: key }).update({ payload: JSON.stringify(state) });
76
+ return result;
77
+ });
78
+ }
79
+ return Object.freeze({ inspect, withAccount, bindCustomer, resolveCustomer, inspectCatalogue, withCatalogue });
80
+ }
81
+
82
+ export { createKnexPaymentStore };
@@ -0,0 +1,117 @@
1
+ import { Paddle, Environment } from "@paddle/paddle-node-sdk";
2
+ import { PaymentError } from "./service.js";
3
+
4
+ const invalid = () => { throw new PaymentError("payment_provider_result_invalid", "Check the Paddle account, price mapping and subscription shape.", 502); };
5
+
6
+ function createPaddlePaymentAdapter({ apiKey, webhookSecret, environment, priceBindings, taxCategory, historicalPriceBindings = {}, client = new Paddle(apiKey, { environment: environment === "live" ? Environment.production : Environment.sandbox }) }) {
7
+ if (!["sandbox", "live"].includes(environment) || typeof webhookSecret !== "string" || !webhookSecret) throw new TypeError("Supply Paddle credentials and environment.");
8
+ const prices = structuredClone(priceBindings);
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
+ }
18
+ async function verifyAccount() {
19
+ await client.products.list({ perPage: 1 }).next();
20
+ // A successful catalogue read cannot prove merchant verification or identify
21
+ // the legal account. Keep those readiness checks explicit in the UI.
22
+ return { environment, credentialsVerified: true, accountIdentityVerified: false, merchantApproval: "manual-check-required" };
23
+ }
24
+ async function verifyEvent(rawBody, signature) {
25
+ if (!Buffer.isBuffer(rawBody)) throw new PaymentError("payment_signature_invalid", "Supply the unmodified webhook request bytes.", 400);
26
+ try { return await client.webhooks.unmarshal(rawBody.toString("utf8"), webhookSecret, signature); }
27
+ catch { throw new PaymentError("payment_signature_invalid", "Paddle webhook verification failed.", 400); }
28
+ }
29
+ function eventIdentity(event) {
30
+ if (event.eventType === "transaction.completed") {
31
+ if (event.data?.subscriptionId === null) return null;
32
+ if (!event.data?.subscriptionId) invalid();
33
+ } else if (!event.eventType?.startsWith("subscription.")) return null;
34
+ if (!event.eventId || !event.data?.customerId) invalid();
35
+ return { eventId: event.eventId, customerId: event.data.customerId };
36
+ }
37
+ async function loadEvent(event) {
38
+ let transaction = null;
39
+ let subscriptionId;
40
+ if (event.eventType === "transaction.completed") {
41
+ transaction = await client.transactions.get(event.data.id);
42
+ subscriptionId = transaction.subscriptionId;
43
+ if (!subscriptionId) return null;
44
+ } else if (event.eventType.startsWith("subscription.")) subscriptionId = event.data.id;
45
+ else return null;
46
+ const subscription = await client.subscriptions.get(subscriptionId);
47
+ if (subscription.items?.length !== 1 || subscription.items[0].quantity !== 1) invalid();
48
+ const result = { subscription: {
49
+ id: subscription.id, status: subscription.status, planId: planFor(subscription.items[0].price.id),
50
+ periodEnd: subscription.currentBillingPeriod ? Date.parse(subscription.currentBillingPeriod.endsAt) : 0
51
+ } };
52
+ if (transaction && transaction.customerId !== subscription.customerId) invalid();
53
+ if (transaction?.status === "completed" && ["api", "web", "subscription_recurring"].includes(transaction.origin)) {
54
+ if (transaction.items?.length !== 1 || transaction.items[0].quantity !== 1 || transaction.items[0].proration || !transaction.billingPeriod) invalid();
55
+ result.renewal = { id: transaction.id, planId: planFor(transaction.items[0].price?.id), periodEnd: Date.parse(transaction.billingPeriod.endsAt) };
56
+ }
57
+ return { customerId: subscription.customerId, ...result };
58
+ }
59
+ async function createCustomer({ email }) {
60
+ const customer = await client.customers.create({ email });
61
+ return customer.id;
62
+ }
63
+ async function createCheckout({ customerId, planId, returnUrl }) {
64
+ if (!Object.hasOwn(prices, planId)) throw new PaymentError("payment_plan_unknown", "Select a published payment plan.");
65
+ // This URL is an app-owned Paddle.js checkout page, approved in Paddle.
66
+ const result = await client.transactions.create({ customerId, collectionMode: "automatic",
67
+ items: [{ priceId: prices[planId], quantity: 1 }], checkout: { url: returnUrl } });
68
+ if (typeof result.checkout?.url !== "string") invalid();
69
+ return { id: result.id, url: result.checkout.url };
70
+ }
71
+ async function createPortal({ customerId }) {
72
+ const result = await client.customerPortalSessions.create(customerId, []);
73
+ return { id: result.id, url: result.urls.general.overview };
74
+ }
75
+ async function readHistory({ customerId, collection, after = null }) {
76
+ if (!["subscriptions", "transactions"].includes(collection)) throw new PaymentError("payment_input_invalid", "Select a billing history collection.");
77
+ const query = { customerId: [customerId], perPage: 20, ...(after ? { after } : {}) };
78
+ const page = collection === "subscriptions" ? client.subscriptions.list(query) : client.transactions.list(query);
79
+ // Fetch one page only; never iterate the SDK collection over the account.
80
+ const data = await page.next();
81
+ if (!Array.isArray(data) || data.length > 20 || typeof page.hasMore !== "boolean" || (page.hasMore && !data.length)) invalid();
82
+ const items = data.map((value) => {
83
+ if (value.customerId !== customerId || typeof value.id !== "string" || typeof value.status !== "string" ||
84
+ typeof value.createdAt !== "string" || !Number.isFinite(Date.parse(value.createdAt))) invalid();
85
+ const base = { id: value.id, kind: collection === "subscriptions" ? "subscription" : "transaction",
86
+ status: value.status, createdAt: new Date(value.createdAt).toISOString() };
87
+ if (collection === "subscriptions") return base;
88
+ const total = value.details?.totals?.total ?? null;
89
+ if ((total !== null && (typeof total !== "string" || !/^-?[0-9]{1,30}$/.test(total))) || !/^[A-Z]{3}$/.test(value.currencyCode)) invalid();
90
+ // A transaction total is not proof it was paid. Preserve provider status.
91
+ return { ...base, currency: value.currencyCode, totalMinor: total, paidMinor: null };
92
+ });
93
+ return { collection, items, nextCursor: page.hasMore ? items.at(-1).id : null };
94
+ }
95
+ async function readProduct(productId) {
96
+ const value = await client.products.get(productId);
97
+ return { id: value.id, name: value.name };
98
+ }
99
+ async function createProduct({ name }) {
100
+ if (!taxCategory) throw new PaymentError("payment_configuration_invalid", "Set the application's Paddle product tax category before publishing.");
101
+ return client.products.create({ name, taxCategory });
102
+ }
103
+ async function renameProduct({ id, name }) {
104
+ return client.products.update(id, { name });
105
+ }
106
+ async function readPrice(priceId) {
107
+ const value = await client.prices.get(priceId);
108
+ if (value.billingCycle?.frequency !== 1) invalid();
109
+ return { id: value.id, productId: value.productId, amount: Number(value.unitPrice.amount), currency: value.unitPrice.currencyCode, interval: value.billingCycle.interval, active: value.status === "active" };
110
+ }
111
+ async function createPrice({ productId, amount, currency, interval }) {
112
+ return client.prices.create({ productId, description: `${currency} ${amount} per ${interval}`, unitPrice: { amount: String(amount), currencyCode: currency }, billingCycle: { interval, frequency: 1 } });
113
+ }
114
+ return Object.freeze({ validatePlan, verifyAccount, verifyEvent, eventIdentity, loadEvent, createCustomer, createCheckout, createPortal, readHistory, readProduct, createProduct, renameProduct, readPrice, createPrice });
115
+ }
116
+
117
+ export { createPaddlePaymentAdapter };
@@ -0,0 +1,63 @@
1
+ import { PaymentError } from "./service.js";
2
+
3
+ // App-server inspection, invoked only after the host authorizes management.
4
+ // Missing app evidence stays unknown; provider errors never become display text.
5
+ function createPaymentReadiness({ adapter, catalogue, scope, inspectApplication }) {
6
+ const merchant = structuredClone(scope);
7
+ if (!["sandbox", "live"].includes(merchant?.environment) || !merchant.providerAccountId) throw new TypeError("Supply the configured payment merchant scope.");
8
+ async function inspect() {
9
+ const checks = [
10
+ { id: "credentials", status: "unknown", detail: "Check the provider credentials for this environment." },
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
+ ];
20
+ let provider;
21
+ try { provider = await adapter.verifyAccount(); }
22
+ catch { checks[0] = { id: "credentials", status: "failed", detail: "Provider verification failed. Check the account, credentials and environment." }; }
23
+ if (provider) {
24
+ if (provider.environment !== merchant.environment || (provider.accountId && provider.accountId !== merchant.providerAccountId)) {
25
+ throw new PaymentError("payment_scope_invalid", "Readiness returned a different payment account or environment.", 403);
26
+ }
27
+ const verified = provider.credentialsVerified === true || provider.accountId === merchant.providerAccountId;
28
+ checks[0] = { id: "credentials", status: verified ? "passed" : "unknown", detail: verified
29
+ ? "The provider accepted credential verification for this environment." : "The provider returned no affirmative credential verification." };
30
+ checks[1] = { id: "account", status: provider.accountId === merchant.providerAccountId ? "passed" : "manual",
31
+ detail: provider.accountId === merchant.providerAccountId ? "The provider account matches the saved merchant identity." : "This provider check does not establish merchant identity. Confirm it in the provider dashboard." };
32
+ for (const [index, property] of [[2, "chargesEnabled"], [3, "payoutsEnabled"]]) {
33
+ if (typeof provider[property] === "boolean") checks[index] = { id: checks[index].id, status: provider[property] ? "passed" : "failed",
34
+ detail: `The provider reports ${checks[index].id} ${provider[property] ? "enabled" : "not enabled"}.` };
35
+ }
36
+ try {
37
+ const review = await catalogue.preview();
38
+ if (review.environment !== merchant.environment || review.providerAccountId !== merchant.providerAccountId) throw new Error("scope");
39
+ const unresolved = Boolean(review.pending || review.changes.length || review.drift.length || review.removed.length);
40
+ checks[4] = { id: "catalogue", status: unresolved ? "failed" : "passed",
41
+ detail: unresolved ? "Catalogue changes, drift, removed plans or an unresolved write need review." : "Saved plans match their published provider products and prices." };
42
+ } catch { checks[4] = { id: "catalogue", status: "failed", detail: "Catalogue inspection failed. Review the application's catalogue command and provider access." }; }
43
+ }
44
+ if (inspectApplication) {
45
+ let evidence;
46
+ try { evidence = await inspectApplication(); } catch { evidence = {}; }
47
+ for (const check of checks.slice(5)) {
48
+ const value = evidence?.[check.id];
49
+ if (value === undefined) continue;
50
+ if (!value || !["passed", "failed", "unknown", "manual"].includes(value.status) || typeof value.detail !== "string" ||
51
+ !value.detail.trim() || value.detail.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.detail)) {
52
+ throw new PaymentError("payment_readiness_invalid", "App readiness checks must return bounded status and safe explanatory text.");
53
+ }
54
+ check.status = value.status;
55
+ check.detail = value.detail;
56
+ }
57
+ }
58
+ return { paymentEnvironment: merchant.environment, providerAccountId: merchant.providerAccountId, checks };
59
+ }
60
+ return Object.freeze({ inspect });
61
+ }
62
+
63
+ export { createPaymentReadiness };