@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,733 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { initializePaddle } from "@paddle/paddle-js";
|
|
5
|
+
import {
|
|
6
|
+
PAYMENTS_NO_BROWSER,
|
|
7
|
+
PAYMENTS_UNREADABLE,
|
|
8
|
+
type PaymentsFailure,
|
|
9
|
+
type PaymentsPaddleDisplayMode,
|
|
10
|
+
type PaymentsPaddleEnvironment,
|
|
11
|
+
type PaymentsResult,
|
|
12
|
+
} from "./api";
|
|
13
|
+
import { cachedAnswer, type PaddleQuoteCache, readQuoteCache, rememberAnswer } from "./paddleCache";
|
|
14
|
+
import { withoutZeroFraction } from "./wholeUnits";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Paddle.js: loading it once, asking it what this visitor pays, and reading the answer safely.
|
|
18
|
+
*
|
|
19
|
+
* **This is the half of the Paddle rail a redirect cannot do.** The server mints transactions and takes
|
|
20
|
+
* webhooks; nothing on that side can tell a visitor in Berlin that the $5.00 they see includes $0.80 of
|
|
21
|
+
* VAT while a visitor in Chicago will be charged $5.75. Only a script running in that browser, talking to
|
|
22
|
+
* Paddle from that IP, knows — so the whole of localized pricing lives here or it does not exist.
|
|
23
|
+
*
|
|
24
|
+
* **Never format a price yourself here, and never write one down.** Paddle returns `formattedTotals`
|
|
25
|
+
* already rendered for the visitor: the right symbol, the right separators, and whole units for the
|
|
26
|
+
* zero-decimal currencies (¥725 is `725`, not `72500`). The raw amounts are exposed too, but they are for
|
|
27
|
+
* **comparing**, never for showing.
|
|
28
|
+
*
|
|
29
|
+
* **The reason this line used to give was false, and it was measured false (#465, 2026-08-28).** It said a
|
|
30
|
+
* kit reaching for `Intl.NumberFormat` "would have to carry a table of which currencies have two decimals,
|
|
31
|
+
* and would get one wrong". `Intl` carries that table, from CLDR:
|
|
32
|
+
* `new Intl.NumberFormat(locale, { style: "currency", currency }).resolvedOptions().maximumFractionDigits`
|
|
33
|
+
* answers 2 for `USD` and `GBP` and 0 for `JPY`, `KRW` and `CLP`, and renders `$65.82`, `¥6,582` and
|
|
34
|
+
* `₩6,582` from the same 6582. Paddle's own documentation instructs adopters to do exactly this — "use a
|
|
35
|
+
* currency library to format monetary values to the correct number of decimals… symbols and decimal
|
|
36
|
+
* separators are placed correctly" (api-reference/about/data-types).
|
|
37
|
+
*
|
|
38
|
+
* **What the kit still refuses is deriving an AMOUNT, and that is the rule this paragraph is load-bearing
|
|
39
|
+
* for.** The figure is Paddle's; a renderer places a point and attaches a symbol and decides nothing about
|
|
40
|
+
* how much money there is. Where Paddle already rendered the string — here, on the pricing surfaces, where
|
|
41
|
+
* `formattedTotals` exists — that string is what ships, because a second rendering of one price is a second
|
|
42
|
+
* spelling of it and the visitor's browser locale is not the one Paddle used. Where Paddle renders nothing
|
|
43
|
+
* — `subscriptions.preview` and `transactions.preview` return no formatted field at any depth, verified
|
|
44
|
+
* against the recordings — the alternative to rendering is a confirmation screen with no figure on it. That
|
|
45
|
+
* case is `data/renderMoney.ts`, on the server, from the negotiated locale, and it carries the measurement
|
|
46
|
+
* that the exponent must come from ISO 4217 rather than from CLDR's display digits.
|
|
47
|
+
*
|
|
48
|
+
* **Amended: removing a zero fraction from Paddle's string is not composing one (Jim, 2026-08-21).** The
|
|
49
|
+
* rule above was read as forbidding any touch of a formatted total, and two adopter surfaces each wrote
|
|
50
|
+
* their own `total.replace(/([.,])00(?=\D*$)/, "")` rather than break it — the same wrong regex, twice,
|
|
51
|
+
* in two repositories (`#427`). Nothing is assembled by dropping an all-zero fraction: the symbol, the
|
|
52
|
+
* separators, the grouping and the digits are still the ones Paddle chose, and one run of characters is
|
|
53
|
+
* gone. What the rule actually forbids is *deciding* what a price looks like from raw minor units, and
|
|
54
|
+
* that decision is still Paddle's. So {@link PriceSummaryOptions.wholeUnits} exists, is off unless a
|
|
55
|
+
* caller asks, and removes only a fraction the arithmetic in `./wholeUnits` has already proved is zero —
|
|
56
|
+
* which is the part neither adopter could do, because `totals` never leaves this package.
|
|
57
|
+
*
|
|
58
|
+
* **What "localized" actually means, measured rather than assumed.** With no `unit_price_overrides` on
|
|
59
|
+
* the price, every country is billed in the catalog's currency — a preview from a UK address on a USD
|
|
60
|
+
* price comes back in dollars. Currency is a catalog decision. What this delivers without one is tax
|
|
61
|
+
* and formatting, which is real: the US adds tax to the listed figure and settles it at the postal code,
|
|
62
|
+
* while the EU, the UK and Japan take it out of an inclusive one. {@link priceSummary} is where that
|
|
63
|
+
* difference stops being a footnote and becomes two different sentences.
|
|
64
|
+
*
|
|
65
|
+
* **No API key is reachable from this module or any other under `src/client/`.** The client token is
|
|
66
|
+
* publishable by design and is the only credential that belongs in a browser. `paddle.test.ts` sweeps the
|
|
67
|
+
* directory for the shapes of the ones that do not.
|
|
68
|
+
*
|
|
69
|
+
* Cookie/session and same-origin are irrelevant here: none of this talks to the adopter's Worker. It
|
|
70
|
+
* talks to Paddle, with Paddle's own publishable token, exactly as Paddle's documentation intends.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/** Paddle.js could not be loaded at all: an ad blocker, a Content Security Policy, or no network. */
|
|
74
|
+
export const PADDLE_UNAVAILABLE: PaymentsFailure = {
|
|
75
|
+
code: "client/paddle_unavailable",
|
|
76
|
+
message: "We couldn't load the payment provider.",
|
|
77
|
+
action: "Check your connection or any content blockers, then reload.",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Paddle.js loaded and then refused to initialize. A token from the wrong account is the usual cause.
|
|
82
|
+
*
|
|
83
|
+
* Distinct from {@link PADDLE_UNAVAILABLE} because the two are fixed by different people: one is the
|
|
84
|
+
* visitor's browser, the other is the project's configuration.
|
|
85
|
+
*/
|
|
86
|
+
export const PADDLE_NOT_INITIALIZED: PaymentsFailure = {
|
|
87
|
+
code: "client/paddle_not_initialized",
|
|
88
|
+
message: "The payment provider wouldn't start.",
|
|
89
|
+
action: "Reload. If it keeps happening, this project's payment settings are wrong.",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** A second, different Paddle account was asked for on a page that already has one. */
|
|
93
|
+
export const PADDLE_ACCOUNT_CONFLICT: PaymentsFailure = {
|
|
94
|
+
code: "client/paddle_account_conflict",
|
|
95
|
+
message: "The payment provider is already running for a different account.",
|
|
96
|
+
action: "One page serves one Paddle account. Reload before switching.",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Paddle refused the price request itself — an unknown price id, or a token scoped elsewhere. */
|
|
100
|
+
export const PADDLE_PREVIEW_REFUSED: PaymentsFailure = {
|
|
101
|
+
code: "client/paddle_preview_refused",
|
|
102
|
+
message: "We couldn't get a price for you.",
|
|
103
|
+
action: "Try again in a moment.",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** What Paddle.js needs to start: the publishable token, and which account it belongs to. */
|
|
107
|
+
export interface PaddleSetup {
|
|
108
|
+
/** Paddle's publishable client token — `test_…` in sandbox, `live_…` in production. */
|
|
109
|
+
clientToken: string;
|
|
110
|
+
/** Which Paddle account the token belongs to. Sandbox and live are separate accounts, not a flag. */
|
|
111
|
+
environment: PaymentsPaddleEnvironment;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One line of a price request. */
|
|
115
|
+
export interface PaddlePriceItem {
|
|
116
|
+
/** The Paddle price — `pri_…`. `paymentsConfig.products[].skus.paddle` carries it. */
|
|
117
|
+
priceId: string;
|
|
118
|
+
/** How many. Paddle refuses a quantity outside the price's own `quantity` bounds. */
|
|
119
|
+
quantity: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What to ask a price for, and on whose behalf.
|
|
124
|
+
*
|
|
125
|
+
* **Location is optional, and where it comes from is a real decision.** Omitted, Paddle resolves it from
|
|
126
|
+
* the browser's own IP, which is right for a marketing page nobody has signed in to. A Worker can do
|
|
127
|
+
* better for a signed-in visitor: `request.cf.country` is on every Cloudflare request, and a customer
|
|
128
|
+
* with a saved address has one on file at Paddle already — pass `customerId` and Paddle uses it.
|
|
129
|
+
*
|
|
130
|
+
* **Send a postal code where you have one.** United States tax resolves at the postal code, not the
|
|
131
|
+
* country: 15% in Chicago, 8.875% in New York, 0% in Oregon — and 0% for the country with no code at
|
|
132
|
+
* all. A country-only preview therefore quotes an American buyer $5.00 for something they will be
|
|
133
|
+
* charged $5.44 for. {@link priceSummary} says so rather than letting the page imply otherwise.
|
|
134
|
+
*/
|
|
135
|
+
export interface PaddlePriceQuery {
|
|
136
|
+
/** The prices to quote. */
|
|
137
|
+
items: readonly PaddlePriceItem[];
|
|
138
|
+
/** Where the visitor is. Omit to let Paddle resolve it from their IP. */
|
|
139
|
+
address?: { countryCode: string; postalCode?: string };
|
|
140
|
+
/** An existing Paddle customer, whose saved address Paddle will use. */
|
|
141
|
+
customerId?: string;
|
|
142
|
+
/** An IP to resolve location from, for a server that knows it better than the browser does. */
|
|
143
|
+
customerIpAddress?: string;
|
|
144
|
+
/** Force a currency. Only meaningful where the catalog has an override for it. */
|
|
145
|
+
currencyCode?: string;
|
|
146
|
+
/** A resolved Paddle discount — `dsc_…`. Never a raw code; resolving one needs the API key. */
|
|
147
|
+
discountId?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The slice of Paddle.js this kit touches, declared structurally.
|
|
152
|
+
*
|
|
153
|
+
* Structural rather than `import type { Paddle }` for the reason `PaymentsFetch` is structural: this file
|
|
154
|
+
* compiles inside an adopter's browser program, and a narrow shape is one they can satisfy with a stub
|
|
155
|
+
* where the real type drags Paddle's whole graph in. `paddle.test.ts` pins it against the real `Paddle`
|
|
156
|
+
* type at compile time, so it cannot drift when Paddle renames something.
|
|
157
|
+
*/
|
|
158
|
+
export interface PaddleJs {
|
|
159
|
+
/** Whether `Initialize` has run and succeeded. Paddle.js sets it; nothing here writes it. */
|
|
160
|
+
Initialized: boolean;
|
|
161
|
+
/** Which account this instance talks to. Must be set before `Initialize`, which the loader does. */
|
|
162
|
+
Environment: { set(environment: PaymentsPaddleEnvironment): void };
|
|
163
|
+
/** Prices for this visitor. The camelCase mirror of the server's `pricing-preview` endpoint. */
|
|
164
|
+
PricePreview(params: PaddlePriceQuery): Promise<unknown>;
|
|
165
|
+
/** The overlay and the inline frame. See {@link PaddleCheckoutOpen} for what this kit may hand it. */
|
|
166
|
+
Checkout: {
|
|
167
|
+
/** Open a checkout. Synchronous and returns nothing — a refusal arrives as a throw or not at all. */
|
|
168
|
+
open(options: PaddleCheckoutOpen): void;
|
|
169
|
+
/** Close whatever is open. Nothing happens when nothing is. */
|
|
170
|
+
close(): void;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Light or dark. Paddle's whole theme surface, and the only one there is.
|
|
176
|
+
*
|
|
177
|
+
* Not a palette. Colors, fonts, borders and focus states are configured in the Paddle dashboard and are
|
|
178
|
+
* not expressible from code at all — see {@link PaddleCheckoutSettings.theme} for where and why.
|
|
179
|
+
*/
|
|
180
|
+
export type PaddleCheckoutTheme = "light" | "dark";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether the card form is one page or several.
|
|
184
|
+
*
|
|
185
|
+
* Paddle's own type carries a third value, `express`, which its checkout-settings documentation does not
|
|
186
|
+
* list. Absent here rather than passed through: this kit hands Paddle only what Paddle documents a seller
|
|
187
|
+
* may set, and a value nobody can point at a page for is one nobody can support.
|
|
188
|
+
*/
|
|
189
|
+
export type PaddleCheckoutVariant = "one-page" | "multi-page";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* How one checkout is presented. Paddle takes the same settings at `Initialize` and here.
|
|
193
|
+
*
|
|
194
|
+
* **Here, deliberately.** The loader is one per page and these are per checkout: the display mode comes
|
|
195
|
+
* off a handoff the server minted, and the container class is the screen's, so a page with a paywall and
|
|
196
|
+
* a pricing panel must be able to open two checkouts differently without re-initializing Paddle — which
|
|
197
|
+
* it cannot do, because `Initialize` runs once. Paddle's own documentation says `frameTarget` goes in
|
|
198
|
+
* `Paddle.Initialize()`; its `Checkout.open` reference then passes exactly these fields per call, and the
|
|
199
|
+
* live sandbox honors them on both forms. Where the two disagree, the measurement wins.
|
|
200
|
+
*
|
|
201
|
+
* **Every field here is optional and stays absent when nobody named one.** Paddle reads these over
|
|
202
|
+
* account-level settings a seller configured in the dashboard, so a key present and `undefined` is not
|
|
203
|
+
* the same message as no key — see `settingsFor` in `./checkout`, which is where that is enforced.
|
|
204
|
+
*/
|
|
205
|
+
export interface PaddleCheckoutSettings {
|
|
206
|
+
/** Over the page, or inside the element {@link PaddleCheckoutSettings.frameTarget} names. */
|
|
207
|
+
displayMode?: PaymentsPaddleDisplayMode;
|
|
208
|
+
/**
|
|
209
|
+
* Light or dark. Paddle defaults to `light`, which is why an app in dark mode has to say so.
|
|
210
|
+
*
|
|
211
|
+
* **Never inferred, and that is a decision rather than an omission.** Nothing in this kit reads
|
|
212
|
+
* `prefers-color-scheme`, samples a computed style, or calls `matchMedia`: the OS preference is not the
|
|
213
|
+
* app's theme — an app with its own toggle, or one that is dark whatever the OS says, would get a card
|
|
214
|
+
* form contradicting the page it opened over. The adopter knows; guessing wrong is worse than
|
|
215
|
+
* defaulting, and a wrong guess is harder to find than a missing option.
|
|
216
|
+
*
|
|
217
|
+
* **This is the whole of the theming Paddle exposes to code.** Colors, fonts, borders, hover and focus
|
|
218
|
+
* states are set in the Paddle dashboard under *Checkout → Branded inline checkout* (and logo plus
|
|
219
|
+
* brand color for the overlay). That is Paddle's deliberate product decision, not a missing endpoint,
|
|
220
|
+
* so there is no option to add here for it and nothing to go looking for.
|
|
221
|
+
*/
|
|
222
|
+
theme?: PaddleCheckoutTheme;
|
|
223
|
+
/** The buyer's language — `"fr"`, `"pt-BR"`. Paddle defaults to the browser's. Pass it where the app has its own. */
|
|
224
|
+
locale?: string;
|
|
225
|
+
/** One page or several. Paddle defaults to `multi-page`. */
|
|
226
|
+
variant?: PaddleCheckoutVariant;
|
|
227
|
+
/** The **class name** — not an id, not a selector — of the element an inline checkout renders into. */
|
|
228
|
+
frameTarget?: string;
|
|
229
|
+
/** Styles for that element. Paddle needs `min-width` at 312px or the merchant-of-record footer is cut off. */
|
|
230
|
+
frameStyle?: string;
|
|
231
|
+
/** Its height in pixels on load, before the frame resizes itself. Paddle recommends 450. */
|
|
232
|
+
frameInitialHeight?: number;
|
|
233
|
+
/** Where a buyer who paid is sent. From the server, never from a screen — see {@link PaddleCheckoutOpen}. */
|
|
234
|
+
successUrl?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What this kit is willing to open a checkout for: a transaction, and how to show it.
|
|
239
|
+
*
|
|
240
|
+
* **`items` and `customData` are absent, and their absence is deliberate in two different ways.**
|
|
241
|
+
*
|
|
242
|
+
* `items[]` is what makes Paddle pleasant — no server call, a checkout in one click — and it is also a
|
|
243
|
+
* checkout whose price and whose buyer are chosen by the page. This capability takes both from the
|
|
244
|
+
* catalog entry a product id resolves to, everywhere else, so that a client cannot buy Pro for the price
|
|
245
|
+
* of a coin pack. A transaction the server minted is that same rule, kept here.
|
|
246
|
+
*
|
|
247
|
+
* `customData` is the harder one, because leaving it out does **not** make the stamp safe. Measured live:
|
|
248
|
+
* Paddle accepts `customData` beside a `transactionId` and overwrites the `custom_data` the server wrote
|
|
249
|
+
* on that transaction. So omitting it here is hygiene — this kit has nothing to say through that field
|
|
250
|
+
* that it has not already said on the server — and the thing that actually protects ownership is the MAC
|
|
251
|
+
* in `../rails/paddle/objects.ts`. A reader who takes this type as the security boundary would be reading
|
|
252
|
+
* it wrongly, which is why it says so.
|
|
253
|
+
*/
|
|
254
|
+
export interface PaddleCheckoutOpen {
|
|
255
|
+
/** The transaction the server created — `txn_…`. The only thing a checkout here is ever opened for. */
|
|
256
|
+
transactionId: string;
|
|
257
|
+
/** How to present it. */
|
|
258
|
+
settings?: PaddleCheckoutSettings;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* How Paddle.js is fetched and started.
|
|
263
|
+
*
|
|
264
|
+
* The default is `initializePaddle` from `@paddle/paddle-js`. It is a seam because a test must never
|
|
265
|
+
* reach Paddle's CDN, and because a project with its own script-loading policy may want to hand one in.
|
|
266
|
+
*/
|
|
267
|
+
export type PaddleInitializer = (options: {
|
|
268
|
+
token: string;
|
|
269
|
+
environment?: PaymentsPaddleEnvironment;
|
|
270
|
+
}) => Promise<PaddleJs | undefined>;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Where "one initialization per page" is remembered.
|
|
274
|
+
*
|
|
275
|
+
* A parameter with a module-level default rather than a hidden singleton with a reset button. The
|
|
276
|
+
* default is the page, which is what every screen wants; a test passes its own and gets a fresh page
|
|
277
|
+
* without an escape hatch existing in shipped code for anyone else to reach for.
|
|
278
|
+
*/
|
|
279
|
+
export interface PaddleRegistry {
|
|
280
|
+
/** The one load in flight or already done, and which account it was for. */
|
|
281
|
+
current?: { key: string; paddle: Promise<PaymentsResult<PaddleJs>> };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The page's registry. Module state on purpose: a page is exactly the scope this is about. */
|
|
285
|
+
const PAGE: PaddleRegistry = {};
|
|
286
|
+
|
|
287
|
+
/** What {@link loadPaddle} and {@link previewPrices} let a caller replace, and where a quote may rest. */
|
|
288
|
+
export interface PaddleOptions {
|
|
289
|
+
/** How to fetch and start Paddle.js. Defaults to `@paddle/paddle-js`. */
|
|
290
|
+
initialize?: PaddleInitializer;
|
|
291
|
+
/** Where the one-per-page load is remembered. Defaults to the page's own. */
|
|
292
|
+
registry?: PaddleRegistry;
|
|
293
|
+
/**
|
|
294
|
+
* Where a quote may be cached, under what name, and for how long. Omitted, nothing is cached.
|
|
295
|
+
*
|
|
296
|
+
* All three parts or none — see `readQuoteCache`. There is no default store and no default lifetime,
|
|
297
|
+
* because a quote resolved for a signed-in customer is that customer's price, and neither where it
|
|
298
|
+
* rests nor how long it stands is a thing this package may pick for the program that knows who is
|
|
299
|
+
* looking at it.
|
|
300
|
+
*/
|
|
301
|
+
cache?: PaddleQuoteCache;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Which account a setup names, as one comparable string. */
|
|
305
|
+
function setupKey(setup: PaddleSetup): string {
|
|
306
|
+
return `${setup.environment}:${setup.clientToken}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The real loader. Wrapped rather than passed directly so the seam's shape is this kit's, not Paddle's. */
|
|
310
|
+
const defaultInitializer: PaddleInitializer = (options) => initializePaddle(options);
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Load and initialize Paddle.js, once per page.
|
|
314
|
+
*
|
|
315
|
+
* Idempotent by construction rather than by documentation: the first call's promise is remembered and
|
|
316
|
+
* every later call for the same account is handed the same one, so two components mounting at once
|
|
317
|
+
* produce one script and one `Initialize`. A call for a *different* account is refused — Paddle.js holds
|
|
318
|
+
* one environment and one token per page, `Environment.set` after `Initialize` leaves it half-moved, and
|
|
319
|
+
* silently re-pointing a live account at a sandbox is not a failure anyone would notice until a real
|
|
320
|
+
* card was declined.
|
|
321
|
+
*
|
|
322
|
+
* **`Environment.set` runs for sandbox and for production alike**, before `Initialize`. Passing it
|
|
323
|
+
* explicitly both ways is what makes the environment a declared fact rather than a default nobody looked
|
|
324
|
+
* at — and the two are separate accounts with separate tokens, so a token used against the wrong one is
|
|
325
|
+
* refused outright.
|
|
326
|
+
*
|
|
327
|
+
* Nothing here throws. A blocked script, a server render with no `window`, and a token the account
|
|
328
|
+
* rejects are three different {@link PaymentsFailure}s, because they are three different problems.
|
|
329
|
+
*/
|
|
330
|
+
export function loadPaddle(setup: PaddleSetup, options?: PaddleOptions): Promise<PaymentsResult<PaddleJs>> {
|
|
331
|
+
const registry = options?.registry ?? PAGE;
|
|
332
|
+
const key = setupKey(setup);
|
|
333
|
+
const held = registry.current;
|
|
334
|
+
if (held) {
|
|
335
|
+
if (held.key === key) return held.paddle;
|
|
336
|
+
return Promise.resolve({ ok: false, failure: PADDLE_ACCOUNT_CONFLICT });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const initialize = options?.initialize ?? defaultInitializer;
|
|
340
|
+
// `Promise.try`'s shape by hand: an initializer that throws before it returns a promise — Paddle.js
|
|
341
|
+
// does exactly that when the document has neither a `<head>` nor a `<body>` — must arrive here as a
|
|
342
|
+
// refusal like every other. "Nothing here throws" is a claim about every path or it is not one.
|
|
343
|
+
const started = new Promise<PaddleJs | undefined>((resolve) => {
|
|
344
|
+
resolve(initialize({ token: setup.clientToken, environment: setup.environment }));
|
|
345
|
+
});
|
|
346
|
+
const paddle = started
|
|
347
|
+
.then(
|
|
348
|
+
(instance): PaymentsResult<PaddleJs> => {
|
|
349
|
+
// `undefined` is Paddle.js's own answer for "there is no window here" — a server render, or a
|
|
350
|
+
// test with no DOM. It is not a failure of the account or the network.
|
|
351
|
+
if (!instance) return { ok: false, failure: PAYMENTS_NO_BROWSER };
|
|
352
|
+
// `initializePaddle` swallows an initialization error into a `console.warn` and still resolves
|
|
353
|
+
// with the instance, so the only honest check is the flag Paddle.js sets itself. Without this a
|
|
354
|
+
// bad token reads as a working Paddle whose every call quietly does nothing.
|
|
355
|
+
if (!instance.Initialized) return { ok: false, failure: PADDLE_NOT_INITIALIZED };
|
|
356
|
+
return { ok: true, value: instance };
|
|
357
|
+
},
|
|
358
|
+
(): PaymentsResult<PaddleJs> => ({ ok: false, failure: PADDLE_UNAVAILABLE }),
|
|
359
|
+
)
|
|
360
|
+
.then((result): PaymentsResult<PaddleJs> => {
|
|
361
|
+
// **A failure is not remembered.** Idempotence is about not initializing twice, and a load that
|
|
362
|
+
// never produced a Paddle initialized nothing — a blocked script, a flaky network and a page that
|
|
363
|
+
// rendered before its `<head>` existed are all retryable. Caching the refusal would make one bad
|
|
364
|
+
// first second permanent for the life of the page, and a `refresh` button that cannot work.
|
|
365
|
+
if (!result.ok && registry.current?.key === key) registry.current = undefined;
|
|
366
|
+
return result;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
registry.current = { key, paddle };
|
|
370
|
+
return paddle;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Four amounts, as Paddle sends them. Formatted or raw depending on which field they came off. */
|
|
374
|
+
export interface PriceTotals {
|
|
375
|
+
/** The price before tax. */
|
|
376
|
+
subtotal: string;
|
|
377
|
+
/** What any discount took off. */
|
|
378
|
+
discount: string;
|
|
379
|
+
/** The tax. */
|
|
380
|
+
tax: string;
|
|
381
|
+
/** What the buyer pays. Always `subtotal - discount + tax`. */
|
|
382
|
+
total: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* How tax sits against the figure the catalog lists, resolved for this visitor.
|
|
387
|
+
*
|
|
388
|
+
* Not a country table, and not readable off `taxMode` either: a price set to `location` — Paddle's own
|
|
389
|
+
* default — resolves to `added` in Denver and `included` in Berlin, and the mode says only that Paddle
|
|
390
|
+
* will decide. So this is derived from the numbers Paddle returned, by asking which of them is the
|
|
391
|
+
* listed price.
|
|
392
|
+
*
|
|
393
|
+
* - `added` — the listed price is the subtotal, and tax goes on top. The US convention.
|
|
394
|
+
* - `included` — the listed price is the total, and tax comes out of it. The EU, UK and Japan.
|
|
395
|
+
* - `none` — no tax at this address. The question does not arise; both readings are the same number.
|
|
396
|
+
* - `unknown` — it cannot be told. A currency conversion, or a discount, moved both figures away from
|
|
397
|
+
* the listed one. The total is still exactly what the buyer pays, which is what a screen renders.
|
|
398
|
+
*/
|
|
399
|
+
export type PriceTaxTreatment = "added" | "included" | "none" | "unknown";
|
|
400
|
+
|
|
401
|
+
/** One quoted price. */
|
|
402
|
+
export interface PriceLine {
|
|
403
|
+
/** The Paddle price this quotes — `pri_…`. */
|
|
404
|
+
priceId: string;
|
|
405
|
+
/** The product's name, as the catalog has it. */
|
|
406
|
+
productName: string;
|
|
407
|
+
/** The price's own name — "Monthly" — or null. */
|
|
408
|
+
priceName: string | null;
|
|
409
|
+
/** How many units this line quotes. */
|
|
410
|
+
quantity: number;
|
|
411
|
+
/** The tax rate as a decimal string — `"0.08875"`. `"0"` where none applies. */
|
|
412
|
+
taxRate: string;
|
|
413
|
+
/** How often this bills, or null for a one-off. */
|
|
414
|
+
billingCycle: { interval: string; frequency: number } | null;
|
|
415
|
+
/** Per unit, in minor units. For comparing. Never render these. */
|
|
416
|
+
unitTotals: PriceTotals;
|
|
417
|
+
/** The whole line, in minor units. For comparing. Never render these. */
|
|
418
|
+
totals: PriceTotals;
|
|
419
|
+
/** Per unit, rendered by Paddle for this visitor. Render these. */
|
|
420
|
+
formattedUnitTotals: PriceTotals;
|
|
421
|
+
/** The whole line, rendered by Paddle for this visitor. Render these. */
|
|
422
|
+
formattedTotals: PriceTotals;
|
|
423
|
+
/** Where tax sits against the listed figure here. */
|
|
424
|
+
taxTreatment: PriceTaxTreatment;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** What Paddle quoted this visitor. */
|
|
428
|
+
export interface PricePreview {
|
|
429
|
+
/** The currency the quote is in. The catalog's, unless an override or `currencyCode` moved it. */
|
|
430
|
+
currencyCode: string;
|
|
431
|
+
/** The country Paddle resolved, or null. */
|
|
432
|
+
countryCode: string | null;
|
|
433
|
+
/**
|
|
434
|
+
* The postal code the quote was resolved at, or null.
|
|
435
|
+
*
|
|
436
|
+
* Null is load-bearing, not incidental. United States tax resolves below the country, so a quote with
|
|
437
|
+
* no postal code can be short by up to 15% — and Paddle returns it as `0%` rather than as an error.
|
|
438
|
+
*/
|
|
439
|
+
postalCode: string | null;
|
|
440
|
+
/** One line per item asked for, in the order asked. */
|
|
441
|
+
lines: readonly PriceLine[];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Whether a value is a plain record. */
|
|
445
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
446
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Whether every one of the four amounts is a string. */
|
|
450
|
+
function readTotals(value: unknown): PriceTotals | null {
|
|
451
|
+
if (!isRecord(value)) return null;
|
|
452
|
+
const { subtotal, discount, tax, total } = value;
|
|
453
|
+
if (typeof subtotal !== "string") return null;
|
|
454
|
+
if (typeof discount !== "string") return null;
|
|
455
|
+
if (typeof tax !== "string") return null;
|
|
456
|
+
if (typeof total !== "string") return null;
|
|
457
|
+
return { subtotal, discount, tax, total };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A non-empty string, or null. */
|
|
461
|
+
function optionalString(value: unknown): string | null {
|
|
462
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The price's listed amount for this quote's country, following any override that covers it. */
|
|
466
|
+
function listedAmount(
|
|
467
|
+
price: Record<string, unknown>,
|
|
468
|
+
countryCode: string | null,
|
|
469
|
+
): { amount: string; currency: string } | null {
|
|
470
|
+
const overrides = price.unitPriceOverrides;
|
|
471
|
+
if (countryCode !== null && Array.isArray(overrides)) {
|
|
472
|
+
for (const override of overrides) {
|
|
473
|
+
if (!isRecord(override) || !Array.isArray(override.countryCodes)) continue;
|
|
474
|
+
if (!override.countryCodes.includes(countryCode)) continue;
|
|
475
|
+
const unit = override.unitPrice;
|
|
476
|
+
if (!isRecord(unit) || typeof unit.amount !== "string" || typeof unit.currencyCode !== "string") return null;
|
|
477
|
+
return { amount: unit.amount, currency: unit.currencyCode };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const unit = price.unitPrice;
|
|
481
|
+
if (!isRecord(unit) || typeof unit.amount !== "string" || typeof unit.currencyCode !== "string") return null;
|
|
482
|
+
return { amount: unit.amount, currency: unit.currencyCode };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Which of the quoted figures is the price the catalog lists.
|
|
487
|
+
*
|
|
488
|
+
* Per **unit**, so quantity never enters the comparison, and against the override that covers this
|
|
489
|
+
* country where there is one. Every branch here was measured against the live sandbox; the fixtures
|
|
490
|
+
* beside this module are those measurements.
|
|
491
|
+
*/
|
|
492
|
+
function taxTreatment(
|
|
493
|
+
unitTotals: PriceTotals,
|
|
494
|
+
price: Record<string, unknown>,
|
|
495
|
+
currencyCode: string,
|
|
496
|
+
countryCode: string | null,
|
|
497
|
+
): PriceTaxTreatment {
|
|
498
|
+
if (unitTotals.tax === "0") return "none";
|
|
499
|
+
const listed = listedAmount(price, countryCode);
|
|
500
|
+
// A converted quote is in a currency the catalog never named an amount in, so there is nothing to
|
|
501
|
+
// compare against and no honest answer but "cannot tell".
|
|
502
|
+
if (listed === null || listed.currency !== currencyCode) return "unknown";
|
|
503
|
+
if (unitTotals.subtotal === listed.amount) return "added";
|
|
504
|
+
if (unitTotals.total === listed.amount) return "included";
|
|
505
|
+
// A discount moved both figures. The total is still what is owed.
|
|
506
|
+
return "unknown";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** One line item of Paddle's answer, or null when it is not one. */
|
|
510
|
+
function readLine(value: unknown, currencyCode: string, countryCode: string | null): PriceLine | null {
|
|
511
|
+
if (!isRecord(value)) return null;
|
|
512
|
+
const price = value.price;
|
|
513
|
+
const product = value.product;
|
|
514
|
+
if (!isRecord(price) || !isRecord(product)) return null;
|
|
515
|
+
if (typeof price.id !== "string" || typeof product.name !== "string") return null;
|
|
516
|
+
if (typeof value.quantity !== "number" || typeof value.taxRate !== "string") return null;
|
|
517
|
+
|
|
518
|
+
const unitTotals = readTotals(value.unitTotals);
|
|
519
|
+
const totals = readTotals(value.totals);
|
|
520
|
+
const formattedUnitTotals = readTotals(value.formattedUnitTotals);
|
|
521
|
+
const formattedTotals = readTotals(value.formattedTotals);
|
|
522
|
+
if (!unitTotals || !totals || !formattedUnitTotals || !formattedTotals) return null;
|
|
523
|
+
|
|
524
|
+
const cycle = price.billingCycle;
|
|
525
|
+
const billingCycle =
|
|
526
|
+
isRecord(cycle) && typeof cycle.interval === "string" && typeof cycle.frequency === "number"
|
|
527
|
+
? { interval: cycle.interval, frequency: cycle.frequency }
|
|
528
|
+
: null;
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
priceId: price.id,
|
|
532
|
+
productName: product.name,
|
|
533
|
+
priceName: optionalString(price.name),
|
|
534
|
+
quantity: value.quantity,
|
|
535
|
+
taxRate: value.taxRate,
|
|
536
|
+
billingCycle,
|
|
537
|
+
unitTotals,
|
|
538
|
+
totals,
|
|
539
|
+
formattedUnitTotals,
|
|
540
|
+
formattedTotals,
|
|
541
|
+
taxTreatment: taxTreatment(unitTotals, price, currencyCode, countryCode),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Narrow Paddle's answer, or refuse it.
|
|
547
|
+
*
|
|
548
|
+
* **This is a trust boundary and it is treated as one.** The response comes from a third party's script
|
|
549
|
+
* over a network this kit does not control, and a page that read `lineItems[0].formattedTotals.total`
|
|
550
|
+
* off whatever arrived would render `undefined` as a price. Every field a caller can reach is checked
|
|
551
|
+
* here; nothing partial gets through, because half a price is worse than none.
|
|
552
|
+
*
|
|
553
|
+
* Hand-written guards rather than Zod, for the reason `api.ts` gives: this compiles into a browser
|
|
554
|
+
* bundle and must not drag the server's schema graph in behind it.
|
|
555
|
+
*/
|
|
556
|
+
export function readPricePreview(answer: unknown): PricePreview | null {
|
|
557
|
+
if (!isRecord(answer)) return null;
|
|
558
|
+
// **`PricePreview()` resolves `{ data, meta }`.** Everything a price is made of is under `data`;
|
|
559
|
+
// `meta` carries a request id nothing here reads. Reading the top level found `undefined` on every
|
|
560
|
+
// real answer and refused all of them — `#416`, and it was invisible because `previewPrices` turns a
|
|
561
|
+
// refusal into the absence of a number, which a screen renders as a price slot that looks deliberate.
|
|
562
|
+
//
|
|
563
|
+
// A bare record is still accepted, because an adopter who unwrapped before calling this was working
|
|
564
|
+
// and must not be broken by the repair. That is a deliberate second shape rather than an accident:
|
|
565
|
+
// the envelope is what this package is handed, and the inner object is what somebody else may hand it.
|
|
566
|
+
const value = isRecord(answer.data) ? answer.data : answer;
|
|
567
|
+
if (typeof value.currencyCode !== "string") return null;
|
|
568
|
+
const details = value.details;
|
|
569
|
+
if (!isRecord(details) || !Array.isArray(details.lineItems)) return null;
|
|
570
|
+
|
|
571
|
+
const address = isRecord(value.address) ? value.address : null;
|
|
572
|
+
const countryCode = address ? optionalString(address.countryCode) : null;
|
|
573
|
+
// Paddle answers a country-only request with `postalCode: ""`, not with null. An empty string is an
|
|
574
|
+
// absent postal code, and the difference matters — see {@link PricePreview.postalCode}.
|
|
575
|
+
const postalCode = address ? optionalString(address.postalCode) : null;
|
|
576
|
+
|
|
577
|
+
const lines: PriceLine[] = [];
|
|
578
|
+
for (const item of details.lineItems) {
|
|
579
|
+
const line = readLine(item, value.currencyCode, countryCode);
|
|
580
|
+
if (line === null) return null;
|
|
581
|
+
lines.push(line);
|
|
582
|
+
}
|
|
583
|
+
return { currencyCode: value.currencyCode, countryCode, postalCode, lines };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Ask Paddle what this visitor pays.
|
|
588
|
+
*
|
|
589
|
+
* Loads Paddle.js if it is not loaded, which is why a screen needs no separate setup step. Every failure
|
|
590
|
+
* on the way — a blocked script, a refused token, an unreadable answer — arrives as a
|
|
591
|
+
* {@link PaymentsFailure} and never as a thrown error and never as a number.
|
|
592
|
+
*/
|
|
593
|
+
export async function previewPrices(
|
|
594
|
+
setup: PaddleSetup,
|
|
595
|
+
query: PaddlePriceQuery,
|
|
596
|
+
options?: PaddleOptions,
|
|
597
|
+
): Promise<PaymentsResult<PricePreview>> {
|
|
598
|
+
// The account and the question, together. The account belongs in the key because sandbox and live are
|
|
599
|
+
// separate accounts answering the same question differently, and a cached sandbox figure surviving a
|
|
600
|
+
// deploy into production is a wrong price nobody would think to look for. It is the *publishable*
|
|
601
|
+
// token, which the page already carries in the open.
|
|
602
|
+
const cache = options?.cache === undefined ? null : readQuoteCache(options.cache);
|
|
603
|
+
const asked = cache === null ? "" : `${setupKey(setup)}|${priceQueryKey(query)}`;
|
|
604
|
+
if (cache !== null) {
|
|
605
|
+
const held = cachedAnswer(cache, asked);
|
|
606
|
+
// Read by the same reader a fresh answer is, so `#416` still has exactly one place it can exist and
|
|
607
|
+
// a stored shape this bundle no longer understands is a miss rather than a price nobody validated.
|
|
608
|
+
const remembered = held === null ? null : readPricePreview(held);
|
|
609
|
+
// Ahead of the load, deliberately: the script fetch is the part a visitor waits for, so a cache that
|
|
610
|
+
// still loaded Paddle.js would save the round trip nobody sees and keep the one everybody does.
|
|
611
|
+
if (remembered !== null) return { ok: true, value: remembered };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const loaded = await loadPaddle(setup, options);
|
|
615
|
+
if (!loaded.ok) return loaded;
|
|
616
|
+
// Settled as a pair rather than caught into a sentinel: the answer is `unknown`, so any sentinel value
|
|
617
|
+
// is one an answer could in principle equal.
|
|
618
|
+
//
|
|
619
|
+
// `Promise.try`'s shape by hand, the second time in this file and for the same reason {@link loadPaddle}
|
|
620
|
+
// gives: Paddle.js validates a query synchronously, so one it refuses outright **throws** instead of
|
|
621
|
+
// returning a rejected promise. Calling it bare let that throw past the handler pair and out of this
|
|
622
|
+
// function, which is an unhandled rejection on the adopter's page in place of the `PaymentsFailure`
|
|
623
|
+
// every path here promises. "Nothing here throws" is a claim about every path or it is not one.
|
|
624
|
+
const answer = await new Promise<unknown>((resolve) => resolve(loaded.value.PricePreview(query))).then(
|
|
625
|
+
(value: unknown) => ({ answered: true as const, value }),
|
|
626
|
+
() => ({ answered: false as const, value: undefined }),
|
|
627
|
+
);
|
|
628
|
+
if (!answer.answered) return { ok: false, failure: PADDLE_PREVIEW_REFUSED };
|
|
629
|
+
const preview = readPricePreview(answer.value);
|
|
630
|
+
if (preview === null) return { ok: false, failure: PAYMENTS_UNREADABLE };
|
|
631
|
+
// Only an answer, and only one that read. A refusal is a thing to ask again, not a thing to serve twice.
|
|
632
|
+
if (cache !== null) rememberAnswer(cache, asked, answer.value);
|
|
633
|
+
return { ok: true, value: preview };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** What a pricing screen puts on the page for one line. */
|
|
637
|
+
export interface PriceSummary {
|
|
638
|
+
/** The figure that goes in large type, already rendered by Paddle. */
|
|
639
|
+
headline: string;
|
|
640
|
+
/** One sentence about tax, or null when there is nothing true to say. */
|
|
641
|
+
note: string | null;
|
|
642
|
+
/**
|
|
643
|
+
* Whether the tax in this quote may be short of what the buyer is charged.
|
|
644
|
+
*
|
|
645
|
+
* True when no postal code was resolved. United States tax lives below the country, so a country-only
|
|
646
|
+
* quote comes back at 0% and the card is charged more. A screen that hides this is the defect this
|
|
647
|
+
* whole module exists to avoid.
|
|
648
|
+
*/
|
|
649
|
+
estimated: boolean;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** What a caller may ask {@link priceSummary} to do differently. */
|
|
653
|
+
export interface PriceSummaryOptions {
|
|
654
|
+
/**
|
|
655
|
+
* Drop the headline's fraction where it is entirely zero — `$6.00` becomes `$6`, `<U+200F>٦٫٠٠٠ د.ك.<U+200F>` becomes
|
|
656
|
+
* `<U+200F>٦ د.ك.<U+200F>`.
|
|
657
|
+
*
|
|
658
|
+
* **Off unless a caller says so, and that is the decision rather than the default.** A seller pricing
|
|
659
|
+
* at `$6.99` is unaffected either way, because only an all-zero fraction ever goes — but which figures
|
|
660
|
+
* a page advertises is a pricing decision, and the kit does not get to make it. An option is a caller
|
|
661
|
+
* saying *I do*.
|
|
662
|
+
*
|
|
663
|
+
* **Only the headline.** A tax sentence names an amount nobody chose: it is a rate applied to a base,
|
|
664
|
+
* so one plan's tax landing on a round number while the next plan's does not would put `$0.44` and
|
|
665
|
+
* `$1` in the same column. `./wholeUnits` is where the removal is argued and tested.
|
|
666
|
+
*/
|
|
667
|
+
readonly wholeUnits?: boolean;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* The one number to show, and the one sentence that makes it true.
|
|
672
|
+
*
|
|
673
|
+
* **The headline is not the same field in every country, and that is the point.** Where tax is added on
|
|
674
|
+
* top the listed price is the subtotal, and quoting the total would advertise a New Yorker's local sales
|
|
675
|
+
* tax as part of the price. Where tax is taken out of an inclusive figure the listed price *is* the
|
|
676
|
+
* total, and quoting the subtotal would advertise €4.20 for something that costs €5.00. One hardcoded
|
|
677
|
+
* string cannot mean "before tax" in Denver and "including VAT" in Berlin, so this returns two.
|
|
678
|
+
*
|
|
679
|
+
* It lives in the package rather than in the scaffolded screen for the reason the hooks do: an adopter's
|
|
680
|
+
* `pricing.tsx` is written once and never rewritten, and tax conventions are not a thing to freeze into
|
|
681
|
+
* somebody else's repository.
|
|
682
|
+
*
|
|
683
|
+
* Per unit, always. A pricing page quotes "$5.00 a month", not "$15.00 for the three seats you have not
|
|
684
|
+
* chosen yet"; a cart that wants the line total reads `formattedTotals` itself.
|
|
685
|
+
*/
|
|
686
|
+
export function priceSummary(preview: PricePreview, line: PriceLine, options?: PriceSummaryOptions): PriceSummary {
|
|
687
|
+
const estimated = preview.postalCode === null;
|
|
688
|
+
const totals = line.formattedUnitTotals;
|
|
689
|
+
// The rendered figure and the minor-unit amount behind it, taken from the same field name so the two
|
|
690
|
+
// cannot drift apart. Which field a convention chose is decided once, below, for both at once.
|
|
691
|
+
const headline = (field: "subtotal" | "total"): string =>
|
|
692
|
+
options?.wholeUnits === true
|
|
693
|
+
? withoutZeroFraction({
|
|
694
|
+
formatted: totals[field],
|
|
695
|
+
minorAmount: line.unitTotals[field],
|
|
696
|
+
currency: preview.currencyCode,
|
|
697
|
+
})
|
|
698
|
+
: totals[field];
|
|
699
|
+
|
|
700
|
+
if (line.taxTreatment === "added") {
|
|
701
|
+
return { headline: headline("subtotal"), note: `Plus ${totals.tax} tax.`, estimated };
|
|
702
|
+
}
|
|
703
|
+
if (line.taxTreatment === "included") {
|
|
704
|
+
return { headline: headline("total"), note: `Includes ${totals.tax} tax.`, estimated };
|
|
705
|
+
}
|
|
706
|
+
if (line.taxTreatment === "none") {
|
|
707
|
+
// No tax was resolved. Whether that is the truth or the missing postal code depends on `estimated`,
|
|
708
|
+
// and saying "no tax" when we did not ask precisely enough would be the lie.
|
|
709
|
+
return { headline: headline("total"), note: estimated ? "Tax is settled at checkout." : null, estimated };
|
|
710
|
+
}
|
|
711
|
+
return { headline: headline("total"), note: `Includes ${totals.tax} tax.`, estimated };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* A stable key for a query, so an effect can depend on what was asked rather than on the object.
|
|
716
|
+
*
|
|
717
|
+
* A screen writes `usePricePreview(setup, { items: [{ priceId, quantity: 1 }] })` inline, which is a new
|
|
718
|
+
* object every render. An effect depending on it would fetch forever. Depending on this instead means
|
|
719
|
+
* the request repeats when — and only when — something about the request changed.
|
|
720
|
+
*
|
|
721
|
+
* Field order is fixed here rather than taken from the object, because `{ a, b }` and `{ b, a }` are the
|
|
722
|
+
* same query and `JSON.stringify` disagrees.
|
|
723
|
+
*/
|
|
724
|
+
export function priceQueryKey(query: PaddlePriceQuery): string {
|
|
725
|
+
return JSON.stringify([
|
|
726
|
+
query.items.map((item) => [item.priceId, item.quantity]),
|
|
727
|
+
query.address ? [query.address.countryCode, query.address.postalCode ?? null] : null,
|
|
728
|
+
query.customerId ?? null,
|
|
729
|
+
query.customerIpAddress ?? null,
|
|
730
|
+
query.currencyCode ?? null,
|
|
731
|
+
query.discountId ?? null,
|
|
732
|
+
]);
|
|
733
|
+
}
|