@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/README.md +182 -2
- package/dist/appearance.d.ts +8 -0
- package/dist/appearance.js +25 -0
- package/dist/bank-details.d.ts +12 -0
- package/dist/bank-details.js +117 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.js +41 -0
- package/dist/express-mount.d.ts +49 -0
- package/dist/express-mount.js +272 -0
- package/dist/express.d.ts +154 -0
- package/dist/express.js +114 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +69 -0
- package/dist/index.cjs +1304 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/locale.d.ts +12 -0
- package/dist/locale.js +79 -0
- package/dist/sheet.d.ts +92 -0
- package/dist/sheet.js +136 -0
- package/dist/storefront.d.ts +90 -0
- package/dist/storefront.js +25 -0
- package/dist/tuppence.d.ts +278 -0
- package/dist/tuppence.js +484 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +5 -0
- package/package.json +43 -3
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { appearanceFor } from "./appearance.js";
|
|
2
|
+
import { TuppenceError } from "./errors.js";
|
|
3
|
+
import { noStripe, wrap, } from "./express.js";
|
|
4
|
+
/**
|
|
5
|
+
* The store's wallet buttons and address once they are mounted (85-10). `mount()` loads this
|
|
6
|
+
* module and nothing loads it before, so a page that takes a payment with `@tuppence/js` and
|
|
7
|
+
* shows neither (a payer's pay page) never downloads it: apps/web/scripts/check-pay-entry.mjs
|
|
8
|
+
* weighs that page.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Stripe gives a wallet sheet's address and delivery events 20 s to answer before the sheet
|
|
12
|
+
* gives up on the shopper (plan/85 Gotchas). A quote that hasn't come back in 15 s is refused,
|
|
13
|
+
* so the sheet says so in its own words and the shopper can try again, instead of hanging.
|
|
14
|
+
*/
|
|
15
|
+
const QUOTE_TIMEOUT_MS = 15000;
|
|
16
|
+
const DAY_MS = 86400000;
|
|
17
|
+
/**
|
|
18
|
+
* A wallet's or the Address Element's address, as the quote takes it. Wallets redact it until
|
|
19
|
+
* the payment is authorised (a city, a region, part of a postcode). A region that is a name
|
|
20
|
+
* ("West Yorkshire") is no code, so it is left out.
|
|
21
|
+
*/
|
|
22
|
+
export function cartAddressOf(address) {
|
|
23
|
+
const region = address.state?.trim().toUpperCase() ?? "";
|
|
24
|
+
const postcode = address.postal_code?.trim() ?? "";
|
|
25
|
+
return {
|
|
26
|
+
country: (address.country ?? "").trim().toUpperCase(),
|
|
27
|
+
region: /^[A-Z0-9]{1,3}$/.test(region) ? region : null,
|
|
28
|
+
postcode: postcode && postcode.length <= 12 ? postcode : null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The address as the basket's quote takes it, once there is a country. */
|
|
32
|
+
export const asCartAddress = (value) => value?.address?.country ? cartAddressOf(value.address) : null;
|
|
33
|
+
/** Days from `today` to a `YYYY-MM-DD` promise date, never fewer than none. */
|
|
34
|
+
function daysTo(date, today) {
|
|
35
|
+
const from = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
|
|
36
|
+
return Math.max(0, Math.round((Date.parse(`${date}T00:00:00Z`) - from) / DAY_MS));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What `ExpressCheckout` does once it is mounted (85-10): Stripe's Express Checkout Element (Apple
|
|
40
|
+
* Pay, Google Pay, Link) for a store's basket, in deferred mode: nothing is made until the shopper
|
|
41
|
+
* approves. Each address and delivery choice in the sheet is priced by the basket's quote and the
|
|
42
|
+
* sheet's total follows it (`elements.update({ amount })`); the server charges its own figure at
|
|
43
|
+
* the end. One per `ExpressCheckout`, so a remount keeps the basket as last quoted.
|
|
44
|
+
*/
|
|
45
|
+
export class Wallet {
|
|
46
|
+
constructor(host, tuppence, payable, options, cart) {
|
|
47
|
+
this.host = host;
|
|
48
|
+
this.tuppence = tuppence;
|
|
49
|
+
this.payable = payable;
|
|
50
|
+
this.options = options;
|
|
51
|
+
this.cart = cart;
|
|
52
|
+
this.address = null;
|
|
53
|
+
this.delivery = null;
|
|
54
|
+
this.elements = null;
|
|
55
|
+
this.basket = options.basket ?? null;
|
|
56
|
+
this.amount = payable.amount;
|
|
57
|
+
}
|
|
58
|
+
/** The delivery choices as the sheet lists them: the chosen one first, as Stripe selects it. */
|
|
59
|
+
ratesOf(basket) {
|
|
60
|
+
const today = this.options.now?.() ?? new Date();
|
|
61
|
+
const options = [...(basket?.delivery_options ?? [])].sort((a, b) => Number(b.selected) - Number(a.selected));
|
|
62
|
+
return options.map((o) => ({
|
|
63
|
+
id: o.key,
|
|
64
|
+
displayName: o.name,
|
|
65
|
+
amount: o.amount,
|
|
66
|
+
deliveryEstimate: {
|
|
67
|
+
minimum: { unit: "day", value: daysTo(o.earliest, today) },
|
|
68
|
+
maximum: { unit: "day", value: daysTo(o.latest, today) },
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** A quote, or why there is none: refused after 15 s rather than left to hang the sheet. */
|
|
73
|
+
async within(ask) {
|
|
74
|
+
let timer;
|
|
75
|
+
const late = new Promise((_, reject) => {
|
|
76
|
+
timer = setTimeout(() => reject(new TuppenceError({
|
|
77
|
+
type: "api_connection_error",
|
|
78
|
+
code: "quote_timeout",
|
|
79
|
+
message: "The delivery prices took too long to come back. Try again.",
|
|
80
|
+
})), QUOTE_TIMEOUT_MS);
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
return await Promise.race([ask(), late]);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The quote's answer for the sheet, or the reason it can't be delivered. */
|
|
90
|
+
deliverable(basket) {
|
|
91
|
+
if (!basket.totals || basket.delivery_options.length === 0) {
|
|
92
|
+
throw new TuppenceError({
|
|
93
|
+
type: "invalid_request_error",
|
|
94
|
+
code: basket.delivery_refusal?.code ?? "no_delivery_options",
|
|
95
|
+
message: basket.delivery_refusal?.message ?? "This basket can't be delivered there.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return basket;
|
|
99
|
+
}
|
|
100
|
+
priced(basket) {
|
|
101
|
+
this.basket = basket;
|
|
102
|
+
this.delivery = basket.delivery_options.find((o) => o.selected)?.key ?? null;
|
|
103
|
+
this.amount = basket.totals?.due ?? this.amount;
|
|
104
|
+
this.elements?.update({ amount: this.amount });
|
|
105
|
+
this.host.emit("total", basket);
|
|
106
|
+
}
|
|
107
|
+
async addressChanged(event) {
|
|
108
|
+
const address = cartAddressOf(event.address);
|
|
109
|
+
const delivery = this.delivery;
|
|
110
|
+
try {
|
|
111
|
+
const ask = this.options.onShippingAddressChange ?? (() => this.cart.quote({ address, delivery }));
|
|
112
|
+
const basket = this.deliverable(await this.within(() => ask({ name: event.name, address, delivery })));
|
|
113
|
+
if (this.host.destroyed())
|
|
114
|
+
return;
|
|
115
|
+
this.address = address;
|
|
116
|
+
this.priced(basket);
|
|
117
|
+
event.resolve({ shippingRates: this.ratesOf(basket) });
|
|
118
|
+
}
|
|
119
|
+
catch (caught) {
|
|
120
|
+
event.reject();
|
|
121
|
+
this.host.emit("error", wrap(caught));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async rateChanged(event) {
|
|
125
|
+
const delivery = event.shippingRate.id;
|
|
126
|
+
const address = this.address;
|
|
127
|
+
try {
|
|
128
|
+
const ask = this.options.onShippingRateChange ?? (() => this.cart.quote({ address, delivery }));
|
|
129
|
+
const basket = this.deliverable(await this.within(() => ask({ delivery, address })));
|
|
130
|
+
if (this.host.destroyed())
|
|
131
|
+
return;
|
|
132
|
+
// The server falls back when a choice isn't offered any more: the sheet mustn't show it.
|
|
133
|
+
if (!basket.delivery_options.some((o) => o.key === delivery && o.selected)) {
|
|
134
|
+
throw new TuppenceError({
|
|
135
|
+
type: "invalid_request_error",
|
|
136
|
+
code: "delivery_unavailable",
|
|
137
|
+
message: "That delivery choice isn't available any more. Choose another.",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
this.priced(basket);
|
|
141
|
+
event.resolve();
|
|
142
|
+
}
|
|
143
|
+
catch (caught) {
|
|
144
|
+
event.reject();
|
|
145
|
+
this.host.emit("error", wrap(caught));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async confirmed(event) {
|
|
149
|
+
const elements = this.elements;
|
|
150
|
+
if (!elements)
|
|
151
|
+
return;
|
|
152
|
+
const failed = (error) => {
|
|
153
|
+
event.paymentFailed({ reason: "fail", message: error.message });
|
|
154
|
+
this.host.emit("error", error);
|
|
155
|
+
};
|
|
156
|
+
const token = await this.tuppence.confirmationToken(elements);
|
|
157
|
+
if ("error" in token)
|
|
158
|
+
return failed(token.error);
|
|
159
|
+
try {
|
|
160
|
+
const outcome = await this.options.onConfirm({
|
|
161
|
+
confirmationToken: token.id,
|
|
162
|
+
amount: this.amount,
|
|
163
|
+
delivery: event.shippingRate?.id ?? (this.options.shipping === false ? null : this.delivery),
|
|
164
|
+
email: event.billingDetails?.email ?? null,
|
|
165
|
+
phone: event.billingDetails?.phone ?? null,
|
|
166
|
+
shippingAddress: event.shippingAddress ?? null,
|
|
167
|
+
billingDetails: event.billingDetails ?? null,
|
|
168
|
+
expressPaymentType: event.expressPaymentType,
|
|
169
|
+
basket: this.basket,
|
|
170
|
+
});
|
|
171
|
+
const refusal = outcome?.error;
|
|
172
|
+
if (refusal) {
|
|
173
|
+
failed(new TuppenceError({
|
|
174
|
+
type: "api_error",
|
|
175
|
+
message: typeof refusal.message === "string"
|
|
176
|
+
? refusal.message
|
|
177
|
+
: "The payment didn't go through.",
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (caught) {
|
|
182
|
+
failed(wrap(caught));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async load(target) {
|
|
186
|
+
const shipping = this.options.shipping !== false;
|
|
187
|
+
const [elements, basket] = await Promise.all([
|
|
188
|
+
this.tuppence.elements(this.payable, {
|
|
189
|
+
appearance: this.options.appearance ?? appearanceFor(this.payable.branding),
|
|
190
|
+
}),
|
|
191
|
+
this.basket || !shipping ? this.basket : this.cart.get(),
|
|
192
|
+
]);
|
|
193
|
+
if (this.host.destroyed())
|
|
194
|
+
return;
|
|
195
|
+
if (!elements)
|
|
196
|
+
return this.host.emit("error", noStripe("wallet"));
|
|
197
|
+
this.elements = elements;
|
|
198
|
+
this.basket = basket;
|
|
199
|
+
this.delivery = basket?.delivery_options.find((o) => o.selected)?.key ?? null;
|
|
200
|
+
const name = this.payable.branding?.display_name;
|
|
201
|
+
const element = elements.create("expressCheckout", {
|
|
202
|
+
...this.options.buttons,
|
|
203
|
+
emailRequired: true,
|
|
204
|
+
phoneNumberRequired: this.options.phone === true,
|
|
205
|
+
shippingAddressRequired: shipping,
|
|
206
|
+
...(this.options.allowedShippingCountries
|
|
207
|
+
? { allowedShippingCountries: this.options.allowedShippingCountries }
|
|
208
|
+
: {}),
|
|
209
|
+
...(name ? { business: { name } } : {}),
|
|
210
|
+
});
|
|
211
|
+
this.host.attach(element);
|
|
212
|
+
element.on("ready", (event) => this.host.emit("ready", Object.entries(event.availablePaymentMethods ?? {})
|
|
213
|
+
.filter(([, on]) => on)
|
|
214
|
+
.map(([method]) => method)));
|
|
215
|
+
element.on("loaderror", (event) => this.host.emit("error", TuppenceError.fromStripe(event.error)));
|
|
216
|
+
// Stripe allows one second: the sheet opens on the choices already known.
|
|
217
|
+
element.on("click", (event) => {
|
|
218
|
+
const rates = this.ratesOf(this.basket);
|
|
219
|
+
if (shipping && rates.length === 0) {
|
|
220
|
+
event.reject();
|
|
221
|
+
return this.host.emit("error", noDelivery(this.basket));
|
|
222
|
+
}
|
|
223
|
+
event.resolve(shipping ? { shippingRates: rates } : {});
|
|
224
|
+
});
|
|
225
|
+
element.on("shippingaddresschange", (event) => void this.addressChanged(event));
|
|
226
|
+
element.on("shippingratechange", (event) => void this.rateChanged(event));
|
|
227
|
+
element.on("cancel", () => {
|
|
228
|
+
// The quote changed nothing: the page's total is the basket's again.
|
|
229
|
+
this.amount = this.payable.amount;
|
|
230
|
+
this.address = null;
|
|
231
|
+
elements.update({ amount: this.amount });
|
|
232
|
+
this.host.emit("cancel", undefined);
|
|
233
|
+
});
|
|
234
|
+
element.on("confirm", (event) => void this.confirmed(event));
|
|
235
|
+
element.mount(target);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const noDelivery = (basket) => new TuppenceError({
|
|
239
|
+
type: "invalid_request_error",
|
|
240
|
+
code: basket?.delivery_refusal?.code ?? "no_delivery_options",
|
|
241
|
+
message: basket?.delivery_refusal?.message ?? "This basket has no delivery options yet.",
|
|
242
|
+
});
|
|
243
|
+
/** Stripe's Address Element, with Stripe's autocomplete, for `AddressField`. */
|
|
244
|
+
export async function mountAddress(host, tuppence, from, options, target) {
|
|
245
|
+
const elements = "create" in from
|
|
246
|
+
? from
|
|
247
|
+
: await tuppence.elements(from, {
|
|
248
|
+
appearance: options.appearance ?? appearanceFor(from.branding),
|
|
249
|
+
});
|
|
250
|
+
if (host.destroyed())
|
|
251
|
+
return;
|
|
252
|
+
if (!elements)
|
|
253
|
+
return host.emit("error", noStripe("Stripe address field"));
|
|
254
|
+
const { mode, defaultValues, allowedCountries, phone } = options;
|
|
255
|
+
const element = elements.create("address", {
|
|
256
|
+
mode,
|
|
257
|
+
autocomplete: { mode: "automatic" },
|
|
258
|
+
...(defaultValues ? { defaultValues } : {}),
|
|
259
|
+
...(allowedCountries ? { allowedCountries } : {}),
|
|
260
|
+
...(phone ? { fields: { phone } } : {}),
|
|
261
|
+
});
|
|
262
|
+
host.attach(element);
|
|
263
|
+
element.on("ready", () => host.emit("ready", undefined));
|
|
264
|
+
element.on("change", (event) => host.emit("change", {
|
|
265
|
+
complete: event.complete,
|
|
266
|
+
empty: event.empty,
|
|
267
|
+
value: event.value,
|
|
268
|
+
address: asCartAddress(event.value),
|
|
269
|
+
}));
|
|
270
|
+
element.on("loaderror", (event) => host.emit("error", TuppenceError.fromStripe(event.error)));
|
|
271
|
+
element.mount(target);
|
|
272
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Appearance, BillingDetails, ExpressPaymentType, ShippingAddress, StripeAddressElementChangeEvent, StripeAddressElementOptions, StripeElements, StripeExpressCheckoutElementOptions } from "@stripe/stripe-js";
|
|
2
|
+
import { TuppenceError } from "./errors.js";
|
|
3
|
+
import type { CartAddress, StorefrontCart, StorefrontCartClient, StorefrontOptions } from "./storefront.js";
|
|
4
|
+
import type { Payable, Tuppence } from "./tuppence.js";
|
|
5
|
+
export declare const wrap: (caught: unknown) => TuppenceError;
|
|
6
|
+
/** No Stripe behind the environment (the simulated gateway): no wallet, no Stripe address. */
|
|
7
|
+
export declare const noStripe: (what: string) => TuppenceError;
|
|
8
|
+
/** What the mounted half may do to what the page holds: tell the page, and own the element. */
|
|
9
|
+
export interface Host<H extends Record<string, (detail: never) => void>> {
|
|
10
|
+
emit<E extends keyof H>(event: E, detail: Parameters<H[E]>[0]): void;
|
|
11
|
+
destroyed(): boolean;
|
|
12
|
+
attach(element: {
|
|
13
|
+
destroy(): void;
|
|
14
|
+
}): void;
|
|
15
|
+
}
|
|
16
|
+
/** Something mounted where the page says, telling the page what happens to it. */
|
|
17
|
+
declare abstract class Mountable<H extends Record<string, (detail: never) => void>> {
|
|
18
|
+
private handlers;
|
|
19
|
+
protected mounted: Promise<void> | null;
|
|
20
|
+
protected destroyed: boolean;
|
|
21
|
+
protected element: {
|
|
22
|
+
destroy(): void;
|
|
23
|
+
} | null;
|
|
24
|
+
on<E extends keyof H>(event: E, handler: H[E]): this;
|
|
25
|
+
/** Shows it in `target` (an element, or a selector). Loads Stripe.js if it must. */
|
|
26
|
+
mount(target: string | HTMLElement): this;
|
|
27
|
+
/** Takes it off the page (or keeps it off, while it loads) and says nothing more. */
|
|
28
|
+
destroy(): void;
|
|
29
|
+
protected emit<E extends keyof H>(event: E, detail: Parameters<H[E]>[0]): void;
|
|
30
|
+
/** What the mounted half (`./express-mount.js`) may do to this. */
|
|
31
|
+
protected host(): Host<H>;
|
|
32
|
+
protected abstract load(target: string | HTMLElement): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** What the page hears from the wallet buttons. */
|
|
35
|
+
export type ExpressHandlers = {
|
|
36
|
+
/** The buttons are showing: which wallets this device and browser offer. */
|
|
37
|
+
ready: (available: string[]) => void;
|
|
38
|
+
/** The sheet's address or delivery choice was priced: the basket as quoted. */
|
|
39
|
+
total: (basket: StorefrontCart) => void;
|
|
40
|
+
/** The shopper closed the sheet. */
|
|
41
|
+
cancel: (detail: undefined) => void;
|
|
42
|
+
error: (error: TuppenceError) => void;
|
|
43
|
+
};
|
|
44
|
+
/** What a wallet hands the page once the shopper approves the payment. */
|
|
45
|
+
export interface ExpressConfirm {
|
|
46
|
+
/** Stripe's confirmation token (`ctoken_…`): the page's server pays with it. */
|
|
47
|
+
confirmationToken: string;
|
|
48
|
+
/** What the sheet showed, in the smallest unit: the server must charge its own figure. */
|
|
49
|
+
amount: number;
|
|
50
|
+
/** The chosen delivery option's key, or null when nothing was shipped. */
|
|
51
|
+
delivery: string | null;
|
|
52
|
+
email: string | null;
|
|
53
|
+
phone: string | null;
|
|
54
|
+
shippingAddress: ShippingAddress | null;
|
|
55
|
+
billingDetails: BillingDetails | null;
|
|
56
|
+
expressPaymentType: ExpressPaymentType;
|
|
57
|
+
/** The basket as last quoted for this sheet. */
|
|
58
|
+
basket: StorefrontCart | null;
|
|
59
|
+
}
|
|
60
|
+
export interface ExpressCheckoutOptions {
|
|
61
|
+
/**
|
|
62
|
+
* The basket being paid: its routes answer the sheet's address and delivery changes
|
|
63
|
+
* (`POST /storefront/v1/cart/quote`). The store's own domain unless you say.
|
|
64
|
+
*/
|
|
65
|
+
cart?: StorefrontCartClient | StorefrontOptions;
|
|
66
|
+
/** The basket as the page last read it: its delivery choices open the sheet. Read if not. */
|
|
67
|
+
basket?: StorefrontCart;
|
|
68
|
+
/** Prices a new address: the basket's quote unless you pass your own. */
|
|
69
|
+
onShippingAddressChange?: (change: {
|
|
70
|
+
name: string;
|
|
71
|
+
address: CartAddress;
|
|
72
|
+
delivery: string | null;
|
|
73
|
+
}) => Promise<StorefrontCart>;
|
|
74
|
+
/** Prices a new delivery choice: the basket's quote, with the sheet's address, unless you say. */
|
|
75
|
+
onShippingRateChange?: (change: {
|
|
76
|
+
delivery: string;
|
|
77
|
+
address: CartAddress | null;
|
|
78
|
+
}) => Promise<StorefrontCart>;
|
|
79
|
+
/**
|
|
80
|
+
* The shopper approved: pay with `confirmationToken` on your server. Answer `{ error }` (or
|
|
81
|
+
* throw) and the sheet says the payment failed, with your message where the wallet shows one.
|
|
82
|
+
*/
|
|
83
|
+
onConfirm: (confirm: ExpressConfirm) => Promise<unknown>;
|
|
84
|
+
/** How the buttons and Stripe's sheet look: the business's branding unless you say. */
|
|
85
|
+
appearance?: Appearance;
|
|
86
|
+
/** Collect a delivery address in the sheet (the default), or not (nothing is shipped). */
|
|
87
|
+
shipping?: boolean;
|
|
88
|
+
/** Ask the wallet for a phone number too. */
|
|
89
|
+
phone?: boolean;
|
|
90
|
+
allowedShippingCountries?: string[];
|
|
91
|
+
/** Stripe's own button options, passed as they are. */
|
|
92
|
+
buttons?: Pick<StripeExpressCheckoutElementOptions, "buttonHeight" | "buttonTheme" | "buttonType" | "layout" | "paymentMethods" | "paymentMethodOrder">;
|
|
93
|
+
/** For the delivery dates in the sheet: today, unless a test says otherwise. */
|
|
94
|
+
now?: () => Date;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Stripe's Express Checkout Element (Apple Pay, Google Pay, Link) for a store's basket, in
|
|
98
|
+
* deferred mode: nothing is made until the shopper approves. Each address and delivery choice in
|
|
99
|
+
* the sheet is priced by the basket's quote and the sheet's total follows it
|
|
100
|
+
* (`elements.update({ amount })`); the server charges its own figure at the end.
|
|
101
|
+
*/
|
|
102
|
+
export declare class ExpressCheckout extends Mountable<ExpressHandlers> {
|
|
103
|
+
private readonly tuppence;
|
|
104
|
+
private readonly payable;
|
|
105
|
+
private readonly options;
|
|
106
|
+
private readonly cart;
|
|
107
|
+
private wallet;
|
|
108
|
+
constructor(tuppence: Tuppence, payable: Payable, options: ExpressCheckoutOptions, cart: StorefrontCartClient);
|
|
109
|
+
protected load(target: string | HTMLElement): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
/** What the page hears from the address. */
|
|
112
|
+
export type AddressHandlers = {
|
|
113
|
+
ready: (detail: undefined) => void;
|
|
114
|
+
change: (change: AddressChange) => void;
|
|
115
|
+
error: (error: TuppenceError) => void;
|
|
116
|
+
};
|
|
117
|
+
export type AddressValue = StripeAddressElementChangeEvent["value"];
|
|
118
|
+
/** The address as typed, and as the basket's quote takes it once there is a country. */
|
|
119
|
+
export interface AddressChange {
|
|
120
|
+
complete: boolean;
|
|
121
|
+
empty: boolean;
|
|
122
|
+
value: AddressValue;
|
|
123
|
+
address: CartAddress | null;
|
|
124
|
+
}
|
|
125
|
+
export interface AddressOptions {
|
|
126
|
+
/** `shipping`: where the order goes (and the payment's shipping). `billing`: the card's. */
|
|
127
|
+
mode: "shipping" | "billing";
|
|
128
|
+
defaultValues?: StripeAddressElementOptions["defaultValues"];
|
|
129
|
+
/** The countries the store delivers to, for a shipping address. */
|
|
130
|
+
allowedCountries?: string[];
|
|
131
|
+
/** Ask for a phone number with the address. */
|
|
132
|
+
phone?: "always" | "never" | "auto";
|
|
133
|
+
/** Only when made from a payment alone: the business's branding unless you say. */
|
|
134
|
+
appearance?: Appearance;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Stripe's Address Element, with Stripe's autocomplete. In the payment's own Elements group (the
|
|
138
|
+
* `elements` a sheet or `tuppence.elements()` made) a shipping address goes with the payment,
|
|
139
|
+
* and Stripe's autocomplete works with no key of yours.
|
|
140
|
+
*/
|
|
141
|
+
export declare class AddressField extends Mountable<AddressHandlers> {
|
|
142
|
+
private readonly tuppence;
|
|
143
|
+
private readonly from;
|
|
144
|
+
private readonly options;
|
|
145
|
+
constructor(tuppence: Tuppence, from: StripeElements | Payable, options: AddressOptions);
|
|
146
|
+
/** The address, checked: Stripe shows what is missing beside its field. */
|
|
147
|
+
value(): Promise<{
|
|
148
|
+
complete: boolean;
|
|
149
|
+
value: AddressValue;
|
|
150
|
+
address: CartAddress | null;
|
|
151
|
+
}>;
|
|
152
|
+
protected load(target: string | HTMLElement): Promise<void>;
|
|
153
|
+
}
|
|
154
|
+
export {};
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { TuppenceError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The store's wallet buttons and address (85-10). What the page holds is here; what they do once
|
|
4
|
+
* mounted is in `./express-mount.js`, which `mount()` loads, so a page that only takes a payment
|
|
5
|
+
* (a payer's pay page) never downloads it (apps/web/scripts/check-pay-entry.mjs).
|
|
6
|
+
*/
|
|
7
|
+
const mounting = () => import("./express-mount.js");
|
|
8
|
+
export const wrap = (caught) => caught instanceof TuppenceError
|
|
9
|
+
? caught
|
|
10
|
+
: new TuppenceError({ type: "api_error", message: String(caught) });
|
|
11
|
+
/** No Stripe behind the environment (the simulated gateway): no wallet, no Stripe address. */
|
|
12
|
+
export const noStripe = (what) => new TuppenceError({
|
|
13
|
+
type: "invalid_request_error",
|
|
14
|
+
code: "no_card_fields",
|
|
15
|
+
message: `There is no ${what} here: this environment runs the simulated gateway.`,
|
|
16
|
+
});
|
|
17
|
+
/** Something mounted where the page says, telling the page what happens to it. */
|
|
18
|
+
class Mountable {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.handlers = {};
|
|
21
|
+
this.mounted = null;
|
|
22
|
+
this.destroyed = false;
|
|
23
|
+
this.element = null;
|
|
24
|
+
}
|
|
25
|
+
on(event, handler) {
|
|
26
|
+
const list = this.handlers[event] ?? [];
|
|
27
|
+
list.push(handler);
|
|
28
|
+
this.handlers[event] = list;
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
/** Shows it in `target` (an element, or a selector). Loads Stripe.js if it must. */
|
|
32
|
+
mount(target) {
|
|
33
|
+
this.destroyed = false;
|
|
34
|
+
this.mounted ?? (this.mounted = this.load(target).catch((caught) => this.emit("error", wrap(caught))));
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
/** Takes it off the page (or keeps it off, while it loads) and says nothing more. */
|
|
38
|
+
destroy() {
|
|
39
|
+
this.destroyed = true;
|
|
40
|
+
this.mounted = null;
|
|
41
|
+
this.element?.destroy();
|
|
42
|
+
this.element = null;
|
|
43
|
+
}
|
|
44
|
+
emit(event, detail) {
|
|
45
|
+
if (this.destroyed)
|
|
46
|
+
return;
|
|
47
|
+
for (const handler of this.handlers[event] ?? [])
|
|
48
|
+
handler(detail);
|
|
49
|
+
}
|
|
50
|
+
/** What the mounted half (`./express-mount.js`) may do to this. */
|
|
51
|
+
host() {
|
|
52
|
+
return {
|
|
53
|
+
emit: (event, detail) => this.emit(event, detail),
|
|
54
|
+
destroyed: () => this.destroyed,
|
|
55
|
+
attach: (element) => {
|
|
56
|
+
this.element = element;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Stripe's Express Checkout Element (Apple Pay, Google Pay, Link) for a store's basket, in
|
|
63
|
+
* deferred mode: nothing is made until the shopper approves. Each address and delivery choice in
|
|
64
|
+
* the sheet is priced by the basket's quote and the sheet's total follows it
|
|
65
|
+
* (`elements.update({ amount })`); the server charges its own figure at the end.
|
|
66
|
+
*/
|
|
67
|
+
export class ExpressCheckout extends Mountable {
|
|
68
|
+
constructor(tuppence, payable, options, cart) {
|
|
69
|
+
super();
|
|
70
|
+
this.tuppence = tuppence;
|
|
71
|
+
this.payable = payable;
|
|
72
|
+
this.options = options;
|
|
73
|
+
this.cart = cart;
|
|
74
|
+
this.wallet = null;
|
|
75
|
+
}
|
|
76
|
+
async load(target) {
|
|
77
|
+
const { Wallet } = await mounting();
|
|
78
|
+
if (this.destroyed)
|
|
79
|
+
return;
|
|
80
|
+
this.wallet ?? (this.wallet = new Wallet(this.host(), this.tuppence, this.payable, this.options, this.cart));
|
|
81
|
+
await this.wallet.load(target);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Stripe's Address Element, with Stripe's autocomplete. In the payment's own Elements group (the
|
|
86
|
+
* `elements` a sheet or `tuppence.elements()` made) a shipping address goes with the payment,
|
|
87
|
+
* and Stripe's autocomplete works with no key of yours.
|
|
88
|
+
*/
|
|
89
|
+
export class AddressField extends Mountable {
|
|
90
|
+
constructor(tuppence, from, options) {
|
|
91
|
+
super();
|
|
92
|
+
this.tuppence = tuppence;
|
|
93
|
+
this.from = from;
|
|
94
|
+
this.options = options;
|
|
95
|
+
}
|
|
96
|
+
/** The address, checked: Stripe shows what is missing beside its field. */
|
|
97
|
+
async value() {
|
|
98
|
+
await this.mounted;
|
|
99
|
+
const element = this.element;
|
|
100
|
+
if (!element)
|
|
101
|
+
throw new TypeError("Mount the address, and let it load, before reading it.");
|
|
102
|
+
const [{ complete, value }, { asCartAddress }] = await Promise.all([
|
|
103
|
+
element.getValue(),
|
|
104
|
+
mounting(),
|
|
105
|
+
]);
|
|
106
|
+
return { complete, value, address: asCartAddress(value) };
|
|
107
|
+
}
|
|
108
|
+
async load(target) {
|
|
109
|
+
const { mountAddress } = await mounting();
|
|
110
|
+
if (this.destroyed)
|
|
111
|
+
return;
|
|
112
|
+
await mountAddress(this.host(), this.tuppence, this.from, this.options, target);
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type Fetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
2
|
+
/**
|
|
3
|
+
* The browser's routes, with nothing ambient: no cookie rides along (`credentials: "omit"`; a
|
|
4
|
+
* store's basket on its own host is the one exception, `StorefrontCartClient`), and
|
|
5
|
+
* a secret — a publishable key, a client secret — only ever travels in a header, never a URL.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Http {
|
|
8
|
+
private readonly base;
|
|
9
|
+
private readonly fetchImpl;
|
|
10
|
+
private readonly sleep;
|
|
11
|
+
/** `same-origin` only for a store's own basket routes, whose cookie is the basket (85-10). */
|
|
12
|
+
private readonly credentials;
|
|
13
|
+
constructor(base: string, fetchImpl: Fetch, sleep?: (ms: number) => Promise<void>,
|
|
14
|
+
/** `same-origin` only for a store's own basket routes, whose cookie is the basket (85-10). */
|
|
15
|
+
credentials?: RequestCredentials);
|
|
16
|
+
get<T>(path: string, headers: Record<string, string>): Promise<T>;
|
|
17
|
+
post<T>(path: string, headers: Record<string, string>, body: unknown): Promise<T>;
|
|
18
|
+
private request;
|
|
19
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { TuppenceError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* A read the server briefly couldn't answer — no answer, a 502/503/504, a 429 — is sent again,
|
|
4
|
+
* twice at most (56-03: the client conformance scenario's 503). Only a read: a confirm is never
|
|
5
|
+
* sent twice, so the card is never presented twice; its lost answer is read back instead.
|
|
6
|
+
*/
|
|
7
|
+
const READ_RETRIES = 2;
|
|
8
|
+
const RETRY_BASE_MS = 250;
|
|
9
|
+
const RETRY_MAX_MS = 2000;
|
|
10
|
+
const TRANSIENT = new Set([429, 502, 503, 504]);
|
|
11
|
+
/**
|
|
12
|
+
* The browser's routes, with nothing ambient: no cookie rides along (`credentials: "omit"`; a
|
|
13
|
+
* store's basket on its own host is the one exception, `StorefrontCartClient`), and
|
|
14
|
+
* a secret — a publishable key, a client secret — only ever travels in a header, never a URL.
|
|
15
|
+
*/
|
|
16
|
+
export class Http {
|
|
17
|
+
constructor(base, fetchImpl, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
18
|
+
/** `same-origin` only for a store's own basket routes, whose cookie is the basket (85-10). */
|
|
19
|
+
credentials = "omit") {
|
|
20
|
+
this.base = base;
|
|
21
|
+
this.fetchImpl = fetchImpl;
|
|
22
|
+
this.sleep = sleep;
|
|
23
|
+
this.credentials = credentials;
|
|
24
|
+
}
|
|
25
|
+
get(path, headers) {
|
|
26
|
+
return this.request("GET", path, headers);
|
|
27
|
+
}
|
|
28
|
+
post(path, headers, body) {
|
|
29
|
+
return this.request("POST", path, headers, body);
|
|
30
|
+
}
|
|
31
|
+
async request(method, path, headers, body) {
|
|
32
|
+
const retries = method === "GET" ? READ_RETRIES : 0;
|
|
33
|
+
for (let attempt = 0;; attempt += 1) {
|
|
34
|
+
const wait = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await this.fetchImpl(`${this.base}${path}`, {
|
|
38
|
+
method,
|
|
39
|
+
credentials: this.credentials,
|
|
40
|
+
headers: {
|
|
41
|
+
Accept: "application/json",
|
|
42
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
43
|
+
...headers,
|
|
44
|
+
},
|
|
45
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
if (attempt < retries) {
|
|
50
|
+
await this.sleep(wait);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
throw new TuppenceError({
|
|
54
|
+
type: "api_connection_error",
|
|
55
|
+
message: "Tuppence could not be reached. Check the connection and try again.",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const json = await response.json().catch(() => null);
|
|
59
|
+
if (response.ok)
|
|
60
|
+
return json;
|
|
61
|
+
if (attempt < retries && TRANSIENT.has(response.status)) {
|
|
62
|
+
const after = Number(response.headers.get("retry-after"));
|
|
63
|
+
await this.sleep(after > 0 ? Math.min(after * 1000, RETRY_MAX_MS) : wait);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw TuppenceError.fromEnvelope(response.status, json);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|