@adminiumjs/add-on-contracts 0.3.31 → 0.3.33

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.
Files changed (45) hide show
  1. package/dist/add-on-block.d.ts +4 -0
  2. package/dist/add-on-block.d.ts.map +1 -1
  3. package/dist/contracts.d.ts +6 -0
  4. package/dist/contracts.d.ts.map +1 -1
  5. package/dist/contracts.js +16 -0
  6. package/dist/contracts.js.map +1 -1
  7. package/dist/document-render.d.ts +9 -2
  8. package/dist/document-render.d.ts.map +1 -1
  9. package/dist/document-render.js +4 -2
  10. package/dist/document-render.js.map +1 -1
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/payment-demo.d.ts +115 -0
  16. package/dist/payment-demo.d.ts.map +1 -0
  17. package/dist/payment-demo.js +659 -0
  18. package/dist/payment-demo.js.map +1 -0
  19. package/dist/payment-money.d.ts +29 -0
  20. package/dist/payment-money.d.ts.map +1 -0
  21. package/dist/payment-money.js +99 -0
  22. package/dist/payment-money.js.map +1 -0
  23. package/dist/payment-provider.d.ts +677 -0
  24. package/dist/payment-provider.d.ts.map +1 -0
  25. package/dist/payment-provider.js +260 -0
  26. package/dist/payment-provider.js.map +1 -0
  27. package/dist/runtime/app-types.d.ts +17 -0
  28. package/dist/runtime/app-types.d.ts.map +1 -1
  29. package/dist/runtime/app.d.ts +2 -0
  30. package/dist/runtime/app.d.ts.map +1 -1
  31. package/dist/runtime/app.js +2 -0
  32. package/dist/runtime/app.js.map +1 -1
  33. package/dist/runtime/index.d.ts +2 -2
  34. package/dist/runtime/index.d.ts.map +1 -1
  35. package/dist/runtime/index.js +2 -1
  36. package/dist/runtime/index.js.map +1 -1
  37. package/dist/testing/index.d.ts +1 -0
  38. package/dist/testing/index.d.ts.map +1 -1
  39. package/dist/testing/index.js +1 -0
  40. package/dist/testing/index.js.map +1 -1
  41. package/dist/testing/payment-provider.d.ts +43 -0
  42. package/dist/testing/payment-provider.d.ts.map +1 -0
  43. package/dist/testing/payment-provider.js +308 -0
  44. package/dist/testing/payment-provider.js.map +1 -0
  45. package/package.json +1 -1
@@ -0,0 +1,659 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ /**
3
+ * The demo payment provider: a pretend payment service, test mode only.
4
+ *
5
+ * ─── WHY IT IS A TRANSPORT AND A BACKEND ──────────────────────────────────
6
+ *
7
+ * The contract's factory is called fresh for every operation with only
8
+ * `{ http, credentials, mode }`, so a provider half has nowhere to keep a
9
+ * checkout between calls — exactly like a real one, whose state lives at the
10
+ * provider. The demo is built the same way: `createDemoPaymentProvider` is a
11
+ * real transport that speaks HTTP to `DEMO_PAYMENT_ORIGIN`, and
12
+ * `createDemoPaymentBackend` is the pretend provider behind it. The engine
13
+ * hands the transport a client that routes to the backend in process, and its
14
+ * `/pay/demo/<attempt>` page drives the backend as a customer would. Tests,
15
+ * an app's demo mode and the staff preview all run with no account.
16
+ *
17
+ * Its cards succeed, fail, need a step, and settle late. Every change is an
18
+ * event, signed like a real webhook (HMAC-SHA256 over `<t>.<body>`, Web Crypto,
19
+ * so this file keeps the package's no-`node:` rule).
20
+ *
21
+ * Nothing here is ever live: the backend refuses a live key, and a live-mode
22
+ * transport refuses every call before it is made.
23
+ */
24
+ import { decimalToMinor, isoExponent, minorToDecimal, subtractAmounts, compareAmounts } from './payment-money.js';
25
+ import { providerError, } from './payment-provider.js';
26
+ /** The demo's pretend API host. `.invalid` is reserved: nothing real can answer it. */
27
+ export const DEMO_PAYMENT_ORIGIN = 'https://demo-payments.invalid';
28
+ /** The demo's add-on key, as the engine registers it. */
29
+ export const DEMO_PAYMENT_KEY = 'demo';
30
+ /** The signature header the demo's webhooks carry. */
31
+ export const DEMO_SIGNATURE_HEADER = 'demo-signature';
32
+ /** A signature older than this is refused, as Stripe's is. */
33
+ export const DEMO_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
34
+ /** What a customer can do on the demo's hosted page. */
35
+ export const DEMO_CARDS = ['succeeds', 'fails', 'needs-a-step', 'settles-late'];
36
+ /** A test key the demo accepts: `demo_test_` and at least eight more characters. */
37
+ export const DEMO_KEY_PATTERN = /^demo_test_[A-Za-z0-9]{8,}$/;
38
+ const METHODS = [
39
+ { id: 'card', label: 'Demo card', delayed: false },
40
+ { id: 'bank_debit', label: 'Demo bank debit', delayed: true },
41
+ ];
42
+ function json(status, body) {
43
+ const text = JSON.stringify(body);
44
+ return {
45
+ status,
46
+ headers: { get: (name) => (name.toLowerCase() === 'content-type' ? 'application/json' : null) },
47
+ text: () => Promise.resolve(text),
48
+ };
49
+ }
50
+ async function hmacHex(secret, message) {
51
+ const encoder = new TextEncoder();
52
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
53
+ 'sign',
54
+ ]);
55
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
56
+ return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
57
+ }
58
+ /** Compares two hex strings in time that does not depend on where they differ. */
59
+ function sameHex(a, b) {
60
+ if (a.length !== b.length)
61
+ return false;
62
+ let diff = 0;
63
+ for (let i = 0; i < a.length; i += 1)
64
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
65
+ return diff === 0;
66
+ }
67
+ /** The signed header value for a body at a time. */
68
+ export async function signDemoWebhook(rawBody, webhookSecret, at) {
69
+ const t = Math.floor(at / 1000);
70
+ return `t=${t},v1=${await hmacHex(webhookSecret, `${t}.${rawBody}`)}`;
71
+ }
72
+ const ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
73
+ /**
74
+ * A new id: the prefix and 16 random base-36 characters (about 82 bits).
75
+ *
76
+ * Random, never counted: the engine keeps every checkout, refund and event id
77
+ * the demo gave (`adminium_payment_attempts` is unique on provider, mode and
78
+ * ref), and this backend lives in memory, so a counter started again at 1
79
+ * after every restart and handed out ids already taken.
80
+ */
81
+ export function demoId(prefix) {
82
+ let out = '';
83
+ while (out.length < 16) {
84
+ for (const byte of crypto.getRandomValues(new Uint8Array(24))) {
85
+ // 252 = 7 × 36: the bytes above it are dropped, so every character is equally likely.
86
+ if (byte < 252 && out.length < 16)
87
+ out += ID_ALPHABET[byte % 36];
88
+ }
89
+ }
90
+ return `${prefix}_${out}`;
91
+ }
92
+ /** How often, at most, the backend looks for what it may forget. */
93
+ const FORGET_EVERY_MS = 60_000;
94
+ /** The pretend provider. State lives in this object; one per engine process (or test). */
95
+ export function createDemoPaymentBackend(options = {}) {
96
+ const now = options.now ?? (() => Date.now());
97
+ const settleAfter = options.settleAfterMs ?? 60_000;
98
+ const lifetime = options.checkoutLifetimeMs ?? 60 * 60 * 1000;
99
+ const keepPaid = options.keepPaidMs ?? 7 * 24 * 60 * 60 * 1000;
100
+ const accountId = options.accountId ?? 'demo_account';
101
+ const pageUrl = options.pageUrl ?? ((checkout) => `${DEMO_PAYMENT_ORIGIN}/pay/${encodeURIComponent(checkout.id)}`);
102
+ const checkouts = new Map();
103
+ const checkoutByKey = new Map();
104
+ const refunds = new Map();
105
+ const refundByKey = new Map();
106
+ let events = [];
107
+ const nextId = demoId;
108
+ /**
109
+ * Forgets what nobody can ask about any more, so a long-running engine does
110
+ * not keep every pretend payment for its life: a checkout never paid, a
111
+ * lifetime after it expired; a paid one with its refunds, `keepPaid` after it
112
+ * was paid; an event, a lifetime after it happened (the sweep asks every few
113
+ * minutes). A late payment still on its way is kept. Like a restart, but
114
+ * only for what is long over.
115
+ */
116
+ let forgotAt = Number.NEGATIVE_INFINITY;
117
+ function forget() {
118
+ const at = now();
119
+ if (at - forgotAt < FORGET_EVERY_MS)
120
+ return;
121
+ forgotAt = at;
122
+ const gone = new Set();
123
+ for (const checkout of checkouts.values()) {
124
+ age(checkout);
125
+ const over = checkout.status === 'succeeded'
126
+ ? at > (checkout.settledAt ?? at) + keepPaid
127
+ : checkout.status !== 'processing' && at > checkout.expiresAt + lifetime;
128
+ if (!over)
129
+ continue;
130
+ checkouts.delete(checkout.id);
131
+ checkoutByKey.delete(checkout.idempotencyKey);
132
+ if (checkout.paymentRef !== null)
133
+ gone.add(checkout.paymentRef);
134
+ }
135
+ for (const refund of refunds.values()) {
136
+ if (!gone.has(refund.paymentRef))
137
+ continue;
138
+ refunds.delete(refund.id);
139
+ refundByKey.delete(refund.idempotencyKey);
140
+ }
141
+ events = events.filter((event) => at - event.occurredAt <= lifetime);
142
+ }
143
+ function emit(type, kind, refs) {
144
+ events.push({
145
+ id: nextId('evt'),
146
+ type,
147
+ kind,
148
+ checkoutRef: refs.checkout?.id ?? null,
149
+ paymentRef: refs.checkout?.paymentRef ?? refs.refund?.paymentRef ?? null,
150
+ refundRef: refs.refund?.id ?? null,
151
+ occurredAt: now(),
152
+ delivered: false,
153
+ });
154
+ }
155
+ /** Moves time-driven states forward before anyone reads them. */
156
+ function age(checkout) {
157
+ const at = now();
158
+ if (checkout.status === 'processing' && checkout.settleAt !== null && at >= checkout.settleAt) {
159
+ checkout.status = 'succeeded';
160
+ checkout.settledAt = checkout.settleAt;
161
+ emit('checkout.succeeded', 'checkout', { checkout });
162
+ }
163
+ if ((checkout.status === 'pending' || checkout.status === 'requires_action') && at >= checkout.expiresAt) {
164
+ checkout.status = 'expired';
165
+ emit('checkout.expired', 'checkout', { checkout });
166
+ }
167
+ return checkout;
168
+ }
169
+ function ageRefund(refund) {
170
+ if (refund.status === 'pending' && now() >= refund.settleAt) {
171
+ refund.status = 'succeeded';
172
+ emit('refund.succeeded', 'refund', { refund });
173
+ }
174
+ return refund;
175
+ }
176
+ function checkoutBody(checkout) {
177
+ return {
178
+ id: checkout.id,
179
+ attemptId: checkout.attemptId,
180
+ status: checkout.status,
181
+ amount: checkout.takenAmount,
182
+ currency: checkout.takenCurrency,
183
+ paymentRef: checkout.paymentRef,
184
+ paidWith: checkout.paidWith,
185
+ settledAt: checkout.settledAt,
186
+ failureMessage: checkout.failureMessage,
187
+ expiresAt: checkout.expiresAt,
188
+ url: pageUrl({ id: checkout.id, attemptId: checkout.attemptId, successUrl: checkout.successUrl }),
189
+ };
190
+ }
191
+ function refundBody(refund) {
192
+ return {
193
+ id: refund.id,
194
+ paymentRef: refund.paymentRef,
195
+ status: refund.status,
196
+ amount: refund.amount,
197
+ currency: refund.currency,
198
+ failureMessage: refund.failureMessage,
199
+ };
200
+ }
201
+ function refundedSoFar(paymentRef, currency) {
202
+ const exponent = isoExponent(currency) ?? 2;
203
+ let total = 0n;
204
+ for (const refund of refunds.values()) {
205
+ if (refund.paymentRef !== paymentRef || refund.status === 'failed' || refund.status === 'cancelled')
206
+ continue;
207
+ total += BigInt(decimalToMinor(refund.amount, exponent) ?? '0');
208
+ }
209
+ return minorToDecimal(total.toString(), exponent) ?? '0';
210
+ }
211
+ const http = async (url, init) => {
212
+ forget();
213
+ const parsed = new URL(url);
214
+ if (parsed.origin !== DEMO_PAYMENT_ORIGIN)
215
+ return json(404, { error: 'NOT_FOUND', message: 'No such host.' });
216
+ const auth = (init?.headers?.['authorization'] ?? init?.headers?.['Authorization'] ?? '').replace(/^Bearer /, '');
217
+ if (!DEMO_KEY_PATTERN.test(auth)) {
218
+ return json(401, { error: 'CREDENTIALS_REFUSED', message: 'That is not a demo test key.' });
219
+ }
220
+ const method = (init?.method ?? 'GET').toUpperCase();
221
+ const path = parsed.pathname.split('/').filter(Boolean);
222
+ const body = init?.body !== undefined && init.body !== '' ? JSON.parse(init.body) : {};
223
+ const idempotencyKey = init?.headers?.['idempotency-key'] ?? init?.headers?.['Idempotency-Key'] ?? null;
224
+ if (method === 'GET' && path[0] === 'account') {
225
+ return json(200, { accountId, livemode: false, missing: [] });
226
+ }
227
+ if (method === 'GET' && path[0] === 'methods') {
228
+ return json(200, { methods: METHODS });
229
+ }
230
+ if (path[0] === 'checkouts' && path.length === 1 && method === 'POST') {
231
+ if (idempotencyKey === null)
232
+ return json(400, { error: 'INVALID_REQUEST', message: 'An idempotency key is required.' });
233
+ const known = checkoutByKey.get(idempotencyKey);
234
+ if (known !== undefined)
235
+ return json(200, checkoutBody(age(checkouts.get(known))));
236
+ const checkout = {
237
+ id: nextId('cs'),
238
+ attemptId: String(body['attemptId']),
239
+ amount: String(body['amount']),
240
+ currency: String(body['currency']),
241
+ methods: Array.isArray(body['methods']) ? body['methods'] : [],
242
+ status: 'pending',
243
+ takenAmount: null,
244
+ takenCurrency: null,
245
+ paymentRef: null,
246
+ paidWith: null,
247
+ settledAt: null,
248
+ settleAt: null,
249
+ failureMessage: null,
250
+ expiresAt: now() + lifetime,
251
+ idempotencyKey,
252
+ successUrl: typeof body['successUrl'] === 'string' ? body['successUrl'] : null,
253
+ };
254
+ checkouts.set(checkout.id, checkout);
255
+ checkoutByKey.set(idempotencyKey, checkout.id);
256
+ emit('checkout.created', 'checkout', { checkout });
257
+ return json(200, checkoutBody(checkout));
258
+ }
259
+ if (path[0] === 'checkouts' && path[1] !== undefined) {
260
+ const checkout = checkouts.get(path[1]);
261
+ if (checkout === undefined)
262
+ return json(404, { error: 'NOT_FOUND', message: 'No such checkout.' });
263
+ age(checkout);
264
+ if (method === 'GET' && path.length === 2)
265
+ return json(200, checkoutBody(checkout));
266
+ if (method === 'POST' && path[2] === 'expire') {
267
+ if (checkout.status === 'succeeded' || checkout.status === 'processing') {
268
+ return json(409, { error: 'ALREADY_COMPLETED', message: 'The checkout has already completed.' });
269
+ }
270
+ if (checkout.status === 'pending' || checkout.status === 'requires_action') {
271
+ checkout.status = 'expired';
272
+ emit('checkout.expired', 'checkout', { checkout });
273
+ }
274
+ return json(200, checkoutBody(checkout));
275
+ }
276
+ if (method === 'POST' && path[2] === 'complete')
277
+ return json(200, checkoutBody(checkout));
278
+ }
279
+ if (path[0] === 'refunds' && path.length === 1 && method === 'POST') {
280
+ if (idempotencyKey === null)
281
+ return json(400, { error: 'INVALID_REQUEST', message: 'An idempotency key is required.' });
282
+ const known = refundByKey.get(idempotencyKey);
283
+ if (known !== undefined)
284
+ return json(200, refundBody(ageRefund(refunds.get(known))));
285
+ const paymentRef = String(body['paymentRef']);
286
+ const paid = [...checkouts.values()].find((one) => one.paymentRef === paymentRef);
287
+ if (paid === undefined)
288
+ return json(404, { error: 'NOT_FOUND', message: 'No such payment.' });
289
+ age(paid);
290
+ if (paid.status !== 'succeeded' || paid.takenAmount === null || paid.takenCurrency === null) {
291
+ return json(409, { error: 'REFUND_REFUSED', message: 'This payment has not succeeded.' });
292
+ }
293
+ const amount = String(body['amount']);
294
+ const currency = String(body['currency']);
295
+ if (currency !== paid.takenCurrency) {
296
+ return json(409, { error: 'REFUND_REFUSED', message: 'A refund is made in the currency that was taken.' });
297
+ }
298
+ const exponent = isoExponent(currency) ?? 2;
299
+ const left = subtractAmounts(paid.takenAmount, refundedSoFar(paymentRef, currency), exponent);
300
+ const fits = left !== null && compareAmounts(amount, left) !== 1 && compareAmounts(amount, '0') === 1;
301
+ if (!fits)
302
+ return json(409, { error: 'REFUND_REFUSED', message: 'That is more than can still be refunded.' });
303
+ const refund = {
304
+ id: nextId('re'),
305
+ paymentRef,
306
+ amount,
307
+ currency,
308
+ status: 'pending',
309
+ failureMessage: null,
310
+ settleAt: now() + settleAfter,
311
+ idempotencyKey,
312
+ };
313
+ refunds.set(refund.id, refund);
314
+ refundByKey.set(idempotencyKey, refund.id);
315
+ emit('refund.created', 'refund', { refund });
316
+ return json(200, refundBody(refund));
317
+ }
318
+ if (path[0] === 'refunds' && path[1] !== undefined && method === 'GET') {
319
+ const refund = refunds.get(path[1]);
320
+ if (refund === undefined)
321
+ return json(404, { error: 'NOT_FOUND', message: 'No such refund.' });
322
+ return json(200, refundBody(ageRefund(refund)));
323
+ }
324
+ if (path[0] === 'events' && method === 'GET') {
325
+ for (const one of checkouts.values())
326
+ age(one);
327
+ for (const one of refunds.values())
328
+ ageRefund(one);
329
+ const after = parsed.searchParams.get('after');
330
+ const start = after === null ? 0 : events.findIndex((one) => one.id === after) + 1;
331
+ const page = events.slice(start).filter((one) => !one.delivered).slice(0, 100);
332
+ const last = page.at(-1)?.id ?? after;
333
+ return json(200, {
334
+ events: page.map(({ delivered: _delivered, ...rest }) => rest),
335
+ cursor: last,
336
+ more: events.slice(start).filter((one) => !one.delivered).length > page.length,
337
+ });
338
+ }
339
+ return json(404, { error: 'NOT_FOUND', message: 'No such route.' });
340
+ };
341
+ function mustCheckout(id) {
342
+ const checkout = checkouts.get(id);
343
+ if (checkout === undefined)
344
+ throw new Error(`No demo checkout ${id}.`);
345
+ return age(checkout);
346
+ }
347
+ return {
348
+ http,
349
+ async pay(checkoutId, card, overrides = {}) {
350
+ const checkout = mustCheckout(checkoutId);
351
+ if (checkout.status !== 'pending' && checkout.status !== 'requires_action') {
352
+ throw new Error(`Demo checkout ${checkoutId} is ${checkout.status}.`);
353
+ }
354
+ const delayed = card === 'settles-late';
355
+ const method = delayed ? METHODS[1] : METHODS[0];
356
+ if (card === 'fails') {
357
+ checkout.status = 'failed';
358
+ checkout.failureMessage = 'The demo card was declined.';
359
+ emit('checkout.failed', 'checkout', { checkout });
360
+ return;
361
+ }
362
+ checkout.paymentRef = nextId('pay');
363
+ checkout.paidWith = { method: method.id, label: delayed ? method.label : 'Demo card ••4242', delayed };
364
+ checkout.takenAmount = overrides.amount ?? checkout.amount;
365
+ checkout.takenCurrency = overrides.currency ?? checkout.currency;
366
+ if (card === 'needs-a-step') {
367
+ checkout.status = 'requires_action';
368
+ emit('checkout.requires_action', 'checkout', { checkout });
369
+ return;
370
+ }
371
+ if (delayed) {
372
+ checkout.status = 'processing';
373
+ checkout.settleAt = now() + settleAfter;
374
+ emit('checkout.processing', 'checkout', { checkout });
375
+ return;
376
+ }
377
+ checkout.status = 'succeeded';
378
+ checkout.settledAt = now();
379
+ emit('checkout.succeeded', 'checkout', { checkout });
380
+ },
381
+ async finishStep(checkoutId) {
382
+ const checkout = mustCheckout(checkoutId);
383
+ if (checkout.status !== 'requires_action')
384
+ throw new Error(`Demo checkout ${checkoutId} needs no step.`);
385
+ checkout.status = 'succeeded';
386
+ checkout.settledAt = now();
387
+ emit('checkout.succeeded', 'checkout', { checkout });
388
+ },
389
+ async cancel(checkoutId) {
390
+ const checkout = mustCheckout(checkoutId);
391
+ if (checkout.status !== 'pending' && checkout.status !== 'requires_action')
392
+ return;
393
+ checkout.status = 'cancelled';
394
+ emit('checkout.cancelled', 'checkout', { checkout });
395
+ },
396
+ async settleNow(id) {
397
+ const checkout = checkouts.get(id);
398
+ if (checkout !== undefined && checkout.status === 'processing') {
399
+ checkout.settleAt = now();
400
+ age(checkout);
401
+ return;
402
+ }
403
+ const refund = refunds.get(id);
404
+ if (refund !== undefined && refund.status === 'pending') {
405
+ refund.settleAt = now();
406
+ ageRefund(refund);
407
+ return;
408
+ }
409
+ throw new Error(`Nothing of id ${id} is waiting to settle.`);
410
+ },
411
+ async dispute(paymentRef, closes = false) {
412
+ const checkout = [...checkouts.values()].find((one) => one.paymentRef === paymentRef);
413
+ if (checkout === undefined)
414
+ throw new Error(`No demo payment ${paymentRef}.`);
415
+ emit(closes ? 'charge.dispute.closed' : 'charge.dispute.created', 'dispute', { checkout });
416
+ },
417
+ async refundAtProvider(paymentRef, amount) {
418
+ const paid = [...checkouts.values()].find((one) => one.paymentRef === paymentRef);
419
+ if (paid === undefined || paid.takenCurrency === null)
420
+ throw new Error(`No demo payment ${paymentRef}.`);
421
+ const refund = {
422
+ id: nextId('re'),
423
+ paymentRef,
424
+ amount,
425
+ currency: paid.takenCurrency,
426
+ status: 'succeeded',
427
+ failureMessage: null,
428
+ settleAt: now(),
429
+ idempotencyKey: nextId('dash'),
430
+ };
431
+ refunds.set(refund.id, refund);
432
+ emit('refund.succeeded', 'refund', { refund });
433
+ return refund.id;
434
+ },
435
+ async failRefund(refundId) {
436
+ const refund = refunds.get(refundId);
437
+ if (refund === undefined)
438
+ throw new Error(`No demo refund ${refundId}.`);
439
+ ageRefund(refund);
440
+ refund.status = 'failed';
441
+ refund.failureMessage = 'The card was closed.';
442
+ emit('refund.failed', 'refund', { refund });
443
+ },
444
+ async deliveryFor(eventId, webhookSecret, at = now()) {
445
+ const event = events.find((one) => one.id === eventId);
446
+ if (event === undefined)
447
+ throw new Error(`No demo event ${eventId}.`);
448
+ const { delivered: _delivered, ...rest } = event;
449
+ const rawBody = JSON.stringify({ ...rest, livemode: false });
450
+ return { rawBody, headers: { [DEMO_SIGNATURE_HEADER]: await signDemoWebhook(rawBody, webhookSecret, at) } };
451
+ },
452
+ events() {
453
+ return events.map((one) => ({ id: one.id, type: one.type, checkoutRef: one.checkoutRef, refundRef: one.refundRef }));
454
+ },
455
+ markDelivered(eventId) {
456
+ const event = events.find((one) => one.id === eventId);
457
+ if (event !== undefined)
458
+ event.delivered = true;
459
+ },
460
+ checkoutForAttempt(attemptId) {
461
+ const checkout = [...checkouts.values()].find((one) => one.attemptId === attemptId);
462
+ if (checkout === undefined)
463
+ return null;
464
+ age(checkout);
465
+ return { id: checkout.id, amount: checkout.amount, currency: checkout.currency, status: checkout.status };
466
+ },
467
+ };
468
+ }
469
+ // ─── The transport ──────────────────────────────────────────────────────────
470
+ const ERROR_CODES = new Set([
471
+ 'CREDENTIALS_REFUSED',
472
+ 'NOT_FOUND',
473
+ 'REFUND_REFUSED',
474
+ 'ALREADY_COMPLETED',
475
+ 'INVALID_REQUEST',
476
+ ]);
477
+ async function call(context, method, path, body, idempotencyKey) {
478
+ if (context.mode !== 'test')
479
+ return providerError('MODE_MISMATCH', 'The demo provider works in test mode only.');
480
+ const key = context.credentials['secretKey'] ?? '';
481
+ const headers = { authorization: `Bearer ${key}`, 'content-type': 'application/json' };
482
+ if (idempotencyKey !== undefined)
483
+ headers['idempotency-key'] = idempotencyKey;
484
+ let response;
485
+ try {
486
+ response = await context.http(`${DEMO_PAYMENT_ORIGIN}${path}`, {
487
+ method,
488
+ headers,
489
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
490
+ });
491
+ }
492
+ catch {
493
+ return providerError('UNAVAILABLE', 'The demo provider could not be reached.', true);
494
+ }
495
+ let parsed;
496
+ try {
497
+ parsed = JSON.parse(await response.text());
498
+ }
499
+ catch {
500
+ return providerError('UNAVAILABLE', 'The demo provider answered with something that is not JSON.', true);
501
+ }
502
+ if (response.status >= 200 && response.status < 300)
503
+ return parsed;
504
+ const code = String(parsed['error'] ?? '');
505
+ const message = String(parsed['message'] ?? 'The demo provider refused the request.');
506
+ if (ERROR_CODES.has(code))
507
+ return providerError(code, message);
508
+ return providerError('UNAVAILABLE', message, response.status >= 500);
509
+ }
510
+ function stateOf(body) {
511
+ return {
512
+ ok: true,
513
+ ref: String(body['id']),
514
+ status: body['status'],
515
+ attemptId: typeof body['attemptId'] === 'string' ? body['attemptId'] : null,
516
+ amount: body['amount'] ?? null,
517
+ currency: body['currency'] ?? null,
518
+ livemode: false,
519
+ paymentRef: body['paymentRef'] ?? null,
520
+ paidWith: body['paidWith'] ?? null,
521
+ settledAt: body['settledAt'] ?? null,
522
+ failureMessage: body['failureMessage'] ?? null,
523
+ };
524
+ }
525
+ function refundOf(body) {
526
+ return {
527
+ ok: true,
528
+ ref: String(body['id']),
529
+ paymentRef: String(body['paymentRef']),
530
+ status: body['status'],
531
+ amount: String(body['amount']),
532
+ currency: String(body['currency']),
533
+ failureMessage: body['failureMessage'] ?? null,
534
+ };
535
+ }
536
+ function isError(value) {
537
+ return value.ok === false;
538
+ }
539
+ /** The demo's server half: the same factory shape a real provider exports. */
540
+ export function createDemoPaymentProvider(context) {
541
+ return {
542
+ key: DEMO_PAYMENT_KEY,
543
+ mode: context.mode,
544
+ async checkCredentials() {
545
+ const body = await call(context, 'GET', '/account');
546
+ if (isError(body))
547
+ return body;
548
+ return { ok: true, accountId: String(body['accountId']), livemode: false, missing: [] };
549
+ },
550
+ async createCheckout(input) {
551
+ const minor = this.toMinor(input.payable.amount, input.payable.currency);
552
+ if (isError(minor))
553
+ return minor;
554
+ const body = await call(context, 'POST', '/checkouts', {
555
+ attemptId: input.attemptId,
556
+ amount: input.payable.amount,
557
+ currency: input.payable.currency,
558
+ methods: input.methods,
559
+ successUrl: input.successUrl,
560
+ }, input.idempotencyKey);
561
+ if (isError(body))
562
+ return body;
563
+ return { ok: true, ref: String(body['id']), url: String(body['url']), expiresAt: Number(body['expiresAt']) };
564
+ },
565
+ async status(ref) {
566
+ const body = await call(context, 'GET', `/checkouts/${encodeURIComponent(ref)}`);
567
+ return isError(body) ? body : stateOf(body);
568
+ },
569
+ async complete(ref, idempotencyKey) {
570
+ const body = await call(context, 'POST', `/checkouts/${encodeURIComponent(ref)}/complete`, {}, idempotencyKey);
571
+ return isError(body) ? body : stateOf(body);
572
+ },
573
+ async expire(ref) {
574
+ const body = await call(context, 'POST', `/checkouts/${encodeURIComponent(ref)}/expire`, {});
575
+ if (isError(body))
576
+ return body;
577
+ return { ok: true, ref: String(body['id']), status: body['status'] };
578
+ },
579
+ async refund(input) {
580
+ const body = await call(context, 'POST', '/refunds', { paymentRef: input.paymentRef, amount: input.amount, currency: input.currency, reason: input.reason }, input.idempotencyKey);
581
+ return isError(body) ? body : refundOf(body);
582
+ },
583
+ async refundStatus(ref) {
584
+ const body = await call(context, 'GET', `/refunds/${encodeURIComponent(ref)}`);
585
+ return isError(body) ? body : refundOf(body);
586
+ },
587
+ async verifyWebhook(delivery) {
588
+ if (context.mode !== 'test')
589
+ return providerError('MODE_MISMATCH', 'The demo provider works in test mode only.');
590
+ const secret = context.credentials['webhookSecret'];
591
+ if (secret === undefined || secret === '')
592
+ return providerError('SIGNATURE_INVALID', 'No webhook secret is set.');
593
+ const header = delivery.headers[DEMO_SIGNATURE_HEADER] ?? '';
594
+ const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
595
+ if (match === null)
596
+ return providerError('SIGNATURE_INVALID', 'The signature header is missing or malformed.');
597
+ const t = Number(match[1]);
598
+ if (Math.abs(Date.now() - t * 1000) > DEMO_SIGNATURE_TOLERANCE_MS) {
599
+ return providerError('SIGNATURE_INVALID', 'The signature is too old.');
600
+ }
601
+ const expected = await hmacHex(secret, `${t}.${delivery.rawBody}`);
602
+ if (!sameHex(expected, match[2] ?? ''))
603
+ return providerError('SIGNATURE_INVALID', 'The signature does not match.');
604
+ let body;
605
+ try {
606
+ body = JSON.parse(delivery.rawBody);
607
+ }
608
+ catch {
609
+ return providerError('INVALID_REQUEST', 'The event is not JSON.');
610
+ }
611
+ return eventOf(body);
612
+ },
613
+ async listMissedEvents(cursor) {
614
+ const query = cursor === null ? '' : `?after=${encodeURIComponent(cursor)}`;
615
+ const body = await call(context, 'GET', `/events${query}`);
616
+ if (isError(body))
617
+ return body;
618
+ const events = body['events'].map(eventOf);
619
+ return { ok: true, events, cursor: body['cursor'] ?? null, more: body['more'] === true };
620
+ },
621
+ async methodsFor() {
622
+ const body = await call(context, 'GET', '/methods');
623
+ if (isError(body))
624
+ return body;
625
+ return { ok: true, methods: body['methods'] };
626
+ },
627
+ toMinor(amount, currency) {
628
+ const exponent = isoExponent(currency);
629
+ if (exponent === null)
630
+ return providerError('CURRENCY_UNSUPPORTED', `The demo provider does not take ${currency}.`);
631
+ const minor = decimalToMinor(amount, exponent);
632
+ if (minor === null) {
633
+ return providerError('AMOUNT_UNREPRESENTABLE', `${amount} ${currency} cannot be held exactly in ${currency}'s units.`);
634
+ }
635
+ return { minor, currency };
636
+ },
637
+ fromMinor(minor, currency) {
638
+ const exponent = isoExponent(currency);
639
+ if (exponent === null)
640
+ return providerError('CURRENCY_UNSUPPORTED', `The demo provider does not take ${currency}.`);
641
+ const amount = minorToDecimal(minor, exponent);
642
+ return amount ?? providerError('INVALID_REQUEST', `${minor} is not an amount in the smallest unit.`);
643
+ },
644
+ };
645
+ }
646
+ function eventOf(body) {
647
+ return {
648
+ ok: true,
649
+ eventId: String(body['id']),
650
+ type: String(body['type']),
651
+ kind: body['kind'],
652
+ checkoutRef: body['checkoutRef'] ?? null,
653
+ paymentRef: body['paymentRef'] ?? null,
654
+ refundRef: body['refundRef'] ?? null,
655
+ livemode: body['livemode'] === true,
656
+ occurredAt: Number(body['occurredAt']),
657
+ };
658
+ }
659
+ //# sourceMappingURL=payment-demo.js.map