@envarai/envarpay 0.2.0-alpha.2 → 0.2.0-alpha.4
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/dist/commerce/buyer.js +1 -1
- package/dist/commerce/cli.js +24 -4
- package/dist/commerce/config.d.ts +2 -0
- package/dist/commerce/config.js +5 -1
- package/dist/commerce/mpp-runtime.d.ts +1 -0
- package/dist/commerce/mpp-runtime.js +1 -0
- package/dist/commerce/server.js +10 -6
- package/dist/commerce/stripe-issuer.d.ts +39 -0
- package/dist/commerce/stripe-issuer.js +330 -0
- package/dist/commerce/stripe-provider.d.ts +2 -0
- package/dist/commerce/stripe-provider.js +57 -22
- package/package.json +1 -1
package/dist/commerce/buyer.js
CHANGED
|
@@ -108,7 +108,7 @@ export class CommerceBuyer {
|
|
|
108
108
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
109
109
|
if (this.policy.peers.some(p => p.protocol === 'x402') && (!options.signer || !/^0x[0-9a-fA-F]{40}$/.test(options.signer.address) || BigInt(options.signer.address) === 0n || !options.verifyReceipt))
|
|
110
110
|
throw new CommerceError('invalid_signer', 'x402 peers require an EVM wallet and independent receipt verifier');
|
|
111
|
-
if (this.policy.peers.some(p => p.protocol === 'mpp') && (!options.mpp || !options.mpp.payer ||
|
|
111
|
+
if (this.policy.peers.some(p => p.protocol === 'mpp') && (!options.mpp || !options.mpp.payer || !(/^pm_[A-Za-z0-9]+$/.test(options.mpp.paymentMethod) || (options.mpp.mode === 'test' && options.mpp.paymentMethod === 'pm_card_visa')) || !['test', 'live'].includes(options.mpp.mode) || typeof options.mpp.createToken !== 'function' || typeof options.mpp.verifyReceipt !== 'function'))
|
|
112
112
|
throw new CommerceError('mpp_provider_missing', 'MPP peers require explicit token creation and independent provider verification');
|
|
113
113
|
for (const peer of this.policy.peers)
|
|
114
114
|
if (!(peer.mode === 'standard-a2a' && peer.authentication === 'none') && (!options.peerTokens[peer.id] || options.peerTokens[peer.id].length < 16))
|
package/dist/commerce/cli.js
CHANGED
|
@@ -123,15 +123,35 @@ async function run() {
|
|
|
123
123
|
throw new CommerceError('buyer_key', 'Signer file must contain one 32-byte 0x-prefixed private key');
|
|
124
124
|
signer = privateKeyToAccount(key);
|
|
125
125
|
}
|
|
126
|
-
if (
|
|
127
|
-
throw new CommerceError('
|
|
128
|
-
|
|
126
|
+
if (secrets.mppAdapterModule && secrets.mppStripe)
|
|
127
|
+
throw new CommerceError('mpp_configuration', 'Choose the built-in Stripe issuer or one explicit adapter module');
|
|
128
|
+
if (needsMpp && !secrets.mppAdapterModule && !secrets.mppStripe)
|
|
129
|
+
throw new CommerceError('mpp_configuration', 'MPP peers require a configured Stripe issuer or explicit authorized funding adapter');
|
|
130
|
+
const buyerVault = secrets.vaultKeyFile ? new CredentialVault(join(dirname(values.state), 'buyer-authorizations'), privateFile(secrets.vaultKeyFile)) : undefined;
|
|
131
|
+
let mpp;
|
|
132
|
+
if (needsMpp && secrets.mppStripe) {
|
|
133
|
+
const configured = secrets.mppStripe;
|
|
134
|
+
if (!configured.secretKeyFile || !configured.sellers || typeof configured.sellers !== 'object' || Array.isArray(configured.sellers))
|
|
135
|
+
throw new CommerceError('mpp_configuration', 'Stripe issuer and seller read credentials must use private key files');
|
|
136
|
+
const { createStripeMppBuyer } = await import('./stripe-issuer.js');
|
|
137
|
+
const adapter = createStripeMppBuyer({
|
|
138
|
+
payer: configured.payer, mode: configured.mode, issuerAccountId: configured.issuerAccountId,
|
|
139
|
+
secretKey: privateFile(configured.secretKeyFile).toString('utf8').trim(), paymentMethod: configured.paymentMethod,
|
|
140
|
+
sellers: Object.fromEntries(Object.entries(configured.sellers).map(([id, seller]) => [id, { accountId: seller.accountId, secretKey: privateFile(seller.secretKeyFile).toString('utf8').trim() }])),
|
|
141
|
+
...(configured.issuance ? { issuance: configured.issuance } : {}), ...(configured.returnUrl ? { returnUrl: configured.returnUrl } : {}),
|
|
142
|
+
}, { statePath: values.state + '.stripe-issuer.sqlite3', vault: buyerVault });
|
|
143
|
+
closers.push(() => adapter.close());
|
|
144
|
+
await adapter.assertReady();
|
|
145
|
+
mpp = adapter;
|
|
146
|
+
}
|
|
147
|
+
else if (needsMpp)
|
|
148
|
+
mpp = await loadMppAdapter(secrets.mppAdapterModule);
|
|
129
149
|
const { BuyerStore } = await import('./buyer-store.js');
|
|
130
150
|
const { CommerceBuyer } = await import('./buyer.js');
|
|
131
151
|
const { BuyerManagement, listenBuyerManagement } = await import('./management.js');
|
|
132
152
|
const store = new BuyerStore(values.state);
|
|
133
153
|
closers.push(() => store.close());
|
|
134
|
-
const buyer = new CommerceBuyer({ policy, store, vault:
|
|
154
|
+
const buyer = new CommerceBuyer({ policy, store, vault: buyerVault,
|
|
135
155
|
signer, mpp, peerTokens: secrets.peerTokens, peerEndpoints: secrets.peerEndpoints,
|
|
136
156
|
...(needsEvm ? { verifyReceipt: evmReceiptVerifier(secrets.rpcUrls), ...evmSettlementRecovery(secrets.rpcUrls) } : {}) });
|
|
137
157
|
closers.push(() => buyer.stop());
|
|
@@ -2,6 +2,8 @@ import { type BuyerPolicy, type CommerceConfig, type PaymentProfile } from './ty
|
|
|
2
2
|
export declare function atomic(value: string): bigint;
|
|
3
3
|
export declare function validateUrl(value: string, allowPrivate?: boolean): URL;
|
|
4
4
|
export declare function currencyOf(profile: PaymentProfile): string;
|
|
5
|
+
/** Stripe documents sandbox Business Profile IDs as profile_test_<opaque id>. */
|
|
6
|
+
export declare function isStripeProfileId(value: unknown): value is string;
|
|
5
7
|
export declare function digest(value: unknown): string;
|
|
6
8
|
export declare function loadCommerceConfig(value: unknown): CommerceConfig;
|
|
7
9
|
export declare function loadBuyerPolicy(value: unknown): BuyerPolicy;
|
package/dist/commerce/config.js
CHANGED
|
@@ -45,6 +45,10 @@ export function validateUrl(value, allowPrivate = false) {
|
|
|
45
45
|
export function currencyOf(profile) {
|
|
46
46
|
return profile.adapter === 'x402' ? `${profile.network}/erc20:${profile.asset.toLowerCase()}` : profile.currency;
|
|
47
47
|
}
|
|
48
|
+
/** Stripe documents sandbox Business Profile IDs as profile_test_<opaque id>. */
|
|
49
|
+
export function isStripeProfileId(value) {
|
|
50
|
+
return typeof value === 'string' && /^profile_(?:test_)?[A-Za-z0-9]{1,120}$/.test(value);
|
|
51
|
+
}
|
|
48
52
|
function assertJson(value) {
|
|
49
53
|
if (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))
|
|
50
54
|
throw new CommerceError('unsafe_json_number', 'Use strings for integers outside the safe JSON number range');
|
|
@@ -172,7 +176,7 @@ export function loadBuyerPolicy(value) {
|
|
|
172
176
|
throw new CommerceError('invalid_recipient', 'x402 peers require a nonzero EVM recipient and USDC asset');
|
|
173
177
|
peer.recipient = peer.recipient.toLowerCase();
|
|
174
178
|
}
|
|
175
|
-
else if (peer.currency !== 'usd' ||
|
|
179
|
+
else if (peer.currency !== 'usd' || !isStripeProfileId(peer.recipient))
|
|
176
180
|
throw new CommerceError('invalid_recipient', 'MPP peers require USD and a verified Stripe merchant profile');
|
|
177
181
|
const limit = limits.get(peer.currency);
|
|
178
182
|
if (limit === undefined || atomic(peer.maxPerPurchase) > limit)
|
package/dist/commerce/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { AgentCard, Role, Task, TaskState, SendMessageRequest } from '@a2a-js/sdk';
|
|
3
3
|
import { DefaultRequestHandler, JsonRpcTransportHandler, ServerCallContext } from '@a2a-js/sdk/server';
|
|
4
|
-
import { RequestMalformedError, TaskNotFoundError, UnsupportedOperationError } from '@a2a-js/sdk/errors';
|
|
4
|
+
import { ContentTypeNotSupportedError, RequestMalformedError, TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError } from '@a2a-js/sdk/errors';
|
|
5
5
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
6
6
|
import addFormatsImport from 'ajv-formats';
|
|
7
7
|
import { createOfferCard, offerPath } from './card.js';
|
|
@@ -267,8 +267,6 @@ export class CommerceServer {
|
|
|
267
267
|
const caller = await this.options.authenticate(request);
|
|
268
268
|
if (!caller)
|
|
269
269
|
return Response.json({ error: 'authentication_required' }, { status: 401, headers: { 'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store' } });
|
|
270
|
-
if (request.headers.get('A2A-Version') !== '1.0')
|
|
271
|
-
return Response.json({ error: 'A2A-Version 1.0 required' }, { status: 400 });
|
|
272
270
|
if (Number(request.headers.get('Content-Length') ?? 0) > 1024 * 1024)
|
|
273
271
|
return Response.json({ error: 'request_too_large' }, { status: 413 });
|
|
274
272
|
let size = 0;
|
|
@@ -291,12 +289,18 @@ export class CommerceServer {
|
|
|
291
289
|
let requestId = null;
|
|
292
290
|
try {
|
|
293
291
|
const raw = JSON.parse(body);
|
|
294
|
-
if (raw.method === 'SendMessage' && this.workerFailed)
|
|
295
|
-
return Response.json({ error: 'worker_unavailable' }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
|
296
292
|
if (!raw || Array.isArray(raw) || typeof raw !== 'object')
|
|
297
293
|
throw new RequestMalformedError('A single JSONRPC object is required');
|
|
298
294
|
requestId = raw.id ?? null;
|
|
299
295
|
const params = raw.params;
|
|
296
|
+
// Parse only the bounded envelope to preserve its request ID. Reject these
|
|
297
|
+
// transport conditions before any quote, payment gate or Task dispatch.
|
|
298
|
+
if (request.headers.get('A2A-Version') !== '1.0')
|
|
299
|
+
throw new VersionNotSupportedError('This service supports A2A-Version 1.0');
|
|
300
|
+
if (request.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
|
|
301
|
+
throw new ContentTypeNotSupportedError('Send application/json to this JSON-RPC endpoint');
|
|
302
|
+
if (raw.method === 'SendMessage' && this.workerFailed)
|
|
303
|
+
return Response.json({ error: 'worker_unavailable' }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
|
300
304
|
if (params && typeof params === 'object' && 'tenant' in params && params.tenant !== route.tenant)
|
|
301
305
|
return Response.json({ jsonrpc: '2.0', id: raw.id ?? null, error: { code: -32602, message: 'Invalid tenant' } }, { status: 200 });
|
|
302
306
|
let paymentHeaders = {};
|
|
@@ -329,7 +333,7 @@ export class CommerceServer {
|
|
|
329
333
|
}
|
|
330
334
|
catch (error) {
|
|
331
335
|
const mapped = error instanceof CommerceError ? { code: -32000, message: error.message, data: { code: error.code } } : JsonRpcTransportHandler.mapToJSONRPCError(error);
|
|
332
|
-
return Response.json({ jsonrpc: '2.0', id: requestId, error: mapped }, { headers: { 'Cache-Control': 'no-store' } });
|
|
336
|
+
return Response.json({ jsonrpc: '2.0', id: requestId, error: mapped }, { headers: { 'A2A-Version': '1.0', 'Cache-Control': 'no-store' } });
|
|
333
337
|
}
|
|
334
338
|
}
|
|
335
339
|
async continueTask(params, input, service, offerId, context) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CredentialVault } from './vault.js';
|
|
2
|
+
import type { MppBuyerOptions } from './mpp-client.js';
|
|
3
|
+
/** Buyer and seller use the same public preview, independently of mppx internals. */
|
|
4
|
+
export declare const STRIPE_ISSUER_API_VERSION = "2026-09-30.preview";
|
|
5
|
+
export interface StripeSellerReadBinding {
|
|
6
|
+
accountId: string;
|
|
7
|
+
/** Account-scoped read credential for account, profile and PaymentIntent reads. */
|
|
8
|
+
secretKey: string;
|
|
9
|
+
}
|
|
10
|
+
export interface StripeMppBuyerConfig {
|
|
11
|
+
payer: string;
|
|
12
|
+
mode: 'test' | 'live';
|
|
13
|
+
issuerAccountId: string;
|
|
14
|
+
secretKey: string;
|
|
15
|
+
/** Previously owner-authorized PaymentMethod; this adapter never collects card data. */
|
|
16
|
+
paymentMethod: string;
|
|
17
|
+
sellers: Readonly<Record<string, StripeSellerReadBinding>>;
|
|
18
|
+
returnUrl?: string;
|
|
19
|
+
/** Real Stripe test infrastructure, selected explicitly; never a live fallback. */
|
|
20
|
+
issuance?: 'issued-token' | 'test-helper';
|
|
21
|
+
}
|
|
22
|
+
export interface StripeMppBuyerRuntime {
|
|
23
|
+
statePath: string;
|
|
24
|
+
vault: CredentialVault;
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
export interface StripeAuthenticationAction {
|
|
29
|
+
type: 'use_stripe_sdk';
|
|
30
|
+
hashedValue: string;
|
|
31
|
+
}
|
|
32
|
+
export interface StripeMppBuyerAdapter extends MppBuyerOptions {
|
|
33
|
+
assertReady(): Promise<void>;
|
|
34
|
+
close(): void;
|
|
35
|
+
/** Private owner-only API. Pass this value directly to Stripe.js, never logs. */
|
|
36
|
+
authenticationAction(operationId: string): Promise<StripeAuthenticationAction | undefined>;
|
|
37
|
+
}
|
|
38
|
+
/** Official SPT issuer with exact scope and original-ID-only recovery. No automatic funding. */
|
|
39
|
+
export declare function createStripeMppBuyer(input: StripeMppBuyerConfig, runtime: StripeMppBuyerRuntime): StripeMppBuyerAdapter;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { chmodSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { Challenge } from 'mppx';
|
|
5
|
+
import { digest, isStripeProfileId } from './config.js';
|
|
6
|
+
import { CommerceError } from './types.js';
|
|
7
|
+
import { STRIPE_MPP_API_VERSION } from './stripe-provider.js';
|
|
8
|
+
/** Buyer and seller use the same public preview, independently of mppx internals. */
|
|
9
|
+
export const STRIPE_ISSUER_API_VERSION = STRIPE_MPP_API_VERSION;
|
|
10
|
+
const ACCOUNT = /^acct_[A-Za-z0-9]{1,128}$/;
|
|
11
|
+
const TOKEN = /^spt_[A-Za-z0-9_]{1,200}$/;
|
|
12
|
+
const PAYMENT_METHOD = /^pm_[A-Za-z0-9]{1,128}$/;
|
|
13
|
+
const OPERATION = /^[A-Za-z0-9_-]{1,200}$/;
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
15
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
16
|
+
/** Durable private references only; exact requests and tokens live in CredentialVault. */
|
|
17
|
+
class IssuerLedger {
|
|
18
|
+
db;
|
|
19
|
+
owner;
|
|
20
|
+
closed = false;
|
|
21
|
+
constructor(path) {
|
|
22
|
+
if (typeof path !== 'string' || !path.trim() || path === ':memory:')
|
|
23
|
+
throw new CommerceError('stripe_issuer_state', 'SPT issuance requires a durable private ledger');
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
25
|
+
this.owner = new DatabaseSync(path + '.owner.sqlite3');
|
|
26
|
+
chmodSync(path + '.owner.sqlite3', 0o600);
|
|
27
|
+
try {
|
|
28
|
+
this.owner.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
this.owner.close();
|
|
32
|
+
throw new CommerceError('store_locked', 'Stripe issuer ledger already has an active process');
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
this.db = new DatabaseSync(path);
|
|
36
|
+
chmodSync(path, 0o600);
|
|
37
|
+
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
38
|
+
CREATE TABLE IF NOT EXISTS stripe_issuer_meta(version INTEGER NOT NULL);
|
|
39
|
+
INSERT INTO stripe_issuer_meta SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM stripe_issuer_meta);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS stripe_issuer_operations(
|
|
41
|
+
operation_id TEXT PRIMARY KEY, idempotency_key TEXT NOT NULL UNIQUE, purchase_id TEXT NOT NULL UNIQUE,
|
|
42
|
+
fingerprint TEXT NOT NULL, request_ref TEXT NOT NULL, token_ref TEXT,
|
|
43
|
+
created_at INTEGER NOT NULL
|
|
44
|
+
);`);
|
|
45
|
+
if (this.db.prepare('SELECT version FROM stripe_issuer_meta').get()?.version !== 1)
|
|
46
|
+
throw new CommerceError('store_version', 'Unsupported Stripe issuer ledger version');
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
this.db?.close();
|
|
50
|
+
this.owner.close();
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
get(id) {
|
|
55
|
+
const row = this.db.prepare('SELECT * FROM stripe_issuer_operations WHERE operation_id=?').get(id);
|
|
56
|
+
return row ? { operationId: String(row.operation_id), fingerprint: String(row.fingerprint), requestRef: String(row.request_ref), ...(row.token_ref ? { tokenRef: String(row.token_ref) } : {}) } : undefined;
|
|
57
|
+
}
|
|
58
|
+
insert(operation, fingerprint, requestRef, now) {
|
|
59
|
+
try {
|
|
60
|
+
this.db.prepare('INSERT INTO stripe_issuer_operations VALUES(?,?,?,?,?,NULL,?)').run(operation.operationId, operation.idempotencyKey, operation.purchaseId, fingerprint, requestRef, now);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new CommerceError('stripe_spt_operation_conflict', 'Purchase or idempotency identity already has an original issuer operation');
|
|
64
|
+
}
|
|
65
|
+
return this.get(operation.operationId);
|
|
66
|
+
}
|
|
67
|
+
token(id, ref) { this.db.prepare('UPDATE stripe_issuer_operations SET token_ref=? WHERE operation_id=? AND token_ref IS NULL').run(ref, id); }
|
|
68
|
+
close() {
|
|
69
|
+
if (this.closed)
|
|
70
|
+
return;
|
|
71
|
+
this.closed = true;
|
|
72
|
+
this.db.close();
|
|
73
|
+
this.owner?.exec('ROLLBACK');
|
|
74
|
+
this.owner?.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Official SPT issuer with exact scope and original-ID-only recovery. No automatic funding. */
|
|
78
|
+
export function createStripeMppBuyer(input, runtime) {
|
|
79
|
+
const config = structuredClone(input), issuance = config.issuance ?? 'issued-token';
|
|
80
|
+
const keyValid = (key) => typeof key === 'string' && new RegExp(`^(?:sk|rk)_${config.mode}_[A-Za-z0-9]{1,256}$`).test(key);
|
|
81
|
+
if (!['test', 'live'].includes(config.mode) || !['issued-token', 'test-helper'].includes(issuance) || !ACCOUNT.test(config.issuerAccountId) || !keyValid(config.secretKey) ||
|
|
82
|
+
typeof config.payer !== 'string' || !config.payer || config.payer.length > 200 || !config.sellers || !Object.keys(config.sellers).length ||
|
|
83
|
+
!(PAYMENT_METHOD.test(config.paymentMethod) || (issuance === 'test-helper' && config.paymentMethod === 'pm_card_visa'))) {
|
|
84
|
+
throw new CommerceError('stripe_issuer_configuration', 'Explicit Stripe issuer, funding method, seller read bindings and mode are required');
|
|
85
|
+
}
|
|
86
|
+
for (const [profile, seller] of Object.entries(config.sellers)) {
|
|
87
|
+
if (!isStripeProfileId(profile) || (config.mode === 'live' && profile.startsWith('profile_test_')) || !ACCOUNT.test(seller.accountId) || !keyValid(seller.secretKey))
|
|
88
|
+
throw new CommerceError('stripe_issuer_configuration', 'Seller profile, account and read credential must match the explicit mode');
|
|
89
|
+
}
|
|
90
|
+
if (issuance === 'test-helper' && (config.mode !== 'test' || config.paymentMethod !== 'pm_card_visa' || Object.values(config.sellers).some(s => s.accountId !== config.issuerAccountId))) {
|
|
91
|
+
throw new CommerceError('stripe_test_helper_configuration', 'Test helper requires pm_card_visa and the same explicitly configured test seller account');
|
|
92
|
+
}
|
|
93
|
+
if (config.returnUrl !== undefined) {
|
|
94
|
+
let url;
|
|
95
|
+
try {
|
|
96
|
+
url = new URL(config.returnUrl);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new CommerceError('stripe_return_url', 'Stripe return URL must be an owner-configured HTTPS URL');
|
|
100
|
+
}
|
|
101
|
+
if (url.protocol !== 'https:' || url.username || url.password || config.returnUrl.length > 2048)
|
|
102
|
+
throw new CommerceError('stripe_return_url', 'Stripe return URL must be an owner-configured HTTPS URL');
|
|
103
|
+
}
|
|
104
|
+
const ledger = new IssuerLedger(runtime.statePath), vault = runtime.vault;
|
|
105
|
+
const fetchImpl = runtime.fetchImpl ?? fetch, now = runtime.now ?? Date.now;
|
|
106
|
+
const inFlight = new Set();
|
|
107
|
+
async function call(secret, path, body, idempotencyKey) {
|
|
108
|
+
const headers = new Headers({ Authorization: `Bearer ${secret}`, 'Stripe-Version': STRIPE_ISSUER_API_VERSION });
|
|
109
|
+
if (body !== undefined)
|
|
110
|
+
headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
|
111
|
+
if (idempotencyKey)
|
|
112
|
+
headers.set('Idempotency-Key', idempotencyKey);
|
|
113
|
+
let response;
|
|
114
|
+
try {
|
|
115
|
+
response = await fetchImpl(`https://api.stripe.com${path}`, { method: body === undefined ? 'GET' : 'POST', headers, ...(body === undefined ? {} : { body }), redirect: 'error', signal: AbortSignal.timeout(20000) });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new CommerceError('stripe_transport_unknown', 'Stripe request outcome is unknown; retain the original operation');
|
|
119
|
+
}
|
|
120
|
+
const reader = response.body?.getReader();
|
|
121
|
+
let size = 0;
|
|
122
|
+
const chunks = [];
|
|
123
|
+
try {
|
|
124
|
+
if (reader)
|
|
125
|
+
for (;;) {
|
|
126
|
+
const { value, done } = await reader.read();
|
|
127
|
+
if (done)
|
|
128
|
+
break;
|
|
129
|
+
size += value.byteLength;
|
|
130
|
+
if (size > 1024 * 1024) {
|
|
131
|
+
await reader.cancel();
|
|
132
|
+
throw new CommerceError('stripe_response_size', 'Stripe response exceeds limit');
|
|
133
|
+
}
|
|
134
|
+
chunks.push(value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error instanceof CommerceError && error.code === 'stripe_response_size')
|
|
139
|
+
throw error;
|
|
140
|
+
throw new CommerceError('stripe_transport_unknown', 'Stripe response was interrupted; retain the original operation');
|
|
141
|
+
}
|
|
142
|
+
if (!response.ok)
|
|
143
|
+
throw new CommerceError(response.status === 429 ? 'stripe_rate_limit' : 'stripe_request_failed', `Stripe request failed (HTTP ${response.status})`);
|
|
144
|
+
let value;
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new CommerceError('stripe_response', 'Stripe returned invalid JSON');
|
|
150
|
+
}
|
|
151
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
152
|
+
throw new CommerceError('stripe_response', 'Stripe returned an invalid object');
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
async function account(secret, id) {
|
|
156
|
+
const result = await call(secret, '/v1/account');
|
|
157
|
+
if (result.id !== id || result.object !== 'account')
|
|
158
|
+
throw new CommerceError('stripe_account_mismatch', 'Stripe credential does not prove the expected account');
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
async function sellerReady(profile) {
|
|
162
|
+
const seller = config.sellers[profile];
|
|
163
|
+
if (!Object.hasOwn(config.sellers, profile) || !seller)
|
|
164
|
+
throw new CommerceError('stripe_seller_binding', 'Seller is not in the local read-verification allowlist');
|
|
165
|
+
const result = await account(seller.secretKey, seller.accountId);
|
|
166
|
+
if (config.mode === 'live' && (result.charges_enabled !== true || object(result.capabilities).card_payments !== 'active'))
|
|
167
|
+
throw new CommerceError('stripe_charges_disabled', 'Live seller card payments are not enabled');
|
|
168
|
+
const ownProfile = await call(seller.secretKey, '/v2/network/business_profiles/me');
|
|
169
|
+
// Require the documented mode as well as the credential-scoped /me identity.
|
|
170
|
+
if (ownProfile.id !== profile || ownProfile.object !== 'v2.network.business_profile' || ownProfile.livemode !== (config.mode === 'live'))
|
|
171
|
+
throw new CommerceError('stripe_profile_mismatch', 'Seller credential does not prove the expected profile and mode');
|
|
172
|
+
return seller;
|
|
173
|
+
}
|
|
174
|
+
function requestFor(operation) {
|
|
175
|
+
const quote = operation.quote, challenge = operation.challenge;
|
|
176
|
+
const details = object(challenge?.request?.methodDetails), metadata = object(operation.metadata);
|
|
177
|
+
const expiry = Math.floor(Date.parse(quote?.expiresAt) / 1000);
|
|
178
|
+
if (!OPERATION.test(operation.operationId) || operation.idempotencyKey !== operation.operationId || !UUID.test(operation.purchaseId) ||
|
|
179
|
+
operation.mode !== config.mode || operation.paymentMethod !== config.paymentMethod || operation.currency !== 'usd' || !/^[1-9][0-9]{1,7}$/.test(operation.amount) ||
|
|
180
|
+
BigInt(operation.amount) < 50n || BigInt(operation.amount) > 99999999n || operation.amount !== quote.amount || quote.currency !== 'usd' || quote.payer !== config.payer ||
|
|
181
|
+
operation.networkId !== quote.recipient || !isStripeProfileId(operation.networkId) || !Object.hasOwn(config.sellers, operation.networkId) || !Number.isSafeInteger(expiry) || operation.expiresAt !== expiry ||
|
|
182
|
+
challenge?.method !== 'stripe' || challenge.intent !== 'charge' || challenge.header !== 'Payment-Authorization' || challenge.expires !== quote.expiresAt ||
|
|
183
|
+
challenge.request.amount !== quote.amount || challenge.request.currency !== 'usd' || challenge.request.recipient !== quote.recipient || challenge.request.externalId !== quote.quoteId ||
|
|
184
|
+
details.networkId !== quote.recipient || digest(details.metadata ?? {}) !== digest(metadata) ||
|
|
185
|
+
metadata.envarpay_quote !== quote.quoteId || metadata.envarpay_terms !== quote.termsDigest || typeof metadata.envarpay_order !== 'string' || !UUID.test(metadata.envarpay_order) ||
|
|
186
|
+
typeof quote.quoteId !== 'string' || quote.quoteId.length > 500 || typeof quote.termsDigest !== 'string' || quote.termsDigest.length > 500 ||
|
|
187
|
+
Challenge.serialize(challenge).length > 32768)
|
|
188
|
+
throw new CommerceError('stripe_spt_binding', 'SPT request differs from the frozen purchase or approved funding scope');
|
|
189
|
+
const body = new URLSearchParams({ payment_method: operation.paymentMethod,
|
|
190
|
+
'usage_limits[currency]': 'usd', 'usage_limits[max_amount]': operation.amount, 'usage_limits[expires_at]': String(expiry),
|
|
191
|
+
'shared_metadata[envarpay_operation]': operation.operationId, 'shared_metadata[envarpay_purchase]': operation.purchaseId,
|
|
192
|
+
'shared_metadata[envarpay_order]': metadata.envarpay_order, 'shared_metadata[envarpay_quote]': quote.quoteId, 'shared_metadata[envarpay_terms]': quote.termsDigest,
|
|
193
|
+
});
|
|
194
|
+
if (issuance === 'issued-token') {
|
|
195
|
+
body.set('seller_details[network_business_profile]', operation.networkId);
|
|
196
|
+
if (config.returnUrl)
|
|
197
|
+
body.set('return_url', config.returnUrl);
|
|
198
|
+
}
|
|
199
|
+
return { operation: structuredClone(operation), body: body.toString(), binding: { issuerAccountId: config.issuerAccountId, sellerAccountId: config.sellers[operation.networkId].accountId, issuance, ...(config.returnUrl ? { returnUrl: config.returnUrl } : {}) } };
|
|
200
|
+
}
|
|
201
|
+
function saved(row) {
|
|
202
|
+
const original = vault.get(row.requestRef);
|
|
203
|
+
if (row.operationId !== original.operation?.operationId || digest(original) !== row.fingerprint || digest(requestFor(original.operation)) !== row.fingerprint)
|
|
204
|
+
throw new CommerceError('stripe_spt_recovery_binding', 'Original issuer scope changed or failed integrity validation');
|
|
205
|
+
return original;
|
|
206
|
+
}
|
|
207
|
+
function tokenId(row) {
|
|
208
|
+
if (!row.tokenRef)
|
|
209
|
+
throw new CommerceError('stripe_spt_recovery_required', 'Original SPT ID is unknown; do not issue a replacement token');
|
|
210
|
+
const value = vault.get(row.tokenRef);
|
|
211
|
+
if (value.operationId !== row.operationId || value.fingerprint !== row.fingerprint || !TOKEN.test(value.id))
|
|
212
|
+
throw new CommerceError('stripe_spt_recovery_binding', 'Original encrypted SPT binding is invalid');
|
|
213
|
+
return value.id;
|
|
214
|
+
}
|
|
215
|
+
function validateToken(value, original, expectedId) {
|
|
216
|
+
const operation = original.operation, limits = object(value.usage_limits), meta = object(value.shared_metadata);
|
|
217
|
+
if (value.id !== expectedId || value.object !== (issuance === 'issued-token' ? 'shared_payment.issued_token' : 'shared_payment.granted_token') ||
|
|
218
|
+
value.livemode !== (config.mode === 'live') || limits.currency !== 'usd' || !Number.isSafeInteger(limits.max_amount) || String(limits.max_amount) !== operation.amount || limits.expires_at !== operation.expiresAt ||
|
|
219
|
+
meta.envarpay_operation !== operation.operationId || meta.envarpay_purchase !== operation.purchaseId || meta.envarpay_order !== operation.metadata.envarpay_order || meta.envarpay_quote !== operation.quote.quoteId || meta.envarpay_terms !== operation.quote.termsDigest ||
|
|
220
|
+
(issuance === 'issued-token' && (value.payment_method !== operation.paymentMethod || object(value.seller_details).network_business_profile !== operation.networkId)) ||
|
|
221
|
+
(issuance === 'test-helper' && object(value.payment_method_details).type !== 'card'))
|
|
222
|
+
throw new CommerceError('stripe_spt_response_binding', 'Stripe token does not match the original scope and mode');
|
|
223
|
+
}
|
|
224
|
+
function usable(value, original) {
|
|
225
|
+
if (value.status === 'requires_action')
|
|
226
|
+
throw new CommerceError('stripe_spt_requires_action', 'Original SPT requires customer authentication through Stripe.js');
|
|
227
|
+
const captured = object(object(value.usage_details).amount_captured);
|
|
228
|
+
if ((issuance === 'issued-token' && value.status !== 'active') || value.deactivated_at != null || value.deactivated_reason != null ||
|
|
229
|
+
captured.value !== 0 || captured.currency !== 'usd' || original.operation.expiresAt <= Math.floor(now() / 1000))
|
|
230
|
+
throw new CommerceError('stripe_spt_unusable', 'Original SPT is expired, consumed, inactive or not proven unused');
|
|
231
|
+
return String(value.id);
|
|
232
|
+
}
|
|
233
|
+
async function retrieve(row) {
|
|
234
|
+
const original = saved(row), id = tokenId(row);
|
|
235
|
+
await account(config.secretKey, config.issuerAccountId);
|
|
236
|
+
// Test helpers use the seller account itself; issued tokens use the issuer account.
|
|
237
|
+
const value = await call(config.secretKey, `/v1/shared_payment/${issuance === 'issued-token' ? 'issued' : 'granted'}_tokens/${id}`);
|
|
238
|
+
validateToken(value, original, id);
|
|
239
|
+
return { original, value };
|
|
240
|
+
}
|
|
241
|
+
async function exclusive(id, fn) {
|
|
242
|
+
if (inFlight.has(id))
|
|
243
|
+
throw new CommerceError('stripe_spt_operation_busy', 'Original SPT operation is already in progress');
|
|
244
|
+
inFlight.add(id);
|
|
245
|
+
try {
|
|
246
|
+
return await fn();
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
inFlight.delete(id);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const adapter = {
|
|
253
|
+
payer: config.payer, mode: config.mode, paymentMethod: config.paymentMethod,
|
|
254
|
+
async assertReady() {
|
|
255
|
+
await account(config.secretKey, config.issuerAccountId);
|
|
256
|
+
for (const profile of Object.keys(config.sellers))
|
|
257
|
+
await sellerReady(profile);
|
|
258
|
+
},
|
|
259
|
+
close() { ledger.close(); },
|
|
260
|
+
async createToken(operation) {
|
|
261
|
+
return exclusive(operation.operationId, async () => {
|
|
262
|
+
const original = requestFor(operation), fingerprint = digest(original), prior = ledger.get(operation.operationId);
|
|
263
|
+
if (prior) {
|
|
264
|
+
if (prior.fingerprint !== fingerprint)
|
|
265
|
+
throw new CommerceError('stripe_spt_recovery_binding', 'Operation ID is already bound to another SPT request');
|
|
266
|
+
const result = await retrieve(prior);
|
|
267
|
+
return usable(result.value, result.original);
|
|
268
|
+
}
|
|
269
|
+
if (operation.expiresAt <= Math.floor(now() / 1000) || operation.expiresAt > Math.floor(now() / 1000) + 3600)
|
|
270
|
+
throw new CommerceError('stripe_spt_expiry', 'SPT expiry must be within the original one-hour quote window');
|
|
271
|
+
await account(config.secretKey, config.issuerAccountId);
|
|
272
|
+
await sellerReady(operation.networkId);
|
|
273
|
+
if (operation.expiresAt <= Math.floor(now() / 1000))
|
|
274
|
+
throw new CommerceError('stripe_spt_expiry', 'Original quote expired during issuer readiness checks');
|
|
275
|
+
// No await between durable insertion and starting the one permitted POST.
|
|
276
|
+
const row = ledger.insert(operation, fingerprint, vault.put(original), now());
|
|
277
|
+
const value = await call(config.secretKey, issuance === 'issued-token' ? '/v1/shared_payment/issued_tokens' : '/v1/test_helpers/shared_payment/granted_tokens', original.body, operation.idempotencyKey);
|
|
278
|
+
if (typeof value.id !== 'string' || !TOKEN.test(value.id))
|
|
279
|
+
throw new CommerceError('stripe_spt_response_binding', 'Stripe did not return a valid original SPT ID');
|
|
280
|
+
// Save the ID even if the remaining response needs independent reconciliation.
|
|
281
|
+
ledger.token(row.operationId, vault.put({ operationId: row.operationId, fingerprint, id: value.id }));
|
|
282
|
+
validateToken(value, original, value.id);
|
|
283
|
+
return usable(value, original);
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
async recoverToken(operation) {
|
|
287
|
+
return exclusive(operation.operationId, async () => {
|
|
288
|
+
const row = ledger.get(operation.operationId);
|
|
289
|
+
if (!row)
|
|
290
|
+
throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is absent; do not issue a replacement token');
|
|
291
|
+
if (digest(requestFor(operation)) !== row.fingerprint)
|
|
292
|
+
throw new CommerceError('stripe_spt_recovery_binding', 'Recovery differs from the original SPT request');
|
|
293
|
+
const result = await retrieve(row);
|
|
294
|
+
return usable(result.value, result.original);
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
async authenticationAction(operationId) {
|
|
298
|
+
if (!OPERATION.test(operationId))
|
|
299
|
+
throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is required');
|
|
300
|
+
const row = ledger.get(operationId);
|
|
301
|
+
if (!row)
|
|
302
|
+
throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is absent');
|
|
303
|
+
const { value } = await retrieve(row);
|
|
304
|
+
if (value.status !== 'requires_action')
|
|
305
|
+
return undefined;
|
|
306
|
+
const action = object(value.next_action), sdk = object(action.use_stripe_sdk);
|
|
307
|
+
if (action.type !== 'use_stripe_sdk' || typeof sdk.value !== 'string' || !sdk.value || sdk.value.length > 16384)
|
|
308
|
+
throw new CommerceError('stripe_spt_action_unsupported', 'Original SPT requires customer action not supported by this Stripe.js bridge');
|
|
309
|
+
return { type: 'use_stripe_sdk', hashedValue: sdk.value };
|
|
310
|
+
},
|
|
311
|
+
async verifyReceipt(receipt, context) {
|
|
312
|
+
const row = ledger.get(context.operationId);
|
|
313
|
+
if (!row?.tokenRef)
|
|
314
|
+
return false;
|
|
315
|
+
const original = saved(row), op = original.operation, seller = config.sellers[op.networkId];
|
|
316
|
+
if (context.purchaseId !== op.purchaseId || context.mode !== config.mode || digest(context.quote) !== digest(op.quote) ||
|
|
317
|
+
receipt.method !== 'stripe' || receipt.status !== 'success' || !/^pi_[A-Za-z0-9]{1,128}$/.test(receipt.reference) ||
|
|
318
|
+
receipt.externalId !== op.quote.quoteId || receipt.amount !== op.amount || receipt.currency !== 'usd' || receipt.merchant !== op.networkId ||
|
|
319
|
+
receipt.accountId !== seller.accountId || receipt.livemode !== (config.mode === 'live'))
|
|
320
|
+
return false;
|
|
321
|
+
await sellerReady(op.networkId);
|
|
322
|
+
const intent = await call(seller.secretKey, `/v1/payment_intents/${receipt.reference}`);
|
|
323
|
+
const metadata = object(intent.metadata);
|
|
324
|
+
return intent.id === receipt.reference && intent.object === 'payment_intent' && intent.status === 'succeeded' && intent.livemode === (config.mode === 'live') &&
|
|
325
|
+
Number.isSafeInteger(intent.amount) && String(intent.amount) === op.amount && Number.isSafeInteger(intent.amount_received) && String(intent.amount_received) === op.amount &&
|
|
326
|
+
intent.currency === 'usd' && metadata.envarpay_order === op.metadata.envarpay_order && metadata.envarpay_quote === op.quote.quoteId && metadata.envarpay_terms === op.quote.termsDigest;
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
return adapter;
|
|
330
|
+
}
|
|
@@ -5,5 +5,7 @@ export interface StripeProviderConfig {
|
|
|
5
5
|
merchantProfile: string;
|
|
6
6
|
mode: 'test' | 'live';
|
|
7
7
|
}
|
|
8
|
+
/** Public Stripe Business Profiles and Shared Payment Tokens contract. */
|
|
9
|
+
export declare const STRIPE_MPP_API_VERSION = "2026-09-30.preview";
|
|
8
10
|
/** Stripe REST transport used by the official mppx Stripe method. No SPT creation. */
|
|
9
11
|
export declare function stripeProvider(config: StripeProviderConfig, fetchImpl?: typeof fetch): StripeProvider;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { CommerceError } from './types.js';
|
|
2
|
+
import { isStripeProfileId } from './config.js';
|
|
3
|
+
/** Public Stripe Business Profiles and Shared Payment Tokens contract. */
|
|
4
|
+
export const STRIPE_MPP_API_VERSION = '2026-09-30.preview';
|
|
2
5
|
/** Stripe REST transport used by the official mppx Stripe method. No SPT creation. */
|
|
3
6
|
export function stripeProvider(config, fetchImpl = fetch) {
|
|
4
|
-
if (
|
|
5
|
-
!config.
|
|
7
|
+
if (!['test', 'live'].includes(config.mode) || !/^acct_[A-Za-z0-9]+$/.test(config.accountId) || !isStripeProfileId(config.merchantProfile) ||
|
|
8
|
+
!new RegExp(`^(?:sk|rk)_${config.mode}_[A-Za-z0-9]{1,256}$`).test(config.secretKey) ||
|
|
9
|
+
(config.mode === 'live' && config.merchantProfile.startsWith('profile_test_'))) {
|
|
6
10
|
throw new CommerceError('stripe_configuration', 'Explicit matching Stripe account, profile and test/live credentials are required');
|
|
7
11
|
}
|
|
8
12
|
const call = async (path, method = 'GET', params, options) => {
|
|
@@ -26,30 +30,45 @@ export function stripeProvider(config, fetchImpl = fetch) {
|
|
|
26
30
|
for (const [key, value] of Object.entries(params ?? {}))
|
|
27
31
|
add(key, value);
|
|
28
32
|
const headers = new Headers({ Authorization: `Bearer ${config.secretKey}` });
|
|
29
|
-
|
|
30
|
-
headers.set('Stripe-Version', options.apiVersion);
|
|
33
|
+
headers.set('Stripe-Version', STRIPE_MPP_API_VERSION);
|
|
31
34
|
if (options?.idempotencyKey)
|
|
32
35
|
headers.set('Idempotency-Key', options.idempotencyKey);
|
|
33
36
|
if (method !== 'GET')
|
|
34
37
|
headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const url = `https://api.stripe.com${path}${method === 'GET' && body.size ? '?' + body.toString() : ''}`;
|
|
39
|
+
let response;
|
|
40
|
+
try {
|
|
41
|
+
response = await fetchImpl(url, {
|
|
42
|
+
method, headers, ...(method === 'GET' ? {} : { body }), redirect: 'error', signal: AbortSignal.timeout(20000),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new CommerceError('stripe_transport_unknown', 'Stripe request outcome is unknown; preserve the original payment');
|
|
47
|
+
}
|
|
48
|
+
if (response.url && response.url !== url)
|
|
49
|
+
throw new CommerceError('stripe_redirect', 'Stripe response escaped the fixed provider request');
|
|
38
50
|
const reader = response.body?.getReader();
|
|
39
51
|
let size = 0;
|
|
40
52
|
const chunks = [];
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
try {
|
|
54
|
+
if (reader)
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { value, done } = await reader.read();
|
|
57
|
+
if (done)
|
|
58
|
+
break;
|
|
59
|
+
size += value.byteLength;
|
|
60
|
+
if (size > 1024 * 1024) {
|
|
61
|
+
await reader.cancel();
|
|
62
|
+
throw new CommerceError('stripe_response_size', 'Stripe response exceeds limit');
|
|
63
|
+
}
|
|
64
|
+
chunks.push(value);
|
|
50
65
|
}
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof CommerceError)
|
|
69
|
+
throw error;
|
|
70
|
+
throw new CommerceError('stripe_transport_unknown', 'Stripe response was interrupted; preserve the original payment');
|
|
71
|
+
}
|
|
53
72
|
let result;
|
|
54
73
|
try {
|
|
55
74
|
result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
@@ -73,12 +92,28 @@ export function stripeProvider(config, fetchImpl = fetch) {
|
|
|
73
92
|
accountId: config.accountId, merchantProfile: config.merchantProfile, mode: config.mode,
|
|
74
93
|
async assertReady() {
|
|
75
94
|
const account = await call('/v1/account');
|
|
76
|
-
if (account.id !== config.accountId)
|
|
95
|
+
if (account.object !== 'account' || account.id !== config.accountId)
|
|
77
96
|
throw new CommerceError('stripe_account_mismatch', 'Stripe key belongs to another account');
|
|
78
|
-
if (config.mode === 'live' && account.charges_enabled !== true)
|
|
79
|
-
throw new CommerceError('stripe_charges_disabled', 'Live merchant is not enabled for charges');
|
|
97
|
+
if (config.mode === 'live' && (account.charges_enabled !== true || account.capabilities?.card_payments !== 'active'))
|
|
98
|
+
throw new CommerceError('stripe_charges_disabled', 'Live merchant is not enabled for card charges');
|
|
99
|
+
const profile = await call('/v2/network/business_profiles/me');
|
|
100
|
+
// /me binds the profile to this authenticated merchant; the documented
|
|
101
|
+
// livemode field must also prove the requested test/live boundary.
|
|
102
|
+
if (profile.object !== 'v2.network.business_profile' || profile.id !== config.merchantProfile ||
|
|
103
|
+
profile.livemode !== (config.mode === 'live'))
|
|
104
|
+
throw new CommerceError('stripe_profile_mismatch', 'Stripe profile does not match this merchant and mode');
|
|
105
|
+
},
|
|
106
|
+
async create(params, options) {
|
|
107
|
+
if (!Number.isSafeInteger(params.amount) || Number(params.amount) < 50 || Number(params.amount) > 99999999 || params.currency !== 'usd' || params.confirm !== true ||
|
|
108
|
+
typeof params.shared_payment_granted_token !== 'string' || !/^spt_[A-Za-z0-9_]+$/.test(params.shared_payment_granted_token) ||
|
|
109
|
+
params.payment_method_data !== undefined || params.payment_method !== undefined || typeof options.idempotencyKey !== 'string' || !options.idempotencyKey || options.idempotencyKey.length > 255) {
|
|
110
|
+
throw new CommerceError('stripe_request', 'MPP Stripe charge requires a bound USD amount, original SPT and idempotency key');
|
|
111
|
+
}
|
|
112
|
+
// mppx 0.13.1 supplies the older SDK argument. The pinned public Stripe API
|
|
113
|
+
// consumes SPTs under payment_method_data; preserve the same original token.
|
|
114
|
+
const { shared_payment_granted_token: token, ...rest } = params;
|
|
115
|
+
return validateIntent(await call('/v1/payment_intents', 'POST', { ...rest, payment_method_data: { shared_payment_granted_token: token } }, options));
|
|
80
116
|
},
|
|
81
|
-
async create(params, options) { return validateIntent(await call('/v1/payment_intents', 'POST', params, options)); },
|
|
82
117
|
async retrieve(id) {
|
|
83
118
|
if (!/^pi_[A-Za-z0-9]+$/.test(id))
|
|
84
119
|
throw new CommerceError('stripe_reference', 'Invalid original PaymentIntent');
|