@pithy-sh/payments 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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- package/src/workflows/wrangler.jsonc +55 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PAYMENTS_NO_BROWSER, type PaddleCheckoutHandoff, type PaymentsFailure } from "./api";
|
|
5
|
+
import {
|
|
6
|
+
loadPaddle,
|
|
7
|
+
type PaddleCheckoutSettings,
|
|
8
|
+
type PaddleCheckoutTheme,
|
|
9
|
+
type PaddleCheckoutVariant,
|
|
10
|
+
type PaddleOptions,
|
|
11
|
+
} from "./paddle";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Opening a Paddle checkout over the adopter's own page, or inside it.
|
|
15
|
+
*
|
|
16
|
+
* This is the half of the rail a redirect cannot do. `startCheckout` on a redirect rail sends the browser
|
|
17
|
+
* to somebody else's domain and the sale finishes there; Paddle's overlay draws the card form over the
|
|
18
|
+
* page the buyer was already on, and its inline mode draws it *in* the page, in a container the screen
|
|
19
|
+
* put there. Nothing about the money moves — Paddle is still the merchant of record, still owns SCA, tax
|
|
20
|
+
* and the card fields — but the buyer never leaves.
|
|
21
|
+
*
|
|
22
|
+
* ## `items[]` or `transactionId`, settled
|
|
23
|
+
*
|
|
24
|
+
* Paddle offers two ways to open a checkout. `items: [{ priceId, quantity }]` needs no server at all: a
|
|
25
|
+
* page with the publishable client token can open a checkout in one click, which is the single most
|
|
26
|
+
* pleasant thing about this provider. `transactionId` needs a round trip first, because a transaction is
|
|
27
|
+
* something the server creates.
|
|
28
|
+
*
|
|
29
|
+
* **This kit only ever opens a transaction, and the reason is the price and the buyer — not the stamp.**
|
|
30
|
+
* `items[]` lets the page name what is being sold and to whom, and every other route in this capability
|
|
31
|
+
* takes the price from the catalog entry a product id resolves to precisely so a client cannot buy Pro
|
|
32
|
+
* for the price of a coin pack. There is no reading under which that rule holds at `POST /checkout` and
|
|
33
|
+
* stops holding here. So the cost is one round trip before the overlay appears, paid once per purchase.
|
|
34
|
+
*
|
|
35
|
+
* **What `transactionId` does not buy is the ownership stamp, and that was measured rather than assumed.**
|
|
36
|
+
* #309 argued the stamp needed no proof because `Paddle.Checkout.open` refuses `customData` beside a
|
|
37
|
+
* transaction id. It does not refuse it. Against the live sandbox on 2026-08-13: a server-created
|
|
38
|
+
* transaction stamped `pithy_user: "server-owner"` was opened with
|
|
39
|
+
* `Paddle.Checkout.open({ transactionId, customData: { pithy_user: "attacker", … } })` from a page holding
|
|
40
|
+
* only the publishable client token, paid with a test card, and came back from `GET /transactions/{id}`
|
|
41
|
+
* carrying the attacker's values — same id, `origin` still `"api"`, stamp replaced. The recording is
|
|
42
|
+
* `../rails/paddle/fixtures/browserForged.ts` and the refusal is gated on it.
|
|
43
|
+
*
|
|
44
|
+
* So the two halves are independent, and both are needed. The transaction fixes what is sold; the MAC over
|
|
45
|
+
* `(environment, user)` keyed on the destination secret is the *only* thing that fixes who it belongs to,
|
|
46
|
+
* on either form of the call. See `../rails/paddle/objects.ts` for that half in full, and
|
|
47
|
+
* {@link PaddleCheckoutOpen} in `./paddle` for the type that keeps this one unrepresentable rather than
|
|
48
|
+
* merely conventional.
|
|
49
|
+
*
|
|
50
|
+
* ## How it looks is the caller's to say, and only what Paddle lets code say
|
|
51
|
+
*
|
|
52
|
+
* `theme`, `locale` and `variant` are passed through from the caller and are **never inferred**. No
|
|
53
|
+
* `prefers-color-scheme`, no `matchMedia`, no reading a computed style off the page. The OS preference is
|
|
54
|
+
* not the app's theme: an app with its own light/dark toggle, or one that is dark whatever the machine
|
|
55
|
+
* says, would get a card form contradicting the page it opened over — and a wrong guess is far harder to
|
|
56
|
+
* find than an option nobody passed. The adopter knows. So this takes it, and defaults to Paddle's default
|
|
57
|
+
* by saying nothing.
|
|
58
|
+
*
|
|
59
|
+
* **Saying nothing is a real message.** An omitted setting must reach `Checkout.open` as an absent key,
|
|
60
|
+
* not as a key holding `undefined`, because these overlay account-level settings a seller configured in
|
|
61
|
+
* the Paddle dashboard. {@link settingsFor} builds them one at a time for that reason, and the tests assert
|
|
62
|
+
* on `Object.keys` rather than on values.
|
|
63
|
+
*
|
|
64
|
+
* **Colors and fonts are not here because they are not anywhere in code.** Paddle configures them in its
|
|
65
|
+
* own dashboard — *Checkout → Branded inline checkout* for the 50-odd inline options, logo and brand
|
|
66
|
+
* color for the overlay. That is Paddle's product decision rather than a missing endpoint, and the one
|
|
67
|
+
* API that writes `primary_checkout_color` is a Partners route for a platform configuring another
|
|
68
|
+
* seller's account. Nothing to add here, and nothing to go looking for.
|
|
69
|
+
*
|
|
70
|
+
* ## Nothing here throws
|
|
71
|
+
*
|
|
72
|
+
* `Paddle.Checkout.open` is synchronous and returns nothing, so its only way to report a problem is to
|
|
73
|
+
* throw — inside a click handler, where an unhandled rejection is what a buyer gets instead of a card
|
|
74
|
+
* form. Every path through this module answers with a {@link PaymentsFailure} or with null.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/** A screen said `inline`, and there is no element with that class on the page to render into. */
|
|
78
|
+
export const PADDLE_NO_CONTAINER: PaymentsFailure = {
|
|
79
|
+
code: "client/paddle_no_container",
|
|
80
|
+
message: "We couldn't show the payment form.",
|
|
81
|
+
action: "Reload the page. If it keeps happening, this screen is missing its checkout container.",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Paddle.js refused to open the checkout. A transaction it will not sell, or one already completed. */
|
|
85
|
+
export const PADDLE_CHECKOUT_REFUSED: PaymentsFailure = {
|
|
86
|
+
code: "client/paddle_checkout_refused",
|
|
87
|
+
message: "We couldn't open the payment form.",
|
|
88
|
+
action: "Try again in a moment.",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The default styling of an inline checkout's container.
|
|
93
|
+
*
|
|
94
|
+
* Paddle's own recommendation, and `min-width` in it is a compliance requirement rather than taste: the
|
|
95
|
+
* inline frame carries the footer naming Paddle as merchant of record, and below 312px that footer is cut
|
|
96
|
+
* off. A default here rather than in the scaffolded screen keeps the number out of a file Pithy may never
|
|
97
|
+
* rewrite — and a screen that wants its own passes one.
|
|
98
|
+
*/
|
|
99
|
+
export const PADDLE_FRAME_STYLE = "width: 100%; min-width: 312px; background-color: transparent; border: none;";
|
|
100
|
+
|
|
101
|
+
/** The container's height in pixels before the frame resizes itself. Paddle's recommended 450. */
|
|
102
|
+
export const PADDLE_FRAME_HEIGHT = 450;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The slice of `document` this module reads, as an injectable seam.
|
|
106
|
+
*
|
|
107
|
+
* Structural for the reason `PaymentsGlobal` is: the package compiles in a program with no DOM lib
|
|
108
|
+
* alongside the Worker's code, and a test proves the no-browser path without a DOM.
|
|
109
|
+
*/
|
|
110
|
+
export interface PaddleDocument {
|
|
111
|
+
/** Elements carrying a class. Paddle's `frameTarget` is a class name, so this is the lookup it does. */
|
|
112
|
+
getElementsByClassName(names: string): { length: number };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What {@link openPaddleCheckout} lets a caller replace or decide. */
|
|
116
|
+
export interface PaddleCheckoutOptions extends PaddleOptions {
|
|
117
|
+
/**
|
|
118
|
+
* Light or dark. Omit to take Paddle's default, which is light.
|
|
119
|
+
*
|
|
120
|
+
* **Pass the theme your app is in. This kit will not work it out.** Reading `prefers-color-scheme` here
|
|
121
|
+
* would answer a different question — the machine's preference, not the app's — and an app with its own
|
|
122
|
+
* toggle would open a light card form over a dark page for a reader who chose dark. The screen already
|
|
123
|
+
* knows which theme it rendered; that is the only source that is right every time.
|
|
124
|
+
*
|
|
125
|
+
* Theme is the whole of what code may set. Colors and fonts live in the Paddle dashboard, deliberately.
|
|
126
|
+
*/
|
|
127
|
+
theme?: PaddleCheckoutTheme;
|
|
128
|
+
/**
|
|
129
|
+
* The buyer's language — `"fr"`, `"pt-BR"`. Omit to take the browser's.
|
|
130
|
+
*
|
|
131
|
+
* Worth passing exactly when the app has a language choice of its own, for the reason `theme` is worth
|
|
132
|
+
* passing: a reader who set the app to French should not meet a checkout in the language their browser
|
|
133
|
+
* was installed with.
|
|
134
|
+
*/
|
|
135
|
+
locale?: string;
|
|
136
|
+
/** One page or several. Omit to take Paddle's default, which is `multi-page`. */
|
|
137
|
+
variant?: PaddleCheckoutVariant;
|
|
138
|
+
/**
|
|
139
|
+
* The **class name** of the element an inline checkout renders into.
|
|
140
|
+
*
|
|
141
|
+
* The screen's to name, not the server's: the container is a thing in a layout, and a project switching
|
|
142
|
+
* `paddle.checkout` between `overlay` and `inline` in its config must not have to edit a scaffolded
|
|
143
|
+
* screen to match. So a screen passes one always and it is used only when the handoff says inline.
|
|
144
|
+
*/
|
|
145
|
+
frameTarget?: string;
|
|
146
|
+
/** Styles for that element. Defaults to {@link PADDLE_FRAME_STYLE}. */
|
|
147
|
+
frameStyle?: string;
|
|
148
|
+
/** Its height in pixels on load. Defaults to {@link PADDLE_FRAME_HEIGHT}. */
|
|
149
|
+
frameInitialHeight?: number;
|
|
150
|
+
/** Where the container is looked for. Defaults to the browser's own `document`. */
|
|
151
|
+
document?: PaddleDocument;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The document to consult — the injected one, else the browser's own, else none. */
|
|
155
|
+
function documentOf(options: PaddleCheckoutOptions | undefined): PaddleDocument | undefined {
|
|
156
|
+
if (options && "document" in options) return options.document;
|
|
157
|
+
return (globalThis as { document?: PaddleDocument }).document;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The presentation settings the caller actually named, and no key for the ones they did not.
|
|
162
|
+
*
|
|
163
|
+
* **Assigned one at a time rather than spread, and that is the whole point of the function.**
|
|
164
|
+
* `{ theme: options?.theme }` writes the key whatever the value is, and a key holding `undefined` is not
|
|
165
|
+
* the same object as no key: `"theme" in settings` is true for one and false for the other, and Paddle's
|
|
166
|
+
* settings sit over defaults a seller configured in its dashboard. A caller who passed nothing must not
|
|
167
|
+
* silently overrule an account that did.
|
|
168
|
+
*
|
|
169
|
+
* Nothing is inferred here. There is no branch that reads the environment, because the option not being
|
|
170
|
+
* set is an answer — take Paddle's default — rather than a question to go and resolve.
|
|
171
|
+
*/
|
|
172
|
+
function presentationOf(options: PaddleCheckoutOptions | undefined): PaddleCheckoutSettings {
|
|
173
|
+
const presentation: PaddleCheckoutSettings = {};
|
|
174
|
+
if (options?.theme !== undefined) presentation.theme = options.theme;
|
|
175
|
+
if (options?.locale !== undefined) presentation.locale = options.locale;
|
|
176
|
+
if (options?.variant !== undefined) presentation.variant = options.variant;
|
|
177
|
+
return presentation;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* How this checkout is presented, or the refusal that stops it being opened at all.
|
|
182
|
+
*
|
|
183
|
+
* Inline is checked here rather than left to Paddle because of what Paddle does instead. Measured against
|
|
184
|
+
* the live sandbox: `Checkout.open` with a `frameTarget` matching no element throws synchronously, out of
|
|
185
|
+
* the call, with `TypeError: Cannot read properties of undefined (reading 'appendChild')`. That is a real
|
|
186
|
+
* refusal and it is caught below — but it is somebody else's minified stack trace, and the honest thing to
|
|
187
|
+
* tell a screen is that it is missing its container.
|
|
188
|
+
*/
|
|
189
|
+
function settingsFor(
|
|
190
|
+
handoff: PaddleCheckoutHandoff,
|
|
191
|
+
options: PaddleCheckoutOptions | undefined,
|
|
192
|
+
): { settings: PaddleCheckoutSettings } | { failure: PaymentsFailure } {
|
|
193
|
+
const base: PaddleCheckoutSettings = {
|
|
194
|
+
displayMode: handoff.displayMode,
|
|
195
|
+
successUrl: handoff.successUrl,
|
|
196
|
+
...presentationOf(options),
|
|
197
|
+
};
|
|
198
|
+
if (handoff.displayMode !== "inline") return { settings: base };
|
|
199
|
+
|
|
200
|
+
const document = documentOf(options);
|
|
201
|
+
// A server render, not a screen with a missing container. Two different problems for two different
|
|
202
|
+
// people, and answering the wrong one sends an adopter looking in the wrong file.
|
|
203
|
+
if (document === undefined) return { failure: PAYMENTS_NO_BROWSER };
|
|
204
|
+
|
|
205
|
+
const frameTarget = options?.frameTarget;
|
|
206
|
+
if (frameTarget === undefined || frameTarget === "") return { failure: PADDLE_NO_CONTAINER };
|
|
207
|
+
if (document.getElementsByClassName(frameTarget).length === 0) return { failure: PADDLE_NO_CONTAINER };
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
settings: {
|
|
211
|
+
...base,
|
|
212
|
+
frameTarget,
|
|
213
|
+
frameStyle: options?.frameStyle ?? PADDLE_FRAME_STYLE,
|
|
214
|
+
frameInitialHeight: options?.frameInitialHeight ?? PADDLE_FRAME_HEIGHT,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Open the checkout the server handed back, over this page or inside it.
|
|
221
|
+
*
|
|
222
|
+
* Loads Paddle.js if it is not loaded, from the token and environment on the handoff itself — so a screen
|
|
223
|
+
* needs no second source of truth for which Paddle account it is selling through, and a handoff for one
|
|
224
|
+
* account cannot open against another page's Paddle. Returns null when the checkout opened, and a failure
|
|
225
|
+
* to render otherwise.
|
|
226
|
+
*
|
|
227
|
+
* **The container must be on the page before this is called.** In React that means an effect rather than
|
|
228
|
+
* a click handler: the render that reveals the container has to commit first. `usePaddleCheckout` in
|
|
229
|
+
* `./hooks` is that ordering, written once.
|
|
230
|
+
*/
|
|
231
|
+
export async function openPaddleCheckout(
|
|
232
|
+
handoff: PaddleCheckoutHandoff,
|
|
233
|
+
options?: PaddleCheckoutOptions,
|
|
234
|
+
): Promise<PaymentsFailure | null> {
|
|
235
|
+
const prepared = settingsFor(handoff, options);
|
|
236
|
+
// Before the load, deliberately. A checkout that cannot be rendered should not also fetch a script from
|
|
237
|
+
// a CDN, and the refusal is the same either way.
|
|
238
|
+
if ("failure" in prepared) return prepared.failure;
|
|
239
|
+
|
|
240
|
+
const loaded = await loadPaddle({ clientToken: handoff.clientToken, environment: handoff.environment }, options);
|
|
241
|
+
if (!loaded.ok) return loaded.failure;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
loaded.value.Checkout.open({ transactionId: handoff.transactionId, settings: prepared.settings });
|
|
245
|
+
} catch {
|
|
246
|
+
// Synchronous and void: a throw is Paddle.js's only channel for "I will not open this", and it does
|
|
247
|
+
// use it — a `frameTarget` matching nothing throws a bare `TypeError` about `appendChild`. Left alone
|
|
248
|
+
// that escapes into a buy button as an unhandled rejection.
|
|
249
|
+
return PADDLE_CHECKOUT_REFUSED;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"customerId": null,
|
|
3
|
+
"addressId": null,
|
|
4
|
+
"businessId": null,
|
|
5
|
+
"currencyCode": "USD",
|
|
6
|
+
"address": { "postalCode": "10115", "countryCode": "DE" },
|
|
7
|
+
"customerIpAddress": null,
|
|
8
|
+
"discountId": null,
|
|
9
|
+
"details": {
|
|
10
|
+
"lineItems": [
|
|
11
|
+
{
|
|
12
|
+
"price": {
|
|
13
|
+
"id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
|
|
14
|
+
"description": "Solo, monthly. $5/month.",
|
|
15
|
+
"type": "standard",
|
|
16
|
+
"name": "Monthly",
|
|
17
|
+
"productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
18
|
+
"billingCycle": { "interval": "month", "frequency": 1 },
|
|
19
|
+
"trialPeriod": null,
|
|
20
|
+
"taxMode": "location",
|
|
21
|
+
"unitPrice": { "amount": "500", "currencyCode": "USD" },
|
|
22
|
+
"unitPriceOverrides": [],
|
|
23
|
+
"customData": { "tier": "solo" },
|
|
24
|
+
"quantity": { "minimum": 1, "maximum": 1 },
|
|
25
|
+
"status": "active",
|
|
26
|
+
"createdAt": "2026-08-12T21:45:05.730177Z",
|
|
27
|
+
"updatedAt": "2026-08-12T21:45:05.730178Z",
|
|
28
|
+
"importMeta": null
|
|
29
|
+
},
|
|
30
|
+
"quantity": 1,
|
|
31
|
+
"taxRate": "0.19",
|
|
32
|
+
"unitTotals": { "subtotal": "420", "discount": "0", "tax": "80", "total": "500" },
|
|
33
|
+
"formattedUnitTotals": { "subtotal": "$4.20", "discount": "$0.00", "tax": "$0.80", "total": "$5.00" },
|
|
34
|
+
"totals": { "subtotal": "420", "discount": "0", "tax": "80", "total": "500" },
|
|
35
|
+
"formattedTotals": { "subtotal": "$4.20", "discount": "$0.00", "tax": "$0.80", "total": "$5.00" },
|
|
36
|
+
"product": {
|
|
37
|
+
"id": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
38
|
+
"name": "Solo",
|
|
39
|
+
"description": "One person, working from a browser rather than a terminal.",
|
|
40
|
+
"type": "standard",
|
|
41
|
+
"taxCategory": "saas",
|
|
42
|
+
"imageUrl": null,
|
|
43
|
+
"customData": { "tier": "solo" },
|
|
44
|
+
"status": "active",
|
|
45
|
+
"importMeta": null,
|
|
46
|
+
"createdAt": "2026-08-12T21:45:05.442Z",
|
|
47
|
+
"updatedAt": "2026-08-12T21:45:05.442Z"
|
|
48
|
+
},
|
|
49
|
+
"discounts": []
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"availablePaymentMethods": ["card", "paypal", "apple_pay"]
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"customerId": null,
|
|
3
|
+
"addressId": null,
|
|
4
|
+
"businessId": null,
|
|
5
|
+
"currencyCode": "USD",
|
|
6
|
+
"address": { "postalCode": "SW1A 1AA", "countryCode": "GB" },
|
|
7
|
+
"customerIpAddress": null,
|
|
8
|
+
"discountId": null,
|
|
9
|
+
"details": {
|
|
10
|
+
"lineItems": [
|
|
11
|
+
{
|
|
12
|
+
"price": {
|
|
13
|
+
"id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
|
|
14
|
+
"description": "Solo, monthly. $5/month.",
|
|
15
|
+
"type": "standard",
|
|
16
|
+
"name": "Monthly",
|
|
17
|
+
"productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
18
|
+
"billingCycle": { "interval": "month", "frequency": 1 },
|
|
19
|
+
"trialPeriod": null,
|
|
20
|
+
"taxMode": "location",
|
|
21
|
+
"unitPrice": { "amount": "500", "currencyCode": "USD" },
|
|
22
|
+
"unitPriceOverrides": [],
|
|
23
|
+
"customData": { "tier": "solo" },
|
|
24
|
+
"quantity": { "minimum": 1, "maximum": 1 },
|
|
25
|
+
"status": "active",
|
|
26
|
+
"createdAt": "2026-08-12T21:45:05.730177Z",
|
|
27
|
+
"updatedAt": "2026-08-12T21:45:05.730178Z",
|
|
28
|
+
"importMeta": null
|
|
29
|
+
},
|
|
30
|
+
"quantity": 1,
|
|
31
|
+
"taxRate": "0.2",
|
|
32
|
+
"unitTotals": { "subtotal": "417", "discount": "0", "tax": "83", "total": "500" },
|
|
33
|
+
"formattedUnitTotals": { "subtotal": "$4.17", "discount": "$0.00", "tax": "$0.83", "total": "$5.00" },
|
|
34
|
+
"totals": { "subtotal": "417", "discount": "0", "tax": "83", "total": "500" },
|
|
35
|
+
"formattedTotals": { "subtotal": "$4.17", "discount": "$0.00", "tax": "$0.83", "total": "$5.00" },
|
|
36
|
+
"product": {
|
|
37
|
+
"id": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
38
|
+
"name": "Solo",
|
|
39
|
+
"description": "One person, working from a browser rather than a terminal.",
|
|
40
|
+
"type": "standard",
|
|
41
|
+
"taxCategory": "saas",
|
|
42
|
+
"imageUrl": null,
|
|
43
|
+
"customData": { "tier": "solo" },
|
|
44
|
+
"status": "active",
|
|
45
|
+
"importMeta": null,
|
|
46
|
+
"createdAt": "2026-08-12T21:45:05.442Z",
|
|
47
|
+
"updatedAt": "2026-08-12T21:45:05.442Z"
|
|
48
|
+
},
|
|
49
|
+
"discounts": []
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"availablePaymentMethods": ["card", "paypal", "apple_pay"]
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"customerId": null,
|
|
3
|
+
"addressId": null,
|
|
4
|
+
"businessId": null,
|
|
5
|
+
"currencyCode": "JPY",
|
|
6
|
+
"address": { "postalCode": "1000001", "countryCode": "JP" },
|
|
7
|
+
"customerIpAddress": null,
|
|
8
|
+
"discountId": null,
|
|
9
|
+
"details": {
|
|
10
|
+
"lineItems": [
|
|
11
|
+
{
|
|
12
|
+
"price": {
|
|
13
|
+
"id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
|
|
14
|
+
"description": "Solo, monthly. $5/month.",
|
|
15
|
+
"type": "standard",
|
|
16
|
+
"name": "Monthly",
|
|
17
|
+
"productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
18
|
+
"billingCycle": { "interval": "month", "frequency": 1 },
|
|
19
|
+
"trialPeriod": null,
|
|
20
|
+
"taxMode": "location",
|
|
21
|
+
"unitPrice": { "amount": "500", "currencyCode": "USD" },
|
|
22
|
+
"unitPriceOverrides": [],
|
|
23
|
+
"customData": { "tier": "solo" },
|
|
24
|
+
"quantity": { "minimum": 1, "maximum": 1 },
|
|
25
|
+
"status": "active",
|
|
26
|
+
"createdAt": "2026-08-12T21:45:05.730177Z",
|
|
27
|
+
"updatedAt": "2026-08-12T21:45:05.730178Z",
|
|
28
|
+
"importMeta": null
|
|
29
|
+
},
|
|
30
|
+
"quantity": 1,
|
|
31
|
+
"taxRate": "0.1",
|
|
32
|
+
"unitTotals": { "subtotal": "725", "discount": "0", "tax": "73", "total": "798" },
|
|
33
|
+
"formattedUnitTotals": { "subtotal": "¥725", "discount": "¥0", "tax": "¥73", "total": "¥798" },
|
|
34
|
+
"totals": { "subtotal": "725", "discount": "0", "tax": "73", "total": "798" },
|
|
35
|
+
"formattedTotals": { "subtotal": "¥725", "discount": "¥0", "tax": "¥73", "total": "¥798" },
|
|
36
|
+
"product": {
|
|
37
|
+
"id": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
38
|
+
"name": "Solo",
|
|
39
|
+
"description": "One person, working from a browser rather than a terminal.",
|
|
40
|
+
"type": "standard",
|
|
41
|
+
"taxCategory": "saas",
|
|
42
|
+
"imageUrl": null,
|
|
43
|
+
"customData": { "tier": "solo" },
|
|
44
|
+
"status": "active",
|
|
45
|
+
"importMeta": null,
|
|
46
|
+
"createdAt": "2026-08-12T21:45:05.442Z",
|
|
47
|
+
"updatedAt": "2026-08-12T21:45:05.442Z"
|
|
48
|
+
},
|
|
49
|
+
"discounts": []
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"availablePaymentMethods": ["card", "paypal", "apple_pay"]
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"customerId": null,
|
|
3
|
+
"addressId": null,
|
|
4
|
+
"businessId": null,
|
|
5
|
+
"currencyCode": "USD",
|
|
6
|
+
"address": { "postalCode": "", "countryCode": "US" },
|
|
7
|
+
"customerIpAddress": null,
|
|
8
|
+
"discountId": null,
|
|
9
|
+
"details": {
|
|
10
|
+
"lineItems": [
|
|
11
|
+
{
|
|
12
|
+
"price": {
|
|
13
|
+
"id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
|
|
14
|
+
"description": "Solo, monthly. $5/month.",
|
|
15
|
+
"type": "standard",
|
|
16
|
+
"name": "Monthly",
|
|
17
|
+
"productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
18
|
+
"billingCycle": { "interval": "month", "frequency": 1 },
|
|
19
|
+
"trialPeriod": null,
|
|
20
|
+
"taxMode": "location",
|
|
21
|
+
"unitPrice": { "amount": "500", "currencyCode": "USD" },
|
|
22
|
+
"unitPriceOverrides": [],
|
|
23
|
+
"customData": { "tier": "solo" },
|
|
24
|
+
"quantity": { "minimum": 1, "maximum": 1 },
|
|
25
|
+
"status": "active",
|
|
26
|
+
"createdAt": "2026-08-12T21:45:05.730177Z",
|
|
27
|
+
"updatedAt": "2026-08-12T21:45:05.730178Z",
|
|
28
|
+
"importMeta": null
|
|
29
|
+
},
|
|
30
|
+
"quantity": 1,
|
|
31
|
+
"taxRate": "0",
|
|
32
|
+
"unitTotals": { "subtotal": "500", "discount": "0", "tax": "0", "total": "500" },
|
|
33
|
+
"formattedUnitTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.00", "total": "$5.00" },
|
|
34
|
+
"totals": { "subtotal": "500", "discount": "0", "tax": "0", "total": "500" },
|
|
35
|
+
"formattedTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.00", "total": "$5.00" },
|
|
36
|
+
"product": {
|
|
37
|
+
"id": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
38
|
+
"name": "Solo",
|
|
39
|
+
"description": "One person, working from a browser rather than a terminal.",
|
|
40
|
+
"type": "standard",
|
|
41
|
+
"taxCategory": "saas",
|
|
42
|
+
"imageUrl": null,
|
|
43
|
+
"customData": { "tier": "solo" },
|
|
44
|
+
"status": "active",
|
|
45
|
+
"importMeta": null,
|
|
46
|
+
"createdAt": "2026-08-12T21:45:05.442Z",
|
|
47
|
+
"updatedAt": "2026-08-12T21:45:05.442Z"
|
|
48
|
+
},
|
|
49
|
+
"discounts": []
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"availablePaymentMethods": ["card", "paypal", "apple_pay"]
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"customerId": null,
|
|
3
|
+
"addressId": null,
|
|
4
|
+
"businessId": null,
|
|
5
|
+
"currencyCode": "USD",
|
|
6
|
+
"address": { "postalCode": "10001", "countryCode": "US" },
|
|
7
|
+
"customerIpAddress": null,
|
|
8
|
+
"discountId": null,
|
|
9
|
+
"details": {
|
|
10
|
+
"lineItems": [
|
|
11
|
+
{
|
|
12
|
+
"price": {
|
|
13
|
+
"id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
|
|
14
|
+
"description": "Solo, monthly. $5/month.",
|
|
15
|
+
"type": "standard",
|
|
16
|
+
"name": "Monthly",
|
|
17
|
+
"productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
18
|
+
"billingCycle": { "interval": "month", "frequency": 1 },
|
|
19
|
+
"trialPeriod": null,
|
|
20
|
+
"taxMode": "location",
|
|
21
|
+
"unitPrice": { "amount": "500", "currencyCode": "USD" },
|
|
22
|
+
"unitPriceOverrides": [],
|
|
23
|
+
"customData": { "tier": "solo" },
|
|
24
|
+
"quantity": { "minimum": 1, "maximum": 1 },
|
|
25
|
+
"status": "active",
|
|
26
|
+
"createdAt": "2026-08-12T21:45:05.730177Z",
|
|
27
|
+
"updatedAt": "2026-08-12T21:45:05.730178Z",
|
|
28
|
+
"importMeta": null
|
|
29
|
+
},
|
|
30
|
+
"quantity": 1,
|
|
31
|
+
"taxRate": "0.08875",
|
|
32
|
+
"unitTotals": { "subtotal": "500", "discount": "0", "tax": "44", "total": "544" },
|
|
33
|
+
"formattedUnitTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.44", "total": "$5.44" },
|
|
34
|
+
"totals": { "subtotal": "500", "discount": "0", "tax": "44", "total": "544" },
|
|
35
|
+
"formattedTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.44", "total": "$5.44" },
|
|
36
|
+
"product": {
|
|
37
|
+
"id": "pro_01kzvyz95263x6amf3w4cgbqh4",
|
|
38
|
+
"name": "Solo",
|
|
39
|
+
"description": "One person, working from a browser rather than a terminal.",
|
|
40
|
+
"type": "standard",
|
|
41
|
+
"taxCategory": "saas",
|
|
42
|
+
"imageUrl": null,
|
|
43
|
+
"customData": { "tier": "solo" },
|
|
44
|
+
"status": "active",
|
|
45
|
+
"importMeta": null,
|
|
46
|
+
"createdAt": "2026-08-12T21:45:05.442Z",
|
|
47
|
+
"updatedAt": "2026-08-12T21:45:05.442Z"
|
|
48
|
+
},
|
|
49
|
+
"discounts": []
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"availablePaymentMethods": ["card", "paypal", "apple_pay"]
|
|
54
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import de from "./price-preview-de.json" with { type: "json" };
|
|
5
|
+
import gb from "./price-preview-gb.json" with { type: "json" };
|
|
6
|
+
import jpYen from "./price-preview-jp-yen.json" with { type: "json" };
|
|
7
|
+
import usCountryOnly from "./price-preview-us-country-only.json" with { type: "json" };
|
|
8
|
+
import usNewYork from "./price-preview-us-new-york.json" with { type: "json" };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Five real `PricePreview` answers, recorded from the Paddle **sandbox** on 2026-08-13 against one
|
|
12
|
+
* $5.00/month price with no `unit_price_overrides`.
|
|
13
|
+
*
|
|
14
|
+
* They exist because the claim this rail makes — "prices render localized and tax-correct" — is a claim
|
|
15
|
+
* about numbers Paddle chooses, and a test written against numbers *we* chose proves only that our
|
|
16
|
+
* arithmetic agrees with itself. Each of these was fetched, not composed, and each pins a different
|
|
17
|
+
* behavior of Paddle's that the reader has to get right:
|
|
18
|
+
*
|
|
19
|
+
* | Fixture | Subtotal | Tax | Total | What it pins |
|
|
20
|
+
* |---|---|---|---|---|
|
|
21
|
+
* | {@link US_NEW_YORK} | $5.00 | $0.44 | $5.44 | Tax **added** on top of the listed price. |
|
|
22
|
+
* | {@link US_COUNTRY_ONLY} | $5.00 | $0.00 | $5.00 | The same country with no postal code resolves **0% tax**. |
|
|
23
|
+
* | {@link GB} | $4.17 | $0.83 | $5.00 | VAT taken **out of** the listed price. |
|
|
24
|
+
* | {@link DE} | $4.20 | $0.80 | $5.00 | The same convention, a different rate. |
|
|
25
|
+
* | {@link JP_YEN} | ¥725 | ¥73 | ¥798 | A **zero-decimal** currency, and a converted amount. |
|
|
26
|
+
*
|
|
27
|
+
* Two facts these recordings carry that are easy to state wrongly from memory:
|
|
28
|
+
*
|
|
29
|
+
* **Currency is not localized.** Every response but the last is in USD, from a UK, German and Japanese
|
|
30
|
+
* address alike. Currency comes from `unit_price_overrides` in the catalog, and this price has none.
|
|
31
|
+
* What localizes without them is tax and formatting.
|
|
32
|
+
*
|
|
33
|
+
* **A converted amount is not a stable number.** {@link JP_YEN} was fetched twice minutes apart and
|
|
34
|
+
* returned ¥797 then ¥798 — Paddle's FX rate moved between the calls. Nothing may assert an exact
|
|
35
|
+
* converted figure against a live account; that is what a recording is for.
|
|
36
|
+
*
|
|
37
|
+
* **They carry the envelope, because that is what `PricePreview()` resolves.** Each JSON file below
|
|
38
|
+
* holds a recorded `data` payload; the exported value wraps it as `{ data }`, which is the shape the
|
|
39
|
+
* reader is actually handed. That wrapping is not cosmetic and it is the whole of `#416`: these five
|
|
40
|
+
* were originally exported unwrapped, `readPricePreview` was written to match them, and the reader
|
|
41
|
+
* therefore refused every real answer while this suite stayed green. A recording saved at the wrong
|
|
42
|
+
* depth is a gate that cannot fail. The envelope was verified against a live sandbox call on
|
|
43
|
+
* 2026-08-18 — top-level keys `data` and `meta`.
|
|
44
|
+
*
|
|
45
|
+
* `meta` is not recorded here. The live envelope carries `{ requestId: "715c7a82-…" }` — verified, not
|
|
46
|
+
* assumed — and nothing in this package reads it. It is left out because a fixture carrying a field no
|
|
47
|
+
* reader consults is an invitation to start consulting it, and because a per-call id pinned in a
|
|
48
|
+
* checked-in file is a value that was never true twice.
|
|
49
|
+
*
|
|
50
|
+
* Typed `unknown` on purpose. These are the input to {@link readPricePreview}, which is a trust
|
|
51
|
+
* boundary, and handing it a value TypeScript already believes would test the guard against itself.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** New York, 10001. 8.875% added on top of a $5.00 listed price. */
|
|
55
|
+
export const US_NEW_YORK: unknown = { data: usNewYork };
|
|
56
|
+
|
|
57
|
+
/** The United States with no postal code. Tax resolves to zero, and the buyer will still pay $5.44. */
|
|
58
|
+
export const US_COUNTRY_ONLY: unknown = { data: usCountryOnly };
|
|
59
|
+
|
|
60
|
+
/** London, SW1A 1AA. 20% VAT taken out of an inclusive $5.00. */
|
|
61
|
+
export const GB: unknown = { data: gb };
|
|
62
|
+
|
|
63
|
+
/** Berlin, 10115. 19% VAT, same inclusive convention, different rate. */
|
|
64
|
+
export const DE: unknown = { data: de };
|
|
65
|
+
|
|
66
|
+
/** Tokyo, with `currencyCode: "JPY"` forced. Whole yen — ¥725, not ¥72500. */
|
|
67
|
+
export const JP_YEN: unknown = { data: jpYen };
|