@tuppence/js 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1304 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+
33
+ // src/appearance.ts
34
+ function appearanceFor(branding) {
35
+ const accent = branding?.accent_color ?? TUPPENCE_BLUE;
36
+ return {
37
+ theme: "stripe",
38
+ variables: {
39
+ colorPrimary: accent,
40
+ colorText: "#1f2937",
41
+ colorDanger: "#b42318",
42
+ fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
43
+ fontSizeBase: "16px",
44
+ borderRadius: "8px",
45
+ spacingUnit: "4px"
46
+ },
47
+ rules: {
48
+ ".Input:focus": { borderColor: accent, boxShadow: `0 0 0 2px ${accent}33` }
49
+ }
50
+ };
51
+ }
52
+ var TUPPENCE_BLUE;
53
+ var init_appearance = __esm({
54
+ "src/appearance.ts"() {
55
+ "use strict";
56
+ TUPPENCE_BLUE = "#3b5bff";
57
+ }
58
+ });
59
+
60
+ // src/errors.ts
61
+ var TuppenceError;
62
+ var init_errors = __esm({
63
+ "src/errors.ts"() {
64
+ "use strict";
65
+ TuppenceError = class _TuppenceError extends Error {
66
+ constructor(fields) {
67
+ super(fields.message);
68
+ this.name = "TuppenceError";
69
+ this.type = fields.type;
70
+ this.code = fields.code ?? null;
71
+ this.declineCode = fields.declineCode ?? null;
72
+ this.param = fields.param ?? null;
73
+ this.status = fields.status ?? 0;
74
+ }
75
+ /** The API's envelope: `{ error: { type, code, message, param, decline_code } }`. */
76
+ static fromEnvelope(status, body) {
77
+ const error = body?.error ?? {};
78
+ const text = (key) => typeof error[key] === "string" ? String(error[key]) : null;
79
+ return new _TuppenceError({
80
+ type: text("type") ?? "api_error",
81
+ message: text("message") ?? `Tuppence answered ${status}.`,
82
+ code: text("code"),
83
+ declineCode: text("decline_code"),
84
+ param: text("param"),
85
+ status
86
+ });
87
+ }
88
+ /** Stripe.js's error (card fields, a confirmation token, a bank's check). */
89
+ static fromStripe(error) {
90
+ return new _TuppenceError({
91
+ type: error.type ?? "api_error",
92
+ message: error.message ?? "Stripe could not take the card.",
93
+ code: error.code ?? null,
94
+ declineCode: error.decline_code ?? null,
95
+ param: error.param ?? null
96
+ });
97
+ }
98
+ };
99
+ }
100
+ });
101
+
102
+ // src/express-mount.ts
103
+ var express_mount_exports = {};
104
+ __export(express_mount_exports, {
105
+ Wallet: () => Wallet,
106
+ asCartAddress: () => asCartAddress,
107
+ cartAddressOf: () => cartAddressOf,
108
+ mountAddress: () => mountAddress
109
+ });
110
+ function cartAddressOf(address) {
111
+ const region = address.state?.trim().toUpperCase() ?? "";
112
+ const postcode = address.postal_code?.trim() ?? "";
113
+ return {
114
+ country: (address.country ?? "").trim().toUpperCase(),
115
+ region: /^[A-Z0-9]{1,3}$/.test(region) ? region : null,
116
+ postcode: postcode && postcode.length <= 12 ? postcode : null
117
+ };
118
+ }
119
+ function daysTo(date, today) {
120
+ const from = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
121
+ return Math.max(0, Math.round((Date.parse(`${date}T00:00:00Z`) - from) / DAY_MS));
122
+ }
123
+ async function mountAddress(host, tuppence, from, options, target) {
124
+ const elements = "create" in from ? from : await tuppence.elements(from, {
125
+ appearance: options.appearance ?? appearanceFor(from.branding)
126
+ });
127
+ if (host.destroyed()) return;
128
+ if (!elements) return host.emit("error", noStripe("Stripe address field"));
129
+ const { mode, defaultValues, allowedCountries, phone } = options;
130
+ const element = elements.create("address", {
131
+ mode,
132
+ autocomplete: { mode: "automatic" },
133
+ ...defaultValues ? { defaultValues } : {},
134
+ ...allowedCountries ? { allowedCountries } : {},
135
+ ...phone ? { fields: { phone } } : {}
136
+ });
137
+ host.attach(element);
138
+ element.on("ready", () => host.emit("ready", void 0));
139
+ element.on(
140
+ "change",
141
+ (event) => host.emit("change", {
142
+ complete: event.complete,
143
+ empty: event.empty,
144
+ value: event.value,
145
+ address: asCartAddress(event.value)
146
+ })
147
+ );
148
+ element.on("loaderror", (event) => host.emit("error", TuppenceError.fromStripe(event.error)));
149
+ element.mount(target);
150
+ }
151
+ var QUOTE_TIMEOUT_MS, DAY_MS, asCartAddress, Wallet, noDelivery;
152
+ var init_express_mount = __esm({
153
+ "src/express-mount.ts"() {
154
+ "use strict";
155
+ init_appearance();
156
+ init_errors();
157
+ init_express();
158
+ QUOTE_TIMEOUT_MS = 15e3;
159
+ DAY_MS = 864e5;
160
+ asCartAddress = (value) => value?.address?.country ? cartAddressOf(value.address) : null;
161
+ Wallet = class {
162
+ constructor(host, tuppence, payable, options, cart) {
163
+ this.host = host;
164
+ this.tuppence = tuppence;
165
+ this.payable = payable;
166
+ this.options = options;
167
+ this.cart = cart;
168
+ this.address = null;
169
+ this.delivery = null;
170
+ this.elements = null;
171
+ this.basket = options.basket ?? null;
172
+ this.amount = payable.amount;
173
+ }
174
+ /** The delivery choices as the sheet lists them: the chosen one first, as Stripe selects it. */
175
+ ratesOf(basket) {
176
+ const today = this.options.now?.() ?? /* @__PURE__ */ new Date();
177
+ const options = [...basket?.delivery_options ?? []].sort(
178
+ (a, b) => Number(b.selected) - Number(a.selected)
179
+ );
180
+ return options.map((o) => ({
181
+ id: o.key,
182
+ displayName: o.name,
183
+ amount: o.amount,
184
+ deliveryEstimate: {
185
+ minimum: { unit: "day", value: daysTo(o.earliest, today) },
186
+ maximum: { unit: "day", value: daysTo(o.latest, today) }
187
+ }
188
+ }));
189
+ }
190
+ /** A quote, or why there is none: refused after 15 s rather than left to hang the sheet. */
191
+ async within(ask) {
192
+ let timer;
193
+ const late = new Promise((_, reject) => {
194
+ timer = setTimeout(
195
+ () => reject(
196
+ new TuppenceError({
197
+ type: "api_connection_error",
198
+ code: "quote_timeout",
199
+ message: "The delivery prices took too long to come back. Try again."
200
+ })
201
+ ),
202
+ QUOTE_TIMEOUT_MS
203
+ );
204
+ });
205
+ try {
206
+ return await Promise.race([ask(), late]);
207
+ } finally {
208
+ clearTimeout(timer);
209
+ }
210
+ }
211
+ /** The quote's answer for the sheet, or the reason it can't be delivered. */
212
+ deliverable(basket) {
213
+ if (!basket.totals || basket.delivery_options.length === 0) {
214
+ throw new TuppenceError({
215
+ type: "invalid_request_error",
216
+ code: basket.delivery_refusal?.code ?? "no_delivery_options",
217
+ message: basket.delivery_refusal?.message ?? "This basket can't be delivered there."
218
+ });
219
+ }
220
+ return basket;
221
+ }
222
+ priced(basket) {
223
+ this.basket = basket;
224
+ this.delivery = basket.delivery_options.find((o) => o.selected)?.key ?? null;
225
+ this.amount = basket.totals?.due ?? this.amount;
226
+ this.elements?.update({ amount: this.amount });
227
+ this.host.emit("total", basket);
228
+ }
229
+ async addressChanged(event) {
230
+ const address = cartAddressOf(event.address);
231
+ const delivery = this.delivery;
232
+ try {
233
+ const ask = this.options.onShippingAddressChange ?? (() => this.cart.quote({ address, delivery }));
234
+ const basket = this.deliverable(
235
+ await this.within(() => ask({ name: event.name, address, delivery }))
236
+ );
237
+ if (this.host.destroyed()) return;
238
+ this.address = address;
239
+ this.priced(basket);
240
+ event.resolve({ shippingRates: this.ratesOf(basket) });
241
+ } catch (caught) {
242
+ event.reject();
243
+ this.host.emit("error", wrap(caught));
244
+ }
245
+ }
246
+ async rateChanged(event) {
247
+ const delivery = event.shippingRate.id;
248
+ const address = this.address;
249
+ try {
250
+ const ask = this.options.onShippingRateChange ?? (() => this.cart.quote({ address, delivery }));
251
+ const basket = this.deliverable(await this.within(() => ask({ delivery, address })));
252
+ if (this.host.destroyed()) return;
253
+ if (!basket.delivery_options.some((o) => o.key === delivery && o.selected)) {
254
+ throw new TuppenceError({
255
+ type: "invalid_request_error",
256
+ code: "delivery_unavailable",
257
+ message: "That delivery choice isn't available any more. Choose another."
258
+ });
259
+ }
260
+ this.priced(basket);
261
+ event.resolve();
262
+ } catch (caught) {
263
+ event.reject();
264
+ this.host.emit("error", wrap(caught));
265
+ }
266
+ }
267
+ async confirmed(event) {
268
+ const elements = this.elements;
269
+ if (!elements) return;
270
+ const failed = (error) => {
271
+ event.paymentFailed({ reason: "fail", message: error.message });
272
+ this.host.emit("error", error);
273
+ };
274
+ const token = await this.tuppence.confirmationToken(elements);
275
+ if ("error" in token) return failed(token.error);
276
+ try {
277
+ const outcome = await this.options.onConfirm({
278
+ confirmationToken: token.id,
279
+ amount: this.amount,
280
+ delivery: event.shippingRate?.id ?? (this.options.shipping === false ? null : this.delivery),
281
+ email: event.billingDetails?.email ?? null,
282
+ phone: event.billingDetails?.phone ?? null,
283
+ shippingAddress: event.shippingAddress ?? null,
284
+ billingDetails: event.billingDetails ?? null,
285
+ expressPaymentType: event.expressPaymentType,
286
+ basket: this.basket
287
+ });
288
+ const refusal = outcome?.error;
289
+ if (refusal) {
290
+ failed(
291
+ new TuppenceError({
292
+ type: "api_error",
293
+ message: typeof refusal.message === "string" ? refusal.message : "The payment didn't go through."
294
+ })
295
+ );
296
+ }
297
+ } catch (caught) {
298
+ failed(wrap(caught));
299
+ }
300
+ }
301
+ async load(target) {
302
+ const shipping = this.options.shipping !== false;
303
+ const [elements, basket] = await Promise.all([
304
+ this.tuppence.elements(this.payable, {
305
+ appearance: this.options.appearance ?? appearanceFor(this.payable.branding)
306
+ }),
307
+ this.basket || !shipping ? this.basket : this.cart.get()
308
+ ]);
309
+ if (this.host.destroyed()) return;
310
+ if (!elements) return this.host.emit("error", noStripe("wallet"));
311
+ this.elements = elements;
312
+ this.basket = basket;
313
+ this.delivery = basket?.delivery_options.find((o) => o.selected)?.key ?? null;
314
+ const name = this.payable.branding?.display_name;
315
+ const element = elements.create("expressCheckout", {
316
+ ...this.options.buttons,
317
+ emailRequired: true,
318
+ phoneNumberRequired: this.options.phone === true,
319
+ shippingAddressRequired: shipping,
320
+ ...this.options.allowedShippingCountries ? { allowedShippingCountries: this.options.allowedShippingCountries } : {},
321
+ ...name ? { business: { name } } : {}
322
+ });
323
+ this.host.attach(element);
324
+ element.on(
325
+ "ready",
326
+ (event) => this.host.emit(
327
+ "ready",
328
+ Object.entries(event.availablePaymentMethods ?? {}).filter(([, on]) => on).map(([method]) => method)
329
+ )
330
+ );
331
+ element.on(
332
+ "loaderror",
333
+ (event) => this.host.emit("error", TuppenceError.fromStripe(event.error))
334
+ );
335
+ element.on("click", (event) => {
336
+ const rates = this.ratesOf(this.basket);
337
+ if (shipping && rates.length === 0) {
338
+ event.reject();
339
+ return this.host.emit("error", noDelivery(this.basket));
340
+ }
341
+ event.resolve(shipping ? { shippingRates: rates } : {});
342
+ });
343
+ element.on("shippingaddresschange", (event) => void this.addressChanged(event));
344
+ element.on("shippingratechange", (event) => void this.rateChanged(event));
345
+ element.on("cancel", () => {
346
+ this.amount = this.payable.amount;
347
+ this.address = null;
348
+ elements.update({ amount: this.amount });
349
+ this.host.emit("cancel", void 0);
350
+ });
351
+ element.on("confirm", (event) => void this.confirmed(event));
352
+ element.mount(target);
353
+ }
354
+ };
355
+ noDelivery = (basket) => new TuppenceError({
356
+ type: "invalid_request_error",
357
+ code: basket?.delivery_refusal?.code ?? "no_delivery_options",
358
+ message: basket?.delivery_refusal?.message ?? "This basket has no delivery options yet."
359
+ });
360
+ }
361
+ });
362
+
363
+ // src/express.ts
364
+ var mounting, wrap, noStripe, Mountable, ExpressCheckout, AddressField;
365
+ var init_express = __esm({
366
+ "src/express.ts"() {
367
+ "use strict";
368
+ init_errors();
369
+ mounting = () => Promise.resolve().then(() => (init_express_mount(), express_mount_exports));
370
+ wrap = (caught) => caught instanceof TuppenceError ? caught : new TuppenceError({ type: "api_error", message: String(caught) });
371
+ noStripe = (what) => new TuppenceError({
372
+ type: "invalid_request_error",
373
+ code: "no_card_fields",
374
+ message: `There is no ${what} here: this environment runs the simulated gateway.`
375
+ });
376
+ Mountable = class {
377
+ constructor() {
378
+ this.handlers = {};
379
+ this.mounted = null;
380
+ this.destroyed = false;
381
+ this.element = null;
382
+ }
383
+ on(event, handler) {
384
+ const list = this.handlers[event] ?? [];
385
+ list.push(handler);
386
+ this.handlers[event] = list;
387
+ return this;
388
+ }
389
+ /** Shows it in `target` (an element, or a selector). Loads Stripe.js if it must. */
390
+ mount(target) {
391
+ this.destroyed = false;
392
+ this.mounted ?? (this.mounted = this.load(target).catch(
393
+ (caught) => this.emit("error", wrap(caught))
394
+ ));
395
+ return this;
396
+ }
397
+ /** Takes it off the page (or keeps it off, while it loads) and says nothing more. */
398
+ destroy() {
399
+ this.destroyed = true;
400
+ this.mounted = null;
401
+ this.element?.destroy();
402
+ this.element = null;
403
+ }
404
+ emit(event, detail) {
405
+ if (this.destroyed) return;
406
+ for (const handler of this.handlers[event] ?? []) handler(detail);
407
+ }
408
+ /** What the mounted half (`./express-mount.js`) may do to this. */
409
+ host() {
410
+ return {
411
+ emit: (event, detail) => this.emit(event, detail),
412
+ destroyed: () => this.destroyed,
413
+ attach: (element) => {
414
+ this.element = element;
415
+ }
416
+ };
417
+ }
418
+ };
419
+ ExpressCheckout = class extends Mountable {
420
+ constructor(tuppence, payable, options, cart) {
421
+ super();
422
+ this.tuppence = tuppence;
423
+ this.payable = payable;
424
+ this.options = options;
425
+ this.cart = cart;
426
+ this.wallet = null;
427
+ }
428
+ async load(target) {
429
+ const { Wallet: Wallet2 } = await mounting();
430
+ if (this.destroyed) return;
431
+ this.wallet ?? (this.wallet = new Wallet2(this.host(), this.tuppence, this.payable, this.options, this.cart));
432
+ await this.wallet.load(target);
433
+ }
434
+ };
435
+ AddressField = class extends Mountable {
436
+ constructor(tuppence, from, options) {
437
+ super();
438
+ this.tuppence = tuppence;
439
+ this.from = from;
440
+ this.options = options;
441
+ }
442
+ /** The address, checked: Stripe shows what is missing beside its field. */
443
+ async value() {
444
+ await this.mounted;
445
+ const element = this.element;
446
+ if (!element) throw new TypeError("Mount the address, and let it load, before reading it.");
447
+ const [{ complete, value }, { asCartAddress: asCartAddress2 }] = await Promise.all([
448
+ element.getValue(),
449
+ mounting()
450
+ ]);
451
+ return { complete, value, address: asCartAddress2(value) };
452
+ }
453
+ async load(target) {
454
+ const { mountAddress: mountAddress2 } = await mounting();
455
+ if (this.destroyed) return;
456
+ await mountAddress2(this.host(), this.tuppence, this.from, this.options, target);
457
+ }
458
+ };
459
+ }
460
+ });
461
+
462
+ // src/index.ts
463
+ var index_exports = {};
464
+ __export(index_exports, {
465
+ StorefrontCartClient: () => StorefrontCartClient,
466
+ Tuppence: () => Tuppence,
467
+ TuppenceError: () => TuppenceError,
468
+ appearanceFor: () => appearanceFor,
469
+ createTuppence: () => createTuppence,
470
+ loadTuppence: () => loadTuppence,
471
+ stripeLocale: () => stripeLocale
472
+ });
473
+ module.exports = __toCommonJS(index_exports);
474
+ init_appearance();
475
+ init_errors();
476
+
477
+ // src/locale.ts
478
+ var STRIPE_LOCALES = /* @__PURE__ */ new Set([
479
+ "ar",
480
+ "bg",
481
+ "cs",
482
+ "da",
483
+ "de",
484
+ "el",
485
+ "en",
486
+ "en-AU",
487
+ "en-CA",
488
+ "en-NZ",
489
+ "en-GB",
490
+ "es",
491
+ "es-ES",
492
+ "es-419",
493
+ "et",
494
+ "fi",
495
+ "fil",
496
+ "fr",
497
+ "fr-CA",
498
+ "fr-FR",
499
+ "he",
500
+ "hu",
501
+ "hr",
502
+ "id",
503
+ "it",
504
+ "it-IT",
505
+ "ja",
506
+ "ko",
507
+ "lt",
508
+ "lv",
509
+ "ms",
510
+ "mt",
511
+ "nb",
512
+ "nl",
513
+ "no",
514
+ "pl",
515
+ "pt",
516
+ "pt-BR",
517
+ "ro",
518
+ "ru",
519
+ "sk",
520
+ "sl",
521
+ "sv",
522
+ "th",
523
+ "tr",
524
+ "vi",
525
+ "zh",
526
+ "zh-HK",
527
+ "zh-TW"
528
+ ]);
529
+ function stripeLocale(tag) {
530
+ const parts = (tag ?? "").trim().replace(/_/g, "-").split("-").filter(Boolean);
531
+ const language = parts[0]?.toLowerCase();
532
+ if (!language || !/^[a-z]{2,3}$/.test(language)) return "auto";
533
+ const region = parts.slice(1).find((p) => /^([A-Za-z]{2}|\d{3})$/.test(p));
534
+ const variant = region ? `${language}-${region.toUpperCase()}` : null;
535
+ if (language === "pt") return variant === "pt-BR" ? "pt-BR" : "en";
536
+ if (variant && STRIPE_LOCALES.has(variant)) return variant;
537
+ return STRIPE_LOCALES.has(language) ? language : "en";
538
+ }
539
+ function browserLanguage() {
540
+ const nav = globalThis.navigator;
541
+ return typeof nav?.language === "string" && nav.language ? nav.language : void 0;
542
+ }
543
+
544
+ // src/http.ts
545
+ init_errors();
546
+ var READ_RETRIES = 2;
547
+ var RETRY_BASE_MS = 250;
548
+ var RETRY_MAX_MS = 2e3;
549
+ var TRANSIENT = /* @__PURE__ */ new Set([429, 502, 503, 504]);
550
+ var Http = class {
551
+ constructor(base, fetchImpl, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), credentials = "omit") {
552
+ this.base = base;
553
+ this.fetchImpl = fetchImpl;
554
+ this.sleep = sleep;
555
+ this.credentials = credentials;
556
+ }
557
+ get(path, headers) {
558
+ return this.request("GET", path, headers);
559
+ }
560
+ post(path, headers, body) {
561
+ return this.request("POST", path, headers, body);
562
+ }
563
+ async request(method, path, headers, body) {
564
+ const retries = method === "GET" ? READ_RETRIES : 0;
565
+ for (let attempt = 0; ; attempt += 1) {
566
+ const wait = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
567
+ let response;
568
+ try {
569
+ response = await this.fetchImpl(`${this.base}${path}`, {
570
+ method,
571
+ credentials: this.credentials,
572
+ headers: {
573
+ Accept: "application/json",
574
+ ...body === void 0 ? {} : { "Content-Type": "application/json" },
575
+ ...headers
576
+ },
577
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
578
+ });
579
+ } catch {
580
+ if (attempt < retries) {
581
+ await this.sleep(wait);
582
+ continue;
583
+ }
584
+ throw new TuppenceError({
585
+ type: "api_connection_error",
586
+ message: "Tuppence could not be reached. Check the connection and try again."
587
+ });
588
+ }
589
+ const json = await response.json().catch(() => null);
590
+ if (response.ok) return json;
591
+ if (attempt < retries && TRANSIENT.has(response.status)) {
592
+ const after = Number(response.headers.get("retry-after"));
593
+ await this.sleep(after > 0 ? Math.min(after * 1e3, RETRY_MAX_MS) : wait);
594
+ continue;
595
+ }
596
+ throw TuppenceError.fromEnvelope(response.status, json);
597
+ }
598
+ }
599
+ };
600
+
601
+ // src/storefront.ts
602
+ var StorefrontCartClient = class {
603
+ constructor(fetchImpl, options = {}) {
604
+ const base = (options.base ?? "").replace(/\/+$/, "");
605
+ const headless = /^https?:\/\//.test(base);
606
+ this.http = new Http(base, fetchImpl, void 0, headless ? "omit" : "same-origin");
607
+ this.headers = {
608
+ ...options.publishableKey ? { "Tuppence-Publishable-Key": options.publishableKey } : {},
609
+ ...options.cartToken ? { "Tuppence-Cart": options.cartToken } : {}
610
+ };
611
+ }
612
+ /** The shopper's basket on this store, priced. */
613
+ get() {
614
+ return this.http.get("/storefront/v1/cart", this.headers);
615
+ }
616
+ /** The totals for an address and a delivery choice, changing nothing. */
617
+ quote(input) {
618
+ return this.http.post("/storefront/v1/cart/quote", this.headers, {
619
+ address: input.address ?? null,
620
+ delivery: input.delivery ?? null
621
+ });
622
+ }
623
+ };
624
+
625
+ // src/bank-details.ts
626
+ init_errors();
627
+ var invalid = (message, param) => new TuppenceError({ type: "invalid_request_error", code: "parameter_invalid", message, param });
628
+ var IBAN_LENGTHS = {
629
+ AT: 20,
630
+ BE: 16,
631
+ CY: 28,
632
+ DE: 22,
633
+ EE: 20,
634
+ ES: 24,
635
+ FI: 18,
636
+ FR: 27,
637
+ GR: 27,
638
+ HR: 21,
639
+ IE: 22,
640
+ IT: 27,
641
+ LT: 20,
642
+ LU: 20,
643
+ LV: 21,
644
+ MT: 31,
645
+ NL: 18,
646
+ PT: 25,
647
+ SI: 19,
648
+ SK: 24
649
+ };
650
+ function mod97(iban) {
651
+ let remainder = 0;
652
+ for (const ch of `${iban.slice(4)}${iban.slice(0, 4)}`) {
653
+ for (const digit of String(Number.parseInt(ch, 36))) {
654
+ remainder = (remainder * 10 + Number(digit)) % 97;
655
+ }
656
+ }
657
+ return remainder === 1;
658
+ }
659
+ function ibanError(iban, country, length) {
660
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban) || !iban.startsWith(country)) {
661
+ return invalid("Enter the IBAN: it starts with the bank's country code.", "account_number");
662
+ }
663
+ if (iban.length !== length) {
664
+ return invalid(`Check the IBAN: it is ${length} characters in this country.`, "account_number");
665
+ }
666
+ if (!mod97(iban)) {
667
+ return invalid("Check the IBAN: a character is wrong.", "account_number");
668
+ }
669
+ return null;
670
+ }
671
+ function externalAccountParams(details) {
672
+ const country = details.country.toUpperCase();
673
+ const routing = (details.routingNumber ?? "").replace(/[\s-]/g, "");
674
+ const account = details.accountNumber.replace(/[\s-]/g, "").toUpperCase();
675
+ const holder = details.accountHolderName.trim();
676
+ const ibanLength = IBAN_LENGTHS[country];
677
+ if (country !== "GB" && country !== "US" && !ibanLength) {
678
+ return {
679
+ error: invalid("Bank accounts can be added in GB, the US or the eurozone.", "country")
680
+ };
681
+ }
682
+ if (!holder) return { error: invalid("Enter the name on the account.", "account_holder_name") };
683
+ if (ibanLength) {
684
+ if (routing) {
685
+ return {
686
+ error: invalid(
687
+ "For this country, the IBAN is the whole bank account: leave the routing number out.",
688
+ "routing_number"
689
+ )
690
+ };
691
+ }
692
+ const error = ibanError(account, country, ibanLength);
693
+ if (error) return { error };
694
+ return {
695
+ externalAccount: {
696
+ object: "bank_account",
697
+ country,
698
+ currency: details.currency.toLowerCase(),
699
+ account_holder_name: holder,
700
+ account_holder_type: details.accountHolderType,
701
+ account_number: account
702
+ }
703
+ };
704
+ }
705
+ if (country === "GB") {
706
+ if (!/^\d{6}$/.test(routing)) {
707
+ return { error: invalid("A sort code is 6 digits, like 10-88-00.", "routing_number") };
708
+ }
709
+ if (!/^\d{8}$/.test(account)) {
710
+ return { error: invalid("A UK account number is 8 digits.", "account_number") };
711
+ }
712
+ } else {
713
+ if (!/^\d{9}$/.test(routing)) {
714
+ return { error: invalid("A routing number is 9 digits.", "routing_number") };
715
+ }
716
+ if (!/^\d{4,17}$/.test(account)) {
717
+ return { error: invalid("Check the account number: 4 to 17 digits.", "account_number") };
718
+ }
719
+ }
720
+ return {
721
+ externalAccount: {
722
+ object: "bank_account",
723
+ country,
724
+ currency: details.currency.toLowerCase(),
725
+ account_holder_name: holder,
726
+ account_holder_type: details.accountHolderType,
727
+ routing_number: routing,
728
+ account_number: account
729
+ }
730
+ };
731
+ }
732
+
733
+ // src/tuppence.ts
734
+ init_errors();
735
+ init_express();
736
+
737
+ // src/sheet.ts
738
+ init_appearance();
739
+ init_errors();
740
+ var simulated = () => new TuppenceError({
741
+ type: "invalid_request_error",
742
+ code: "no_card_fields",
743
+ message: "There are no card fields here: this environment runs the simulated gateway. Pay with `testPaymentMethod` instead."
744
+ });
745
+ var Sheet = class {
746
+ constructor(tuppence, options) {
747
+ this.tuppence = tuppence;
748
+ this.options = options;
749
+ this.handlers = {
750
+ ready: [],
751
+ change: [],
752
+ error: []
753
+ };
754
+ this.mounted = null;
755
+ /** Taken down: a load still under way never mounts, and nothing is told any more. */
756
+ this.destroyed = false;
757
+ }
758
+ on(event, handler) {
759
+ this.handlers[event].push(handler);
760
+ return this;
761
+ }
762
+ /** Shows the fields in `target` (an element, or a selector). Loads Stripe.js if it must. */
763
+ mount(target) {
764
+ this.destroyed = false;
765
+ this.mounted ?? (this.mounted = this.load(target).catch((caught) => {
766
+ this.emit(
767
+ "error",
768
+ caught instanceof TuppenceError ? caught : new TuppenceError({ type: "api_error", message: String(caught) })
769
+ );
770
+ return null;
771
+ }));
772
+ return this;
773
+ }
774
+ /**
775
+ * Takes the fields off the page — or, while they are still loading, keeps them off it — and
776
+ * says nothing more. The sheet can be mounted again.
777
+ */
778
+ destroy() {
779
+ this.destroyed = true;
780
+ const mounted = this.mounted;
781
+ this.mounted = null;
782
+ void mounted?.then((m) => m?.element.destroy());
783
+ }
784
+ emit(event, ...detail) {
785
+ if (this.destroyed) return;
786
+ for (const handler of this.handlers[event]) handler(...detail);
787
+ }
788
+ async ready() {
789
+ const mounted = await this.mounted;
790
+ if (!mounted) {
791
+ throw new TypeError("Mount the sheet, and let its fields load, before confirming.");
792
+ }
793
+ return mounted;
794
+ }
795
+ async load(target) {
796
+ const elements = await this.elementsFor();
797
+ if (this.destroyed) return null;
798
+ if (!elements) {
799
+ this.emit("error", simulated());
800
+ return null;
801
+ }
802
+ const wallet = this.options.wallets ?? "auto";
803
+ const element = elements.create("payment", {
804
+ layout: this.options.layout ?? "tabs",
805
+ wallets: { applePay: wallet, googlePay: wallet }
806
+ });
807
+ element.on("ready", () => this.emit("ready"));
808
+ element.on(
809
+ "change",
810
+ (event) => this.emit("change", { complete: event.complete, empty: event.empty })
811
+ );
812
+ element.on("loaderror", (event) => this.emit("error", TuppenceError.fromStripe(event.error)));
813
+ element.mount(target);
814
+ return { elements, element };
815
+ }
816
+ };
817
+ var PaymentSheet = class extends Sheet {
818
+ constructor(tuppence, paymentOptions) {
819
+ super(tuppence, paymentOptions);
820
+ this.paymentOptions = paymentOptions;
821
+ this.amount = paymentOptions.amount;
822
+ }
823
+ async elementsFor() {
824
+ const payment = this.paymentOptions.payment ?? await this.tuppence.retrievePayment(this.paymentOptions.clientSecret);
825
+ return this.tuppence.elements(payment, {
826
+ appearance: this.paymentOptions.appearance ?? appearanceFor(payment.branding),
827
+ ...this.paymentOptions.locale ? { locale: this.paymentOptions.locale } : {},
828
+ ...this.amount === void 0 ? {} : { amount: this.amount }
829
+ });
830
+ }
831
+ /** What is charged changed (a tip): the fields — and Apple Pay's sheet — show the new total. */
832
+ setAmount(amount) {
833
+ this.amount = amount;
834
+ void this.mounted?.then((m) => m?.elements.update({ amount }));
835
+ }
836
+ /** Pays: the steps of `confirmPayment`, with the fields this sheet shows. */
837
+ async confirm(options = {}) {
838
+ const { elements } = await this.ready();
839
+ return this.tuppence.confirmPayment({
840
+ clientSecret: this.paymentOptions.clientSecret,
841
+ elements,
842
+ ...options.tipAmount === void 0 ? {} : { tipAmount: options.tipAmount },
843
+ ...options.params ? { params: options.params } : {}
844
+ });
845
+ }
846
+ };
847
+ var SetupSheet = class extends Sheet {
848
+ constructor(tuppence, setupOptions) {
849
+ super(tuppence, setupOptions);
850
+ this.setupOptions = setupOptions;
851
+ }
852
+ async elementsFor() {
853
+ const session = this.setupOptions.setupSession ?? await this.tuppence.retrieveSetupSession(this.setupOptions.clientSecret);
854
+ return this.tuppence.setupElements(session, {
855
+ appearance: this.setupOptions.appearance ?? appearanceFor(session.branding),
856
+ ...this.setupOptions.locale ? { locale: this.setupOptions.locale } : {}
857
+ });
858
+ }
859
+ async confirm(options = {}) {
860
+ const { elements } = await this.ready();
861
+ return this.tuppence.confirmSetup({
862
+ clientSecret: this.setupOptions.clientSecret,
863
+ elements,
864
+ ...options.params ? { params: options.params } : {}
865
+ });
866
+ }
867
+ };
868
+
869
+ // src/tuppence.ts
870
+ var DEFAULT_API = "https://api.tuppence.ai";
871
+ var PUBLISHABLE_KEY_HEADER = "Tuppence-Publishable-Key";
872
+ var CLIENT_SECRET_HEADER = "Tuppence-Client-Secret";
873
+ var WAITING = /* @__PURE__ */ new Set(["requires_action", "processing"]);
874
+ var stillWaiting = (current) => WAITING.has(current.status) && !(current.status === "processing" && typeof current.payment_method_type === "string" && current.payment_method_type !== "card");
875
+ var TOPUP_PAGE = /^\/card-topup\/[A-Za-z0-9]{20,64}\/?$/;
876
+ var LOCAL = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
877
+ function objectOf(clientSecret, prefix) {
878
+ const secret = typeof clientSecret === "string" ? clientSecret : "";
879
+ const at = secret.indexOf("_secret_");
880
+ const id = at > 0 ? secret.slice(0, at) : "";
881
+ if (!id.startsWith(prefix) || !/^[A-Za-z0-9_]+$/.test(id)) {
882
+ const what = prefix === "pay_" ? "a payment" : "a setup session";
883
+ throw new TypeError(`Expected the client secret of ${what} (${prefix}\u2026_secret_\u2026).`);
884
+ }
885
+ return id;
886
+ }
887
+ var secretHeader = (clientSecret) => ({ [CLIENT_SECRET_HEADER]: clientSecret });
888
+ var refused = (last) => new TuppenceError({
889
+ type: "card_error",
890
+ message: last.message ?? "The card was declined.",
891
+ code: last.code,
892
+ declineCode: last.decline_code,
893
+ status: 402
894
+ });
895
+ var unknownOutcome = (error) => error.status === 0 || error.status === 409 || error.status >= 500;
896
+ var Tuppence = class _Tuppence {
897
+ constructor(options = {}, config = null) {
898
+ this.config = config;
899
+ this.stripes = /* @__PURE__ */ new Map();
900
+ /** The Stripe instance each set of card fields came from: its token must come from it too. */
901
+ this.owners = /* @__PURE__ */ new WeakMap();
902
+ /**
903
+ * The card balance's hosted top-up page (54-18). Your server makes a link
904
+ * (`POST /v1/issuing/topup_links`, with your secret key) and hands its `url` to the page;
905
+ * `tuppence.topups.redirect({ url })` sends the person there — to top up by card or by bank
906
+ * transfer, in your (or your Connect platform's) branding — and they come back to the link's
907
+ * `return_url`. Only a Tuppence top-up page's address is followed: anything else throws.
908
+ */
909
+ this.topups = {
910
+ /**
911
+ * Stripe's card fields for a hosted top-up page's card (54-18), to charge `amount` (the
912
+ * card option's `total`). Null where no Stripe is behind the environment: there,
913
+ * `pay({ testPaymentMethod })` names one of Stripe's test cards.
914
+ */
915
+ elements: async (page, options) => {
916
+ const stripe = await this.stripeFor(page);
917
+ if (!stripe) return null;
918
+ const { amount, ...look } = options;
919
+ const elements = stripe.elements({
920
+ ...look,
921
+ mode: "payment",
922
+ amount,
923
+ currency: page.currency,
924
+ captureMethod: "automatic",
925
+ paymentMethodTypes: ["card"]
926
+ });
927
+ this.owners.set(elements, stripe);
928
+ return elements;
929
+ },
930
+ /**
931
+ * Tops up by card on a hosted top-up page (54-18): the card presented (Stripe's fields make a
932
+ * confirmation token), the top-up made, the bank's check in Stripe's own frame if it asks,
933
+ * then the page read back until the bank and Stripe have answered. `businessFunds: true` is
934
+ * the person's word that the card is the business's own money.
935
+ */
936
+ pay: async (options) => {
937
+ const path = `/public/issuing_topup_links/${encodeURIComponent(options.token)}`;
938
+ const presented = await this.present(
939
+ {
940
+ clientSecret: "",
941
+ elements: options.elements,
942
+ testPaymentMethod: options.testPaymentMethod
943
+ },
944
+ false
945
+ );
946
+ if ("error" in presented) {
947
+ return { page: await this.http.get(path, {}), error: presented.error };
948
+ }
949
+ let page = await this.http.post(
950
+ `${path}/topups`,
951
+ {},
952
+ {
953
+ ...presented.body,
954
+ business_funds: options.businessFunds,
955
+ ...options.amount === void 0 ? {} : { amount: options.amount }
956
+ }
957
+ );
958
+ const secret = page.topup?.status === "requires_action" ? page.topup.client_secret : null;
959
+ const stripe = secret ? await this.stripeFor(page) : null;
960
+ if (!secret || !stripe) return { page };
961
+ const handled = await stripe.handleNextAction({ clientSecret: secret });
962
+ for (let waited = 0; waited < this.timeoutMs; waited += this.intervalMs) {
963
+ page = await this.http.get(path, {});
964
+ if (page.topup?.status !== "requires_action") break;
965
+ await this.sleep(this.intervalMs);
966
+ }
967
+ return handled.error ? { page, error: TuppenceError.fromStripe(handled.error) } : { page };
968
+ },
969
+ /**
970
+ * Finishes the bank's check on a hosted page's card top-up that waited for it when the page
971
+ * opened (a reload half-way), in Stripe's own frame, then reads the page back.
972
+ */
973
+ resume: async (options) => {
974
+ const path = `/public/issuing_topup_links/${encodeURIComponent(options.token)}`;
975
+ let page = await this.http.get(path, {});
976
+ const secret = page.topup?.status === "requires_action" ? page.topup.client_secret : null;
977
+ const stripe = secret ? await this.stripeFor(page) : null;
978
+ if (!secret || !stripe) return { page };
979
+ const handled = await stripe.handleNextAction({ clientSecret: secret });
980
+ for (let waited = 0; waited < this.timeoutMs; waited += this.intervalMs) {
981
+ page = await this.http.get(path, {});
982
+ if (page.topup?.status !== "requires_action") break;
983
+ await this.sleep(this.intervalMs);
984
+ }
985
+ return handled.error ? { page, error: TuppenceError.fromStripe(handled.error) } : { page };
986
+ },
987
+ redirect: (options) => {
988
+ let target = null;
989
+ try {
990
+ target = new URL(typeof options?.url === "string" ? options.url : "");
991
+ } catch {
992
+ target = null;
993
+ }
994
+ const secure = target?.protocol === "https:" || target?.protocol === "http:" && LOCAL.has(target.hostname);
995
+ if (!target || !secure || !TOPUP_PAGE.test(target.pathname) || target.search || target.hash) {
996
+ throw new TypeError(
997
+ "topups.redirect takes a top-up link's `url`, as `POST /v1/issuing/topup_links` answered it (https://\u2026/card-topup/\u2026)."
998
+ );
999
+ }
1000
+ this.navigate(target.toString());
1001
+ }
1002
+ };
1003
+ const fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1004
+ this.fetchImpl = fetchImpl;
1005
+ this.sleep = options.poll?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1006
+ this.navigate = options.navigate ?? ((url) => globalThis.location.assign(url));
1007
+ this.http = new Http(
1008
+ (options.apiBase ?? DEFAULT_API).replace(/\/+$/, ""),
1009
+ fetchImpl,
1010
+ this.sleep
1011
+ );
1012
+ this.loadStripe = options.loadStripe ?? (async (key, { stripeAccount, locale }) => {
1013
+ const { loadStripe } = await import("@stripe/stripe-js/pure");
1014
+ return loadStripe(key, { stripeAccount, locale });
1015
+ });
1016
+ this.locale = stripeLocale(options.locale ?? browserLanguage());
1017
+ this.intervalMs = options.poll?.intervalMs ?? 1e3;
1018
+ this.timeoutMs = options.poll?.timeoutMs ?? 3e4;
1019
+ }
1020
+ /** `loadTuppence`: the key checked here, then what the API says about it. */
1021
+ static async load(publishableKey, options = {}) {
1022
+ const key = typeof publishableKey === "string" ? publishableKey : "";
1023
+ if (/^(sk|rk)_/.test(key)) {
1024
+ throw new TuppenceError({
1025
+ type: "authentication_error",
1026
+ code: "invalid_api_key",
1027
+ message: "That is a secret key: it must never be in a page. Roll it, and pass your publishable key (pk_\u2026) here."
1028
+ });
1029
+ }
1030
+ if (!/^pk_(test|live)_[A-Za-z0-9]+$/.test(key)) {
1031
+ throw new TypeError("loadTuppence needs your publishable key (pk_test_\u2026 or pk_live_\u2026).");
1032
+ }
1033
+ const config = await new _Tuppence(options).http.get("/public/sdk/config", {
1034
+ [PUBLISHABLE_KEY_HEADER]: key
1035
+ });
1036
+ const tuppence = new _Tuppence(options, config);
1037
+ void tuppence.stripeFor(config).catch(() => void 0);
1038
+ return tuppence;
1039
+ }
1040
+ /** The payment a client secret belongs to, as its payer sees it. */
1041
+ retrievePayment(clientSecret) {
1042
+ const id = objectOf(clientSecret, "pay_");
1043
+ return this.http.get(`/public/payments/${id}`, secretHeader(clientSecret));
1044
+ }
1045
+ /** The setup session a client secret belongs to, as its customer sees it. */
1046
+ retrieveSetupSession(clientSecret) {
1047
+ const id = objectOf(clientSecret, "sets_");
1048
+ return this.http.get(
1049
+ `/public/setup_sessions/${id}`,
1050
+ secretHeader(clientSecret)
1051
+ );
1052
+ }
1053
+ /**
1054
+ * Stripe's card fields for a payment — `elements.create("payment").mount(el)` shows them. What
1055
+ * they collect matches the charge Tuppence will make: its amount, currency, how it is
1056
+ * captured and whether it saves the card. Null where no Stripe is behind the environment (the
1057
+ * simulated gateway): there, `testPaymentMethod` names one of Stripe's test cards instead.
1058
+ *
1059
+ * A page that adds to the amount (a tip) says so with `elements.update({ amount })`.
1060
+ */
1061
+ async elements(payment, options = {}) {
1062
+ const stripe = await this.stripeFor(payment);
1063
+ if (!stripe) return null;
1064
+ const { amount, ...look } = options;
1065
+ const excluded = payment.excluded_payment_method_types ?? [];
1066
+ const elements = stripe.elements({
1067
+ ...look,
1068
+ mode: "payment",
1069
+ amount: amount ?? payment.amount,
1070
+ currency: payment.currency,
1071
+ ...payment.capture_method ? { captureMethod: payment.capture_method } : {},
1072
+ ...payment.setup_future_usage ? { setupFutureUsage: payment.setup_future_usage } : {},
1073
+ ...excluded.length > 0 ? { excludedPaymentMethodTypes: [...excluded] } : {}
1074
+ });
1075
+ this.owners.set(elements, stripe);
1076
+ return elements;
1077
+ }
1078
+ /** Stripe's card fields for saving a card with nothing charged — cards only, as the session saves. */
1079
+ async setupElements(session, options = {}) {
1080
+ const stripe = await this.stripeFor(session);
1081
+ if (!stripe) return null;
1082
+ const elements = stripe.elements({ ...options, mode: "setup", paymentMethodTypes: ["card"] });
1083
+ this.owners.set(elements, stripe);
1084
+ return elements;
1085
+ }
1086
+ /**
1087
+ * Stripe's Payment Element for a payment, dressed in the business's branding:
1088
+ * `tuppence.paymentSheet({ clientSecret }).mount("#card")`, then `sheet.confirm()`. Its events
1089
+ * say when the fields show (`ready`), whether they are complete (`change`), and why they
1090
+ * cannot show (`error` — on the simulated gateway there are none).
1091
+ */
1092
+ paymentSheet(options) {
1093
+ return new PaymentSheet(this, options);
1094
+ }
1095
+ /**
1096
+ * A store's basket (85-10): `get()` it, or `quote()` it for an address and a delivery choice
1097
+ * without changing it. On the store's own hosts the basket is its cookie; headless (M94) pass
1098
+ * `base: "https://api.tuppence.ai"`, the publishable key and the basket's token.
1099
+ */
1100
+ cart(options = {}) {
1101
+ return new StorefrontCartClient(this.fetchImpl, options);
1102
+ }
1103
+ /**
1104
+ * Apple Pay, Google Pay and Link for a store's basket (85-10): Stripe's Express Checkout
1105
+ * Element in deferred mode, in the business's branding. The sheet's address and delivery
1106
+ * choices are priced by the basket's quote, and the total in the sheet follows;
1107
+ * `onConfirm` gets Stripe's confirmation token for your server to pay with.
1108
+ * `tuppence.expressCheckout(payable, { onConfirm }).mount("#express")`.
1109
+ */
1110
+ expressCheckout(payable, options) {
1111
+ const cart = options.cart instanceof StorefrontCartClient ? options.cart : this.cart(options.cart);
1112
+ return new ExpressCheckout(this, payable, options, cart);
1113
+ }
1114
+ /**
1115
+ * Stripe's Address Element, with Stripe's autocomplete (85-10): in a payment's own Elements
1116
+ * group (from `elements()`) so a shipping address goes with the payment, or, from a payment
1117
+ * alone, in Elements of its own in the business's branding.
1118
+ */
1119
+ address(from, options) {
1120
+ return new AddressField(this, from, options);
1121
+ }
1122
+ /**
1123
+ * Stripe's confirmation token for fields this SDK made: the fields checked, then the token.
1124
+ * @internal For the SDK's own wallets; pages pay through `confirmPayment`.
1125
+ */
1126
+ async confirmationToken(elements, params) {
1127
+ const stripe = this.owners.get(elements);
1128
+ if (!stripe) {
1129
+ throw new TypeError(
1130
+ "Pass the card fields this SDK made (`tuppence.elements()`): they carry the account the card is for."
1131
+ );
1132
+ }
1133
+ const submitted = await elements.submit();
1134
+ if (submitted.error) return { error: TuppenceError.fromStripe(submitted.error) };
1135
+ const token = await stripe.createConfirmationToken({ elements, ...params ? { params } : {} });
1136
+ if (token.error) return { error: TuppenceError.fromStripe(token.error) };
1137
+ return { id: token.confirmationToken.id };
1138
+ }
1139
+ /** The same for saving a card with nothing charged. */
1140
+ setupSheet(options) {
1141
+ return new SetupSheet(this, options);
1142
+ }
1143
+ /**
1144
+ * Pays a payment: the card is presented (Stripe's fields make a confirmation token), the
1145
+ * payer's bank checks it's them if it asks (in Stripe's own frame), and the payment is read
1146
+ * back until it settles. A refused card is `{ error }` with the payment as it now stands —
1147
+ * never an exception. If an answer is lost on the way, the outcome is read back: the card is
1148
+ * never presented twice.
1149
+ *
1150
+ * A Bacs Direct Debit answers at once with `status: "processing"`, `payment_method_type:
1151
+ * "bacs_debit"` and `processing.expected_debit_date` (`YYYY-MM-DD`): the mandate is set up and
1152
+ * the money is collected in a few working days. Show the payer that; `succeeded` (or
1153
+ * `failed`) comes later, by webhook — `payment.succeeded`, `payment.failed`.
1154
+ */
1155
+ async confirmPayment(options) {
1156
+ const id = objectOf(options.clientSecret, "pay_");
1157
+ const presented = await this.present(options, true);
1158
+ if ("error" in presented) return { error: presented.error, payment: null };
1159
+ const body = options.tipAmount === void 0 ? presented.body : { ...presented.body, tip_amount: options.tipAmount };
1160
+ const outcome = await this.run(
1161
+ `/public/payments/${id}`,
1162
+ options.clientSecret,
1163
+ body
1164
+ );
1165
+ return outcome.error ? { error: outcome.error, payment: outcome.value } : { payment: outcome.value };
1166
+ }
1167
+ /** Saves a card with nothing charged — the same steps as a payment. */
1168
+ async confirmSetup(options) {
1169
+ const id = objectOf(options.clientSecret, "sets_");
1170
+ const presented = await this.present(options, false);
1171
+ if ("error" in presented) return { error: presented.error, setupSession: null };
1172
+ const outcome = await this.run(
1173
+ `/public/setup_sessions/${id}`,
1174
+ options.clientSecret,
1175
+ presented.body
1176
+ );
1177
+ return outcome.error ? { error: outcome.error, setupSession: outcome.value } : { setupSession: outcome.value };
1178
+ }
1179
+ /**
1180
+ * Finishes what a payment waits on when the page opens — the payer's bank checking it's them
1181
+ * (a charge to a saved card that asked for it, a check left half-way) — in Stripe's own frame,
1182
+ * then reads it back. On the simulated gateway it answers the payment as it stands.
1183
+ */
1184
+ async resumePayment(options) {
1185
+ const id = objectOf(options.clientSecret, "pay_");
1186
+ const outcome = await this.resume(
1187
+ `/public/payments/${id}`,
1188
+ options.clientSecret
1189
+ );
1190
+ return outcome.error ? { error: outcome.error, payment: outcome.value } : { payment: outcome.value };
1191
+ }
1192
+ /** The same for a card being saved. */
1193
+ async resumeSetup(options) {
1194
+ const id = objectOf(options.clientSecret, "sets_");
1195
+ const outcome = await this.resume(
1196
+ `/public/setup_sessions/${id}`,
1197
+ options.clientSecret
1198
+ );
1199
+ return outcome.error ? { error: outcome.error, setupSession: outcome.value } : { setupSession: outcome.value };
1200
+ }
1201
+ async resume(path, clientSecret) {
1202
+ const headers = secretHeader(clientSecret);
1203
+ try {
1204
+ return await this.onward(path, headers, await this.http.get(path, headers));
1205
+ } catch (caught) {
1206
+ const error = caught instanceof TuppenceError ? caught : new TuppenceError({ type: "api_error", message: String(caught) });
1207
+ return { error, value: null };
1208
+ }
1209
+ }
1210
+ /**
1211
+ * A bank account, checked and shaped for `POST /v1/accounts/{id}/external_accounts` — the only
1212
+ * way to add one (52-09, 52-17): your server sends `externalAccount` on to Tuppence as
1213
+ * `external_account`, and Tuppence keeps the details (encrypted, for verification) and has Stripe
1214
+ * make the bank account. Tuppence refuses a Stripe bank token. Nothing is sent from
1215
+ * here; a wrong field comes back as an error naming it.
1216
+ */
1217
+ bankAccountDetails(details) {
1218
+ return externalAccountParams(details);
1219
+ }
1220
+ /** Stripe.js for an object's publishable key and account — loaded once for each pair. */
1221
+ stripeFor(view) {
1222
+ const key = view.stripe_publishable_key;
1223
+ const account = view.stripe_account;
1224
+ if (!key || !account) return Promise.resolve(null);
1225
+ const memo = `${key} ${account}`;
1226
+ let loading = this.stripes.get(memo);
1227
+ if (!loading) {
1228
+ loading = this.loadStripe(key, { stripeAccount: account, locale: this.locale });
1229
+ this.stripes.set(memo, loading);
1230
+ loading.catch(() => this.stripes.delete(memo));
1231
+ }
1232
+ return loading;
1233
+ }
1234
+ /** A card, as the confirm route takes it: a confirmation token, a saved card, or a test card. */
1235
+ async present(options, canUseSavedCard) {
1236
+ if (options.elements) {
1237
+ const token = await this.confirmationToken(options.elements, options.params);
1238
+ if ("error" in token) return { error: token.error };
1239
+ return { body: { confirmation_token: token.id } };
1240
+ }
1241
+ if (canUseSavedCard && options.savedCard) {
1242
+ return { body: { saved_card: true } };
1243
+ }
1244
+ if (options.testPaymentMethod) return { body: { payment_method: options.testPaymentMethod } };
1245
+ throw new TypeError(
1246
+ "Present a card: `elements` \u2014 or, on the simulated gateway in test mode, `testPaymentMethod`."
1247
+ );
1248
+ }
1249
+ /** Confirm, then the bank's check if it asks, then read back until it settles. */
1250
+ async run(path, clientSecret, body) {
1251
+ const headers = secretHeader(clientSecret);
1252
+ let current;
1253
+ try {
1254
+ current = await this.http.post(`${path}/confirm`, headers, body);
1255
+ } catch (caught) {
1256
+ const error = caught instanceof TuppenceError ? caught : new TuppenceError({ type: "api_error", message: String(caught) });
1257
+ if (!unknownOutcome(error)) {
1258
+ return { error, value: await this.http.get(path, headers).catch(() => null) };
1259
+ }
1260
+ current = await this.settle(path, headers).catch(() => null);
1261
+ if (!current || current.status === "requires_payment_method") {
1262
+ return { error, value: current };
1263
+ }
1264
+ }
1265
+ return this.onward(path, headers, current);
1266
+ }
1267
+ /** What follows a confirm: the bank's check if it asks, then a read-back until it settles. */
1268
+ async onward(path, headers, confirmed) {
1269
+ let current = confirmed;
1270
+ if (current.status === "requires_action" && current.next_action?.type === "use_stripe_sdk") {
1271
+ const stripe = await this.stripeFor(current);
1272
+ if (!stripe) return { value: current };
1273
+ const handled = await stripe.handleNextAction({
1274
+ clientSecret: current.next_action.stripe_client_secret
1275
+ });
1276
+ current = await this.settle(path, headers).catch(() => null) ?? current;
1277
+ if (handled.error && current.status === "requires_payment_method") {
1278
+ return { error: TuppenceError.fromStripe(handled.error), value: current };
1279
+ }
1280
+ } else if (stillWaiting(current)) {
1281
+ current = await this.settle(path, headers).catch(() => null) ?? current;
1282
+ }
1283
+ if (current.status === "requires_payment_method" && current.last_error) {
1284
+ return { error: refused(current.last_error), value: current };
1285
+ }
1286
+ return { value: current };
1287
+ }
1288
+ /** Reads an object back until neither the bank nor Stripe is still deciding, or time is up. */
1289
+ async settle(path, headers) {
1290
+ let current = await this.http.get(path, headers);
1291
+ for (let waited = 0; stillWaiting(current) && waited < this.timeoutMs; ) {
1292
+ await this.sleep(this.intervalMs);
1293
+ waited += this.intervalMs;
1294
+ current = await this.http.get(path, headers);
1295
+ }
1296
+ return current;
1297
+ }
1298
+ };
1299
+ function createTuppence(options = {}) {
1300
+ return new Tuppence(options);
1301
+ }
1302
+ function loadTuppence(publishableKey, options = {}) {
1303
+ return Tuppence.load(publishableKey, options);
1304
+ }