@tuppence/js 0.0.0-stage → 0.1.0

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 CHANGED
@@ -1,3 +1,183 @@
1
- # Temporary Holding Version
1
+ # @tuppence/js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Take a card in the browser for a [Tuppence](https://tuppence.ai) payment — through Stripe's own
4
+ card fields, never ours. Card details go from the payer's device to Stripe, and never through
5
+ Tuppence, this package, or your code.
6
+
7
+ ```bash
8
+ npm install @tuppence/js
9
+ ```
10
+
11
+ ## Pay a payment
12
+
13
+ Your server creates the payment (`POST /v1/payments`) and hands the browser its `client_secret`.
14
+
15
+ ```js
16
+ import { loadTuppence } from "@tuppence/js";
17
+
18
+ // Your PUBLISHABLE key: a secret key is refused before it is sent anywhere.
19
+ const tuppence = await loadTuppence("pk_test_…");
20
+
21
+ // Stripe's Payment Element, in the business's colours, set up to match the charge.
22
+ const sheet = tuppence.paymentSheet({ clientSecret }).mount("#card");
23
+ sheet.on("ready", () => (payButton.disabled = false));
24
+ sheet.on("error", (error) => show(error.message));
25
+
26
+ form.addEventListener("submit", async (event) => {
27
+ event.preventDefault();
28
+ const result = await sheet.confirm();
29
+ if (result.error) {
30
+ show(result.error.message); // a refused card is an answer, written for the payer
31
+ } else if (result.payment.status === "succeeded") {
32
+ done();
33
+ }
34
+ });
35
+ ```
36
+
37
+ `confirm` presents the card (Stripe's fields make a confirmation token), lets the payer's bank
38
+ check it's them if it asks — in Stripe's own frame — and reads the payment back until it settles.
39
+ If an answer is lost on the way, it reads back what happened: a card is never presented twice.
40
+ A read the server briefly can't answer (a 503, no answer) is sent again, twice at most; a confirm
41
+ never is.
42
+
43
+ A page that adds to what is charged (a tip) calls `sheet.setAmount(total)` and passes
44
+ `sheet.confirm({ tipAmount })`. `wallets: "never"` hides Apple Pay and Google Pay, and
45
+ `layout: "accordion"` changes Stripe's layout.
46
+
47
+ A payment that waits on the payer's bank when your page opens (a charge to a saved card whose
48
+ bank asked) is finished with `tuppence.resumePayment({ clientSecret })`.
49
+
50
+ For your own layout, `tuppence.elements(payment)` gives Stripe's Elements group, set up to match
51
+ the charge, and `tuppence.confirmPayment({ clientSecret, elements })` pays with it.
52
+
53
+ ## Bacs Direct Debit
54
+
55
+ Where the business takes Direct Debit, Stripe's Payment Element offers *Bacs Direct Debit* beside
56
+ cards, and shows the mandate and the Direct Debit Guarantee itself — don't hide or clip it. A
57
+ debit takes a few working days, so `confirm` answers at once with the payment `processing`:
58
+
59
+ ```js
60
+ const { payment } = await sheet.confirm();
61
+ if (payment.status === "processing" && payment.payment_method_type === "bacs_debit") {
62
+ // "Your Direct Debit is set up. We'll collect £50.00 on or around Tue 14 Oct."
63
+ showSetUp(payment.amount, payment.processing.expected_debit_date); // YYYY-MM-DD, or null
64
+ }
65
+ ```
66
+
67
+ Nothing waits for `succeeded`: that (or `failed`) arrives days later, by webhook
68
+ (`payment.succeeded`, `payment.failed`). Stripe emails the payer to confirm the mandate.
69
+ Where a debit can't be used — a booking's deposit, a till, a credit top-up — the payment's
70
+ `excluded_payment_method_types` says so, and the sheet leaves Bacs out.
71
+
72
+ ## Apple Pay, Google Pay and Link on a store
73
+
74
+ A Tuppence store's checkout puts the wallet buttons first. `expressCheckout` mounts Stripe's
75
+ Express Checkout Element in deferred mode (nothing is made until the shopper approves), in the
76
+ business's branding:
77
+
78
+ ```js
79
+ import { createTuppence } from "@tuppence/js";
80
+
81
+ const tuppence = createTuppence();
82
+ const cart = tuppence.cart(); // the store's own domain; `{ base: "/<slug>" }` on the shared address
83
+ const basket = await cart.get();
84
+
85
+ const express = tuppence
86
+ .expressCheckout(checkout, { basket, onConfirm }) // `checkout`: amount, currency, Stripe's key and account
87
+ .mount("#express");
88
+ express.on("total", (quoted) => showTotal(quoted.totals.due));
89
+ express.on("error", (error) => show(error.message)); // "Sorry, we don't deliver to Jersey."
90
+
91
+ async function onConfirm({ confirmationToken, amount, delivery, email, shippingAddress }) {
92
+ // Your server pays the checkout with the token. Answer `{ error: { message } }` and the
93
+ // wallet says the payment failed.
94
+ }
95
+ ```
96
+
97
+ Each address and delivery choice in the wallet's sheet is priced by the basket's quote
98
+ (`POST /storefront/v1/cart/quote`, which changes nothing), and the sheet's total follows it
99
+ (`elements.update({ amount })`). Stripe gives the sheet 20 seconds to hear back; a quote that
100
+ takes 15 is refused (`quote_timeout`), so the sheet says so rather than hanging. An address the
101
+ store doesn't deliver to is refused in the sheet, and the `error` event carries the store's
102
+ reason. Closing the sheet puts the basket's own total back. `amount` in `onConfirm` is what the
103
+ sheet showed: the server charges its own figure, never the page's.
104
+
105
+ Pass `onShippingAddressChange` or `onShippingRateChange` to price the sheet yourself,
106
+ `shipping: false` for a basket with nothing to send, and `buttons` for Stripe's own button
107
+ options. On the store's own hosts the basket's cookie rides along with the quote; headless, pass
108
+ `tuppence.cart({ base: "https://api.tuppence.ai", publishableKey, cartToken })`.
109
+
110
+ ## An address with Stripe's autocomplete
111
+
112
+ ```js
113
+ const elements = await tuppence.elements(checkout); // the payment's own fields
114
+ const address = tuppence
115
+ .address(elements, { mode: "shipping", defaultValues: { address: { country: "GB" } } })
116
+ .mount("#address");
117
+ address.on("change", ({ complete, address }) => complete && cart.quote({ address }));
118
+ const { complete, value } = await address.value(); // Stripe shows what's missing by its field
119
+ ```
120
+
121
+ In the payment's own Elements group a shipping address goes with the payment, and Stripe's
122
+ autocomplete works with no key of yours. `tuppence.address(checkout, { mode: "billing" })` makes
123
+ Elements of its own, in the business's branding.
124
+
125
+ ## Save a card with nothing charged
126
+
127
+ ```js
128
+ const sheet = tuppence.setupSheet({ clientSecret: setupClientSecret }).mount("#card");
129
+ const result = await sheet.confirm();
130
+ ```
131
+
132
+ ## Test mode without a Stripe sandbox
133
+
134
+ An environment with no Stripe sandbox behind it runs Tuppence's simulated gateway: there are no
135
+ card fields (`elements()` answers `null`, and a sheet's `error` event says so), and you name one
136
+ of Stripe's test cards instead —
137
+ `confirmPayment({ clientSecret, testPaymentMethod: "pm_card_gb" })`. `tuppence.config.stripe`
138
+ says which gateway you are on.
139
+
140
+ ## Another API address
141
+
142
+ The SDK calls `https://api.tuppence.ai`. To use another Tuppence environment you have been given,
143
+ pass `apiBase`, and allow that address in your page's `connect-src`:
144
+
145
+ ```js
146
+ const tuppence = await loadTuppence("pk_test_…", { apiBase: "https://…" });
147
+ ```
148
+
149
+ ## Your page's Content-Security-Policy
150
+
151
+ Stripe.js is loaded from `js.stripe.com` — never bundled — when card fields are first asked for.
152
+ Allow what Stripe's integration security guide lists, and Tuppence's API, beside your page's own
153
+ sources (`script-src 'self'`, and so on):
154
+
155
+ ```
156
+ script-src https://js.stripe.com https://*.js.stripe.com
157
+ frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com
158
+ connect-src https://api.stripe.com https://api.tuppence.ai
159
+ ```
160
+
161
+ `hooks.stripe.com` is where a bank's check (3-D Secure) opens. The SDK itself needs nothing more:
162
+ no inline script or style, no `eval`. Tuppence's own tests run a checkout under exactly this
163
+ policy, read from this file, and pay through Stripe's fields.
164
+
165
+ The publishable key and client secrets travel in headers, never in a URL, and no cookie is sent
166
+ (except a store's own basket cookie, to its own host's basket routes).
167
+
168
+ ### No integrity hash on Stripe.js
169
+
170
+ Stripe serves Stripe.js from `js.stripe.com` and changes it in place — fixes, and the fraud
171
+ signals Radar relies on — so it cannot carry a Subresource Integrity (SRI) hash: Stripe does not
172
+ support one, and does not allow Stripe.js to be bundled or hosted anywhere else. The SDK loads it
173
+ through Stripe's own loader (`@stripe/stripe-js`), with no `integrity` attribute. What keeps the
174
+ page's scripts in check is the policy above: scripts only from your origin and Stripe's.
175
+
176
+ `@tuppence/js` itself is part of your bundle, pinned by your lockfile's integrity hash.
177
+
178
+ ### What this package never does
179
+
180
+ It never renders a card field of its own — Stripe's iframes take the card — and never carries
181
+ Stripe.js. Its build fails if either creeps in: the bundle and every published file are checked
182
+ for an `<input>`, for the names card fields go by (`cardnumber`, `cc-number`, `cvc`…), and for
183
+ Stripe.js's own code.
@@ -0,0 +1,8 @@
1
+ import type { Appearance } from "@stripe/stripe-js";
2
+ import type { Branding } from "./types.js";
3
+ /**
4
+ * Stripe's card fields dressed as the business's page (plan 09-06): its accent colour — already
5
+ * checked for contrast when it was set (09-08) — on Tuppence's type and corners, so the fields
6
+ * look like the rest of the page rather than like a form from somewhere else.
7
+ */
8
+ export declare function appearanceFor(branding: Branding | null | undefined): Appearance;
@@ -0,0 +1,25 @@
1
+ /** Tuppence Blue — the pay pages' accent where the business has not set its own. */
2
+ const TUPPENCE_BLUE = "#3b5bff";
3
+ /**
4
+ * Stripe's card fields dressed as the business's page (plan 09-06): its accent colour — already
5
+ * checked for contrast when it was set (09-08) — on Tuppence's type and corners, so the fields
6
+ * look like the rest of the page rather than like a form from somewhere else.
7
+ */
8
+ export function appearanceFor(branding) {
9
+ const accent = branding?.accent_color ?? TUPPENCE_BLUE;
10
+ return {
11
+ theme: "stripe",
12
+ variables: {
13
+ colorPrimary: accent,
14
+ colorText: "#1f2937",
15
+ colorDanger: "#b42318",
16
+ fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
17
+ fontSizeBase: "16px",
18
+ borderRadius: "8px",
19
+ spacingUnit: "4px",
20
+ },
21
+ rules: {
22
+ ".Input:focus": { borderColor: accent, boxShadow: `0 0 0 2px ${accent}33` },
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,12 @@
1
+ import { TuppenceError } from "./errors.js";
2
+ import type { BankAccountDetails, ExternalAccountParams } from "./types.js";
3
+ /**
4
+ * `bankAccountDetails`'s work (52-09): the details checked and put in the shape
5
+ * `POST /v1/accounts/{id}/external_accounts` takes as `external_account`. Pure — nothing is sent
6
+ * from here. Messages name the field, never its value.
7
+ */
8
+ export declare function externalAccountParams(details: BankAccountDetails): {
9
+ externalAccount: ExternalAccountParams;
10
+ } | {
11
+ error: TuppenceError;
12
+ };
@@ -0,0 +1,117 @@
1
+ import { TuppenceError } from "./errors.js";
2
+ const invalid = (message, param) => new TuppenceError({ type: "invalid_request_error", code: "parameter_invalid", message, param });
3
+ /**
4
+ * The eurozone's bank accounts are IBANs (100-25): each live country's length, as Tuppence
5
+ * checks them. Bulgaria joins when it opens.
6
+ */
7
+ const IBAN_LENGTHS = {
8
+ AT: 20,
9
+ BE: 16,
10
+ CY: 28,
11
+ DE: 22,
12
+ EE: 20,
13
+ ES: 24,
14
+ FI: 18,
15
+ FR: 27,
16
+ GR: 27,
17
+ HR: 21,
18
+ IE: 22,
19
+ IT: 27,
20
+ LT: 20,
21
+ LU: 20,
22
+ LV: 21,
23
+ MT: 31,
24
+ NL: 18,
25
+ PT: 25,
26
+ SI: 19,
27
+ SK: 24,
28
+ };
29
+ /** Whether an IBAN's check digits are right: its rearranged digits leave 1 when divided by 97. */
30
+ function mod97(iban) {
31
+ let remainder = 0;
32
+ for (const ch of `${iban.slice(4)}${iban.slice(0, 4)}`) {
33
+ for (const digit of String(Number.parseInt(ch, 36))) {
34
+ remainder = (remainder * 10 + Number(digit)) % 97;
35
+ }
36
+ }
37
+ return remainder === 1;
38
+ }
39
+ /** Why `iban` can't be a bank account in `country`, or null. Never the value itself. */
40
+ function ibanError(iban, country, length) {
41
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban) || !iban.startsWith(country)) {
42
+ return invalid("Enter the IBAN: it starts with the bank's country code.", "account_number");
43
+ }
44
+ if (iban.length !== length) {
45
+ return invalid(`Check the IBAN: it is ${length} characters in this country.`, "account_number");
46
+ }
47
+ if (!mod97(iban)) {
48
+ return invalid("Check the IBAN: a character is wrong.", "account_number");
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * `bankAccountDetails`'s work (52-09): the details checked and put in the shape
54
+ * `POST /v1/accounts/{id}/external_accounts` takes as `external_account`. Pure — nothing is sent
55
+ * from here. Messages name the field, never its value.
56
+ */
57
+ export function externalAccountParams(details) {
58
+ const country = details.country.toUpperCase();
59
+ const routing = (details.routingNumber ?? "").replace(/[\s-]/g, "");
60
+ const account = details.accountNumber.replace(/[\s-]/g, "").toUpperCase();
61
+ const holder = details.accountHolderName.trim();
62
+ const ibanLength = IBAN_LENGTHS[country];
63
+ if (country !== "GB" && country !== "US" && !ibanLength) {
64
+ return {
65
+ error: invalid("Bank accounts can be added in GB, the US or the eurozone.", "country"),
66
+ };
67
+ }
68
+ if (!holder)
69
+ return { error: invalid("Enter the name on the account.", "account_holder_name") };
70
+ if (ibanLength) {
71
+ if (routing) {
72
+ return {
73
+ error: invalid("For this country, the IBAN is the whole bank account: leave the routing number out.", "routing_number"),
74
+ };
75
+ }
76
+ const error = ibanError(account, country, ibanLength);
77
+ if (error)
78
+ return { error };
79
+ return {
80
+ externalAccount: {
81
+ object: "bank_account",
82
+ country,
83
+ currency: details.currency.toLowerCase(),
84
+ account_holder_name: holder,
85
+ account_holder_type: details.accountHolderType,
86
+ account_number: account,
87
+ },
88
+ };
89
+ }
90
+ if (country === "GB") {
91
+ if (!/^\d{6}$/.test(routing)) {
92
+ return { error: invalid("A sort code is 6 digits, like 10-88-00.", "routing_number") };
93
+ }
94
+ if (!/^\d{8}$/.test(account)) {
95
+ return { error: invalid("A UK account number is 8 digits.", "account_number") };
96
+ }
97
+ }
98
+ else {
99
+ if (!/^\d{9}$/.test(routing)) {
100
+ return { error: invalid("A routing number is 9 digits.", "routing_number") };
101
+ }
102
+ if (!/^\d{4,17}$/.test(account)) {
103
+ return { error: invalid("Check the account number: 4 to 17 digits.", "account_number") };
104
+ }
105
+ }
106
+ return {
107
+ externalAccount: {
108
+ object: "bank_account",
109
+ country,
110
+ currency: details.currency.toLowerCase(),
111
+ account_holder_name: holder,
112
+ account_holder_type: details.accountHolderType,
113
+ routing_number: routing,
114
+ account_number: account,
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Why something did not happen — Tuppence's error envelope, or Stripe.js's, in one shape.
3
+ *
4
+ * `type` is `card_error` for a card the bank refused (show `message` to the payer: it is written
5
+ * for them), `validation_error` for card fields that are not complete, `api_connection_error`
6
+ * when Tuppence could not be reached, and otherwise the API's own type.
7
+ */
8
+ export declare class TuppenceError extends Error {
9
+ readonly type: string;
10
+ readonly code: string | null;
11
+ readonly declineCode: string | null;
12
+ readonly param: string | null;
13
+ /** The HTTP status, or 0 when there was no answer. */
14
+ readonly status: number;
15
+ constructor(fields: {
16
+ type: string;
17
+ message: string;
18
+ code?: string | null;
19
+ declineCode?: string | null;
20
+ param?: string | null;
21
+ status?: number;
22
+ });
23
+ /** The API's envelope: `{ error: { type, code, message, param, decline_code } }`. */
24
+ static fromEnvelope(status: number, body: unknown): TuppenceError;
25
+ /** Stripe.js's error (card fields, a confirmation token, a bank's check). */
26
+ static fromStripe(error: {
27
+ type?: string;
28
+ message?: string;
29
+ code?: string;
30
+ decline_code?: string;
31
+ param?: string;
32
+ }): TuppenceError;
33
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Why something did not happen — Tuppence's error envelope, or Stripe.js's, in one shape.
3
+ *
4
+ * `type` is `card_error` for a card the bank refused (show `message` to the payer: it is written
5
+ * for them), `validation_error` for card fields that are not complete, `api_connection_error`
6
+ * when Tuppence could not be reached, and otherwise the API's own type.
7
+ */
8
+ export class TuppenceError extends Error {
9
+ constructor(fields) {
10
+ super(fields.message);
11
+ this.name = "TuppenceError";
12
+ this.type = fields.type;
13
+ this.code = fields.code ?? null;
14
+ this.declineCode = fields.declineCode ?? null;
15
+ this.param = fields.param ?? null;
16
+ this.status = fields.status ?? 0;
17
+ }
18
+ /** The API's envelope: `{ error: { type, code, message, param, decline_code } }`. */
19
+ static fromEnvelope(status, body) {
20
+ const error = body?.error ?? {};
21
+ const text = (key) => (typeof error[key] === "string" ? String(error[key]) : null);
22
+ return new TuppenceError({
23
+ type: text("type") ?? "api_error",
24
+ message: text("message") ?? `Tuppence answered ${status}.`,
25
+ code: text("code"),
26
+ declineCode: text("decline_code"),
27
+ param: text("param"),
28
+ status,
29
+ });
30
+ }
31
+ /** Stripe.js's error (card fields, a confirmation token, a bank's check). */
32
+ static fromStripe(error) {
33
+ return new TuppenceError({
34
+ type: error.type ?? "api_error",
35
+ message: error.message ?? "Stripe could not take the card.",
36
+ code: error.code ?? null,
37
+ declineCode: error.decline_code ?? null,
38
+ param: error.param ?? null,
39
+ });
40
+ }
41
+ }
@@ -0,0 +1,49 @@
1
+ import type { StripeElements } from "@stripe/stripe-js";
2
+ import { type AddressHandlers, type AddressOptions, type AddressValue, type ExpressCheckoutOptions, type ExpressHandlers, type Host } from "./express.js";
3
+ import type { CartAddress, StorefrontCartClient } from "./storefront.js";
4
+ import type { Payable, Tuppence } from "./tuppence.js";
5
+ /**
6
+ * A wallet's or the Address Element's address, as the quote takes it. Wallets redact it until
7
+ * the payment is authorised (a city, a region, part of a postcode). A region that is a name
8
+ * ("West Yorkshire") is no code, so it is left out.
9
+ */
10
+ export declare function cartAddressOf(address: {
11
+ country?: string | null;
12
+ state?: string | null;
13
+ postal_code?: string | null;
14
+ }): CartAddress;
15
+ /** The address as the basket's quote takes it, once there is a country. */
16
+ export declare const asCartAddress: (value: AddressValue | undefined) => CartAddress | null;
17
+ /**
18
+ * What `ExpressCheckout` does once it is mounted (85-10): Stripe's Express Checkout Element (Apple
19
+ * Pay, Google Pay, Link) for a store's basket, in deferred mode: nothing is made until the shopper
20
+ * approves. Each address and delivery choice in the sheet is priced by the basket's quote and the
21
+ * sheet's total follows it (`elements.update({ amount })`); the server charges its own figure at
22
+ * the end. One per `ExpressCheckout`, so a remount keeps the basket as last quoted.
23
+ */
24
+ export declare class Wallet {
25
+ private readonly host;
26
+ private readonly tuppence;
27
+ private readonly payable;
28
+ private readonly options;
29
+ private readonly cart;
30
+ private basket;
31
+ private address;
32
+ private delivery;
33
+ private amount;
34
+ private elements;
35
+ constructor(host: Host<ExpressHandlers>, tuppence: Tuppence, payable: Payable, options: ExpressCheckoutOptions, cart: StorefrontCartClient);
36
+ /** The delivery choices as the sheet lists them: the chosen one first, as Stripe selects it. */
37
+ private ratesOf;
38
+ /** A quote, or why there is none: refused after 15 s rather than left to hang the sheet. */
39
+ private within;
40
+ /** The quote's answer for the sheet, or the reason it can't be delivered. */
41
+ private deliverable;
42
+ private priced;
43
+ private addressChanged;
44
+ private rateChanged;
45
+ private confirmed;
46
+ load(target: string | HTMLElement): Promise<void>;
47
+ }
48
+ /** Stripe's Address Element, with Stripe's autocomplete, for `AddressField`. */
49
+ export declare function mountAddress(host: Host<AddressHandlers>, tuppence: Tuppence, from: StripeElements | Payable, options: AddressOptions, target: string | HTMLElement): Promise<void>;