@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,332 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PaymentsFailure, PaymentsResult } from "./api";
|
|
5
|
+
import type { PaddleSetup } from "./paddle";
|
|
6
|
+
import { type PaddleCacheStore, type PaddleQuoteCache, readQuoteCache } from "./paddleCache";
|
|
7
|
+
import {
|
|
8
|
+
type PaddlePlanPrices,
|
|
9
|
+
type PaddlePlanQuote,
|
|
10
|
+
type PaddleQuoteOptions,
|
|
11
|
+
type PaddleQuoteQuery,
|
|
12
|
+
quotePlans,
|
|
13
|
+
} from "./paddlePrices";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The script tag as configuration, for a site with no build step.
|
|
17
|
+
*
|
|
18
|
+
* A static site loads one classic script and gets prices. It cannot import a module, it has no bundler,
|
|
19
|
+
* and it must not carry a price id in its markup — an id belongs to one Paddle account, so it is
|
|
20
|
+
* environment-specific and a page that names one is a page that quotes the wrong account the day the
|
|
21
|
+
* account changes. So the ids arrive on the tag, where whoever deploys the site puts them, and the page
|
|
22
|
+
* itself names only plans.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The tag names no account, or is still carrying the placeholders it was scaffolded with. */
|
|
26
|
+
export const PADDLE_PRICES_NOT_CONFIGURED: PaymentsFailure = {
|
|
27
|
+
code: "client/paddle_prices_not_configured",
|
|
28
|
+
message: "Prices aren't available right now.",
|
|
29
|
+
action:
|
|
30
|
+
"Set data-paddle-env, data-paddle-token and every data-paddle-price-<name> id on the paddle-prices script tag.",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The slice of a script element this reads, declared structurally for the reason `./checkout` gives. */
|
|
34
|
+
export interface PaddlePricesTag {
|
|
35
|
+
/** Every attribute on the tag, so the `data-paddle-price-*` set can be enumerated rather than guessed. */
|
|
36
|
+
getAttributeNames(): readonly string[];
|
|
37
|
+
/** One attribute's value, or null. */
|
|
38
|
+
getAttribute(name: string): string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a configured tag says. */
|
|
42
|
+
export interface PaddlePricesTagConfig {
|
|
43
|
+
/** Which Paddle account answers, and with which publishable token. */
|
|
44
|
+
readonly setup: PaddleSetup;
|
|
45
|
+
/** Plan name to price id, one entry per `data-paddle-price-*` attribute. */
|
|
46
|
+
readonly plans: PaddlePlanPrices;
|
|
47
|
+
/** Whether to write the totals into the page, or only to hand them to whoever asked. */
|
|
48
|
+
readonly paint: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Whether the page asked for a zero fraction to be dropped from each figure — `$6.00` as `$6`.
|
|
51
|
+
*
|
|
52
|
+
* False unless the tag said `on`, and false for any other value. A seller who prices in whole numbers
|
|
53
|
+
* is the one who knows it, and a mistyped attribute leaves every figure exactly as Paddle rendered it,
|
|
54
|
+
* which is the safe half of the two. See `./wholeUnits`.
|
|
55
|
+
*/
|
|
56
|
+
readonly wholeUnits: boolean;
|
|
57
|
+
/** Who to quote for. Empty unless the tag named a customer, which is a visitor Paddle knows. */
|
|
58
|
+
readonly query: PaddleQuoteQuery;
|
|
59
|
+
/** Where a quote may rest, or null — which is every tag that did not ask for a cache, and every tag
|
|
60
|
+
* that asked for half of one. */
|
|
61
|
+
readonly cache: PaddleQuoteCache | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The two stores a script tag can name, injectable so a suite can look inside one.
|
|
66
|
+
*
|
|
67
|
+
* A tag cannot hand over an object, so it names a store rather than passing one — `local` or `session`,
|
|
68
|
+
* the only two a browser has. Everywhere else in this kit the caller passes the store itself, and that
|
|
69
|
+
* stays true: this is the one surface where the caller is HTML.
|
|
70
|
+
*/
|
|
71
|
+
export interface PricesCacheStores {
|
|
72
|
+
/** What `data-paddle-cache-store="local"` resolves to. Defaults to `globalThis.localStorage`. */
|
|
73
|
+
readonly local?: PaddleCacheStore | null;
|
|
74
|
+
/** What `data-paddle-cache-store="session"` resolves to. Defaults to `globalThis.sessionStorage`. */
|
|
75
|
+
readonly session?: PaddleCacheStore | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The prefix that marks an attribute as naming a plan. */
|
|
79
|
+
const PRICE_PREFIX = "data-paddle-price-";
|
|
80
|
+
|
|
81
|
+
/** The token prefix each Paddle account issues. Sandbox and live are separate accounts, not a flag. */
|
|
82
|
+
const CLIENT_TOKEN: Readonly<Record<PaddleSetup["environment"], string>> = {
|
|
83
|
+
sandbox: "test_",
|
|
84
|
+
production: "live_",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** A real Paddle price. */
|
|
88
|
+
const PRICE_ID = /^pri_/;
|
|
89
|
+
|
|
90
|
+
/** A real Paddle customer. */
|
|
91
|
+
const CUSTOMER_ID = /^ctm_/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One of the browser's own stores, or null where there is not one.
|
|
95
|
+
*
|
|
96
|
+
* Guarded rather than read, because reaching for `localStorage` **throws** where a browser has storage
|
|
97
|
+
* switched off or a sandboxed frame denies it — a page that quotes prices would take that exception on
|
|
98
|
+
* the way to asking Paddle a question it could have asked anyway.
|
|
99
|
+
*/
|
|
100
|
+
function browserStore(name: "localStorage" | "sessionStorage"): PaddleCacheStore | null {
|
|
101
|
+
try {
|
|
102
|
+
const held = (globalThis as { localStorage?: PaddleCacheStore; sessionStorage?: PaddleCacheStore })[name];
|
|
103
|
+
return held ?? null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The store a tag named, the **name** it gave when nothing resolved, or null when it named nothing.
|
|
111
|
+
*
|
|
112
|
+
* The three are different and used to be two. `data-paddle-cache-store="localStorage"` is the likely
|
|
113
|
+
* typo — the accepted value is `local` — and collapsing it into the same null a bare tag produces made
|
|
114
|
+
* it the one misconfiguration that got no console line, because "did anybody ask for a cache?" had
|
|
115
|
+
* nothing left to see. Handing the name back is what makes an unresolvable store an answer.
|
|
116
|
+
*
|
|
117
|
+
* An injected `null` is honored as an answer too: `PricesCacheStores` says `PaddleCacheStore | null`,
|
|
118
|
+
* so null means *this environment has none* — a suite saying so, or an adopter's SSR-safe wrapper — and
|
|
119
|
+
* quietly resolving the browser's real store instead would exercise the opposite path from the one the
|
|
120
|
+
* caller named.
|
|
121
|
+
*/
|
|
122
|
+
function storeNamed(named: string | null, stores?: PricesCacheStores): PaddleCacheStore | string | null {
|
|
123
|
+
if (named === "local") return stores?.local !== undefined ? stores.local : (browserStore("localStorage") ?? named);
|
|
124
|
+
if (named === "session") {
|
|
125
|
+
return stores?.session !== undefined ? stores.session : (browserStore("sessionStorage") ?? named);
|
|
126
|
+
}
|
|
127
|
+
return named;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A lifetime in seconds, as milliseconds.
|
|
132
|
+
*
|
|
133
|
+
* Seconds on the attribute because a tag is HTML and HTML counts a cache in seconds — `max-age`, and
|
|
134
|
+
* every header that copied it. `NaN` rather than null for text that is not a number, so a tag that
|
|
135
|
+
* tried to state a lifetime and failed is a warning rather than a silence.
|
|
136
|
+
*/
|
|
137
|
+
function lifetime(value: string | null): number | null {
|
|
138
|
+
if (value === null) return null;
|
|
139
|
+
const seconds = Number(value);
|
|
140
|
+
return Number.isFinite(seconds) ? seconds * 1000 : Number.NaN;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read a tag's configuration, or refuse it.
|
|
145
|
+
*
|
|
146
|
+
* **Refusal is the whole point of this function, and it is why the shapes are checked rather than
|
|
147
|
+
* trusted.** A site is deployed with the placeholders still in it far more often than anyone plans to:
|
|
148
|
+
* the live account is opened weeks after the sandbox one, and the tag ships carrying
|
|
149
|
+
* `REPLACE_WITH_LIVE_CLIENT_TOKEN` in the meantime. Handing that to Paddle produces a refused
|
|
150
|
+
* initialization, a console warning nobody is watching, and — because a page that has already blanked
|
|
151
|
+
* its own sentence has nothing left to fall back to — an empty price slot that looks deliberate. That is
|
|
152
|
+
* `#416` arriving by a second road.
|
|
153
|
+
*
|
|
154
|
+
* So an unconfigured tag is not an error and not a quote. It is null, and a null tag paints nothing, so
|
|
155
|
+
* the sentence the page shipped with is still standing. **All or nothing across the plans**: one plan
|
|
156
|
+
* still on a placeholder refuses the tag rather than quoting the others, because a pricing table with
|
|
157
|
+
* one real figure and one placeholder is the version a reader believes.
|
|
158
|
+
*
|
|
159
|
+
* **The token is checked against the environment beside it, not merely for a prefix.** The half-updated
|
|
160
|
+
* deploy above has a second shape: the environment is switched to `production` and the token below it is
|
|
161
|
+
* still the sandbox one. Paddle refuses that pairing itself, eventually — after the page has fetched
|
|
162
|
+
* Paddle.js and spent a round trip finding out. Refusing it here is what makes "no request" true of
|
|
163
|
+
* every misconfigured tag rather than of most of them.
|
|
164
|
+
*/
|
|
165
|
+
export function readPaddlePricesTag(
|
|
166
|
+
tag: PaddlePricesTag | null,
|
|
167
|
+
stores?: PricesCacheStores,
|
|
168
|
+
): PaddlePricesTagConfig | null {
|
|
169
|
+
if (tag === null) return null;
|
|
170
|
+
const environment = tag.getAttribute("data-paddle-env");
|
|
171
|
+
if (environment !== "sandbox" && environment !== "production") return null;
|
|
172
|
+
const clientToken = tag.getAttribute("data-paddle-token");
|
|
173
|
+
if (clientToken === null || !clientToken.startsWith(CLIENT_TOKEN[environment])) return null;
|
|
174
|
+
|
|
175
|
+
const plans: Record<string, string> = {};
|
|
176
|
+
for (const name of tag.getAttributeNames()) {
|
|
177
|
+
if (!name.startsWith(PRICE_PREFIX)) continue;
|
|
178
|
+
const priceId = tag.getAttribute(name);
|
|
179
|
+
if (priceId === null || !PRICE_ID.test(priceId)) return null;
|
|
180
|
+
plans[name.slice(PRICE_PREFIX.length)] = priceId;
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(plans).length === 0) return null;
|
|
183
|
+
|
|
184
|
+
// **A customer that is not one is dropped, not refused** — the opposite call to the one a placeholder
|
|
185
|
+
// price id gets one line above, and deliberately. A wrong price is unrecoverable, so a placeholder id
|
|
186
|
+
// takes the whole tag down. A missing customer costs the visitor a quote resolved from their IP and
|
|
187
|
+
// marked `estimated`, which is exactly what every anonymous visitor already sees, and is a great deal
|
|
188
|
+
// better than a pricing table with no figures in it.
|
|
189
|
+
const named = tag.getAttribute("data-paddle-customer")?.trim() ?? "";
|
|
190
|
+
const query: PaddleQuoteQuery = CUSTOMER_ID.test(named) ? { customerId: named } : {};
|
|
191
|
+
|
|
192
|
+
const cache = readQuoteCache({
|
|
193
|
+
key: tag.getAttribute("data-paddle-cache"),
|
|
194
|
+
store: storeNamed(tag.getAttribute("data-paddle-cache-store"), stores),
|
|
195
|
+
ttlMs: lifetime(tag.getAttribute("data-paddle-cache-ttl")),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
setup: { clientToken, environment },
|
|
200
|
+
plans,
|
|
201
|
+
paint: tag.getAttribute("data-paddle-paint") !== "off",
|
|
202
|
+
// The opposite polarity to `paint`, and deliberately: painting is what the artifact is for, so it is
|
|
203
|
+
// on until a page opts out, while a pricing decision is off until a page opts in.
|
|
204
|
+
wholeUnits: tag.getAttribute("data-paddle-whole-units") === "on",
|
|
205
|
+
query,
|
|
206
|
+
cache,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** What {@link mountPrices} lets a caller replace, plus the stores a tag may name. */
|
|
211
|
+
export interface MountPricesOptions extends PaddleQuoteOptions {
|
|
212
|
+
/** What `data-paddle-cache-store` resolves to. Defaults to the browser's own two. */
|
|
213
|
+
readonly stores?: PricesCacheStores;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The slice of a plan slot this writes. */
|
|
217
|
+
export interface PricesPlanNode {
|
|
218
|
+
/** Which plan the slot is for. */
|
|
219
|
+
getAttribute(name: string): string | null;
|
|
220
|
+
/** The sentence in the slot — the page's own until a quote replaces it. */
|
|
221
|
+
textContent: string | null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The slice of `document` this reaches, as an injectable seam. */
|
|
225
|
+
export interface PricesDocument {
|
|
226
|
+
/** Whether the page is still being parsed, so the slots to paint may not all exist yet. */
|
|
227
|
+
readyState: string;
|
|
228
|
+
/** How the paint waits for the rest of the page. */
|
|
229
|
+
addEventListener(type: string, listener: () => void, options?: { once?: boolean }): void;
|
|
230
|
+
/** Every plan slot on the page. */
|
|
231
|
+
querySelectorAll(selectors: string): ArrayLike<PricesPlanNode>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The attribute a page marks a plan slot with. */
|
|
235
|
+
const PLAN_SLOT = "data-price-plan";
|
|
236
|
+
|
|
237
|
+
/** The attribute a page marks the sentence beside a figure with. */
|
|
238
|
+
const NOTE_SLOT = "data-price-note";
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Match a plan named on the tag to a plan named in the markup.
|
|
242
|
+
*
|
|
243
|
+
* Case-insensitively, and that is forced rather than lenient. A plan arrives on the tag as part of an
|
|
244
|
+
* *attribute name* — `data-paddle-price-teamPlus` — and HTML lower-cases those, so the tag can only ever say
|
|
245
|
+
* `teamplus`. In the markup the same plan is an attribute *value*, `data-price-plan="teamPlus"`, which
|
|
246
|
+
* keeps its case. Matching exactly would leave that slot on its placeholder with nothing anywhere saying
|
|
247
|
+
* why.
|
|
248
|
+
*/
|
|
249
|
+
function samePlan(named: string | null, quoted: string): boolean {
|
|
250
|
+
return named !== null && named.toLowerCase() === quoted.toLowerCase();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Write each quote into the slot that named its plan, and the sentence that makes it true beside it.
|
|
255
|
+
*
|
|
256
|
+
* **The headline alone is not the price, and a page that shows only the headline is the defect the quote
|
|
257
|
+
* path exists to avoid.** Where tax is added on top — the United States convention — the headline is the
|
|
258
|
+
* subtotal and the buyer is charged more; where no postal code resolved, Paddle returns 0% tax and the
|
|
259
|
+
* card is charged up to 15% above the figure. `priceSummary` returns a sentence for each of those cases,
|
|
260
|
+
* and this puts it in `[data-price-note="<plan>"]`. A page quoting a tax-added price wants that slot.
|
|
261
|
+
*
|
|
262
|
+
* **A slot no quote names is left exactly as it was**, and so is a note slot for a quote with nothing to
|
|
263
|
+
* say. A pricing page ships a true sentence in every slot — "Priced where you are billed" — precisely so
|
|
264
|
+
* that a plan which cannot be quoted still says something honest. Blanking it, or writing `undefined`
|
|
265
|
+
* into it, replaces a true sentence with a worse one; leaving it is the whole reason it is there.
|
|
266
|
+
*/
|
|
267
|
+
export function paintPlanQuotes(document: PricesDocument, quotes: readonly PaddlePlanQuote[]): void {
|
|
268
|
+
// `Array.from` because the seam is an `ArrayLike` — a `NodeList` in a browser, and something else in a
|
|
269
|
+
// test — and indexing one is where an off-by-one would live.
|
|
270
|
+
for (const slot of Array.from(document.querySelectorAll(`[${PLAN_SLOT}]`))) {
|
|
271
|
+
const quote = quotes.find((candidate) => samePlan(slot.getAttribute(PLAN_SLOT), candidate.plan));
|
|
272
|
+
if (quote === undefined) continue;
|
|
273
|
+
slot.textContent = quote.headline;
|
|
274
|
+
}
|
|
275
|
+
for (const slot of Array.from(document.querySelectorAll(`[${NOTE_SLOT}]`))) {
|
|
276
|
+
const quote = quotes.find((candidate) => samePlan(slot.getAttribute(NOTE_SLOT), candidate.plan));
|
|
277
|
+
if (quote === undefined || quote.note === null) continue;
|
|
278
|
+
slot.textContent = quote.note;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Resolve once the page has the slots this paints into. */
|
|
283
|
+
function parsed(document: PricesDocument): Promise<void> {
|
|
284
|
+
if (document.readyState !== "loading") return Promise.resolve();
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
document.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Read the tag, quote its plans, and — unless the page said it paints itself — put the figures on it.
|
|
292
|
+
*
|
|
293
|
+
* The one entry point the browser build calls, and the one place the three halves meet: configuration
|
|
294
|
+
* off the tag, the quote from `./prices`, and the page. It answers with the result rather than swallowing
|
|
295
|
+
* it, so a page that keeps its own cache and its own formatting can have the same answer this painted.
|
|
296
|
+
*/
|
|
297
|
+
export async function mountPrices(
|
|
298
|
+
document: PricesDocument,
|
|
299
|
+
tag: PaddlePricesTag | null,
|
|
300
|
+
options?: MountPricesOptions,
|
|
301
|
+
): Promise<PaymentsResult<readonly PaddlePlanQuote[]>> {
|
|
302
|
+
const { stores, ...given } = options ?? {};
|
|
303
|
+
const config = readPaddlePricesTag(tag, stores);
|
|
304
|
+
if (config === null) return { ok: false, failure: PADDLE_PRICES_NOT_CONFIGURED };
|
|
305
|
+
|
|
306
|
+
// The tag is the page's configuration; an explicit option is a caller who knows better than the
|
|
307
|
+
// markup — a screen mounting this itself, or a test. Neither is common and both are legitimate, so
|
|
308
|
+
// the more specific one wins and the tag fills in the rest.
|
|
309
|
+
//
|
|
310
|
+
// **The query merges, field by field; the cache does not.** A query is a bag of independent facts, and
|
|
311
|
+
// a dashboard that server-renders `data-paddle-customer` onto the tag and passes an address from the
|
|
312
|
+
// screen means both — replacing wholesale would drop the customer and quote from the network again,
|
|
313
|
+
// which is the defect this module exists to remove. A cache is one indivisible decision about where a
|
|
314
|
+
// price rests and for how long, so half of the tag's and half of the caller's is not a cache anybody
|
|
315
|
+
// chose.
|
|
316
|
+
const quoted = await quotePlans(config.setup, config.plans, {
|
|
317
|
+
...given,
|
|
318
|
+
query: { ...config.query, ...given.query },
|
|
319
|
+
cache: given.cache ?? config.cache ?? undefined,
|
|
320
|
+
// One boolean, so the more specific answer wins outright — a caller passing `false` over a tag that
|
|
321
|
+
// said `on` is saying so, and `??` is what keeps that from reading as "nobody asked".
|
|
322
|
+
wholeUnits: given.wholeUnits ?? config.wholeUnits,
|
|
323
|
+
});
|
|
324
|
+
if (!quoted.ok || !config.paint) return quoted;
|
|
325
|
+
// **The quote starts immediately and the paint waits.** A third-party tag's usual home is `<head>`,
|
|
326
|
+
// where nothing it paints into has been parsed yet — so a paint that ran the moment Paddle answered
|
|
327
|
+
// would find some of the page's slots, or none, and leave the rest on their placeholders for good.
|
|
328
|
+
// Starting the round trip early is the reason to load early; only the writing has to wait.
|
|
329
|
+
await parsed(document);
|
|
330
|
+
paintPlanQuotes(document, quoted.value);
|
|
331
|
+
return quoted;
|
|
332
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One product, browser-safe — an element of {@link PaymentsClientProjection}'s catalog.
|
|
6
|
+
*
|
|
7
|
+
* Declared as its own type because the element type is the half a value-derived shape loses first: a
|
|
8
|
+
* catalog of one product infers one product's shape, and every branch the next product would have taken
|
|
9
|
+
* — an empty `entitlements`, a SKU on a rail the first product does not sell — is simply not in it.
|
|
10
|
+
*
|
|
11
|
+
* **It is annotated on the `.map` callback, not on the array it builds.** Measured, and the difference
|
|
12
|
+
* matters: `const products: PaymentsClientProduct[] = …map(…)` type-checks the *result*, by which point
|
|
13
|
+
* the element literals are no longer fresh, so a product that grew an `apple` SKU passed silently. On the
|
|
14
|
+
* callback's own return type the literal is checked where it is written, and the store-only field is a
|
|
15
|
+
* compile error at the line that added it.
|
|
16
|
+
*/
|
|
17
|
+
export type PaymentsClientProduct = {
|
|
18
|
+
/** The logical product id — what `/payments/checkout` is asked for. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** What kind of product it is. */
|
|
21
|
+
type: "consumable" | "non_consumable" | "subscription";
|
|
22
|
+
/** The entitlement keys it grants. Gating code names these, never the product. */
|
|
23
|
+
entitlements: string[];
|
|
24
|
+
/** The display name a paywall renders. */
|
|
25
|
+
name: string;
|
|
26
|
+
/**
|
|
27
|
+
* This product's SKU on each web rail, or null where it is not sold. Keyed by rail rather than one
|
|
28
|
+
* field per rail, so a screen asks `skus[rail]` and a new rail cannot leave a `purchasable()` check
|
|
29
|
+
* silently out of date. Every id here is publishable by design — each is what a checkout names.
|
|
30
|
+
*
|
|
31
|
+
* Apple's and Google's product ids are deliberately absent: a browser cannot open either store.
|
|
32
|
+
*/
|
|
33
|
+
skus: {
|
|
34
|
+
/** The Stripe price id. */
|
|
35
|
+
stripe: string | null;
|
|
36
|
+
/** The Lemon Squeezy variant id. */
|
|
37
|
+
lemonSqueezy: string | null;
|
|
38
|
+
/** The Paddle price id. */
|
|
39
|
+
paddle: string | null;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a browser may know about this project's payments — the shape of `virtual:pithy/payments`.
|
|
45
|
+
*
|
|
46
|
+
* **This declaration is the contract, and the projection is checked against it.** It is written here
|
|
47
|
+
* rather than inferred from the closure that builds it, and that is the whole point: an inferred type
|
|
48
|
+
* follows whatever the producer last happened to say, so a projection that dropped `basePath`, or that
|
|
49
|
+
* widened `type` because `PaymentsConfig` learned a fourth product kind, or that started passing a
|
|
50
|
+
* product's Apple id through, would take the type with it and nothing would go red. Declared, the
|
|
51
|
+
* function is the thing that has to change — every widening of what a browser sees is decided here.
|
|
52
|
+
*
|
|
53
|
+
* Nothing sensitive is in reach of the producer to begin with: Apple's issuer key, Google's service
|
|
54
|
+
* account, and Stripe's secret and signing keys live in the secrets store behind
|
|
55
|
+
* `paymentsSecretsRegistry`, not in the config the closure can see. What this type covers is the rest of
|
|
56
|
+
* the catalog — the `grants` block and the store-only SKUs, which are omissions of judgment rather than
|
|
57
|
+
* of reach, and so are the ones worth writing down.
|
|
58
|
+
*
|
|
59
|
+
* **`billingSubject` is a third omission of judgment, and it is deliberate.** The subject *id* obviously
|
|
60
|
+
* never crosses — it is a fact about one caller, and this projection is inlined into a bundle every caller
|
|
61
|
+
* receives, so there is no request here to be right about. But the mode is not a secret and is left out
|
|
62
|
+
* anyway, on the plainer ground that nothing in a browser can use it. A paywall renders products and buy
|
|
63
|
+
* buttons; who holds what it buys is decided on the server, on the request, by the subject seam, and a
|
|
64
|
+
* screen that branched on the mode would be a second copy of that decision drifting from the first. The
|
|
65
|
+
* shape is also not free: this type is what `@pithy-sh/vite` generates `templates/client-env.d.ts` from, so
|
|
66
|
+
* a key added here is a key added to every adopter's ambient declaration. It goes in the day something in
|
|
67
|
+
* a browser genuinely cannot be written without it, and not before.
|
|
68
|
+
*
|
|
69
|
+
* **This is the only statement of the shape.** `@pithy-sh/ui-react`'s `templates/client-env.d.ts` — the
|
|
70
|
+
* ambient declaration `pithy ui add react` copies into an adopter's Worker — is generated from this type
|
|
71
|
+
* by `@pithy-sh/vite`'s `clientEnvDeclaration.ts` (#398). The unions and the per-field doc comments below
|
|
72
|
+
* are emitted verbatim, so what is written here is what a screen author reads.
|
|
73
|
+
*/
|
|
74
|
+
export type PaymentsClientProjection =
|
|
75
|
+
| {
|
|
76
|
+
/**
|
|
77
|
+
* Payments is not composed, or has no catalog this environment can render. Both read the same on
|
|
78
|
+
* purpose: "composed with nothing to sell" is a paywall with nothing on it, exactly like "not
|
|
79
|
+
* composed", and a screen branches on one value rather than guarding.
|
|
80
|
+
*/
|
|
81
|
+
enabled: false;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
/** Payments is composed AND has a catalog this environment can render. */
|
|
85
|
+
enabled: true;
|
|
86
|
+
/** The environment this bundle was built for. */
|
|
87
|
+
environment: string;
|
|
88
|
+
/**
|
|
89
|
+
* Which rails this project sells through. Apple and Google are display-only on the web — a
|
|
90
|
+
* paywall shows such a product as owned-elsewhere rather than offering a buy button nothing on
|
|
91
|
+
* the web can honor.
|
|
92
|
+
*/
|
|
93
|
+
rails: {
|
|
94
|
+
/** Whether the App Store rail is on. Display-only in a browser. */
|
|
95
|
+
apple: boolean;
|
|
96
|
+
/** Whether the Play Store rail is on. Display-only in a browser. */
|
|
97
|
+
google: boolean;
|
|
98
|
+
/** Whether the Stripe rail is on. */
|
|
99
|
+
stripe: boolean;
|
|
100
|
+
/** Whether the Lemon Squeezy rail is on. */
|
|
101
|
+
lemonSqueezy: boolean;
|
|
102
|
+
/** Whether the Paddle rail is on. */
|
|
103
|
+
paddle: boolean;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* What Paddle.js needs to initialize, or null when the rail is off. The client token is
|
|
107
|
+
* publishable by design — it is what a browser opens a checkout with — and the API key and the
|
|
108
|
+
* webhook signing secret are neither here nor expressible here.
|
|
109
|
+
*/
|
|
110
|
+
paddle: {
|
|
111
|
+
/** The publishable client token Paddle.js initializes with. */
|
|
112
|
+
clientToken: string;
|
|
113
|
+
/** Which Paddle account the token belongs to. */
|
|
114
|
+
environment: "sandbox" | "production";
|
|
115
|
+
/**
|
|
116
|
+
* How checkout is presented: `overlay` opens Paddle.js over your own page, `inline` renders it
|
|
117
|
+
* into a container the screen provides, `hosted` redirects to Paddle's own page.
|
|
118
|
+
*
|
|
119
|
+
* The union is stated, not `string`. A screen switches on this to decide whether to render a
|
|
120
|
+
* container at all, and the exhaustiveness is the point. It was the one field the hand-written
|
|
121
|
+
* `templates/client-env.d.ts` widened, and generating that file from here is what closed it.
|
|
122
|
+
*/
|
|
123
|
+
checkout: "overlay" | "inline" | "hosted";
|
|
124
|
+
} | null;
|
|
125
|
+
/** Where the payments routes mount, e.g. `/payments`. */
|
|
126
|
+
basePath: string;
|
|
127
|
+
/**
|
|
128
|
+
* The catalog, browser-safe, in the order the adopter wrote it. A web rail's price id is
|
|
129
|
+
* publishable by design — a checkout names one. Apple's and Google's SKUs, and anything a
|
|
130
|
+
* purchase fulfills beyond its entitlements, stay server-side.
|
|
131
|
+
*/
|
|
132
|
+
products: PaymentsClientProduct[];
|
|
133
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PaddleCacheStore } from "../paddleCache";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A {@link PaddleCacheStore} a test can look inside.
|
|
8
|
+
*
|
|
9
|
+
* `localStorage` is the store an adopter passes and the wrong one to test against: a suite that reached
|
|
10
|
+
* for it would need a DOM, would leak entries between tests, and could not answer "what is in there now"
|
|
11
|
+
* without re-deriving the key the module chose. This is a `Map`, and the `Map` is exposed — so a test can
|
|
12
|
+
* assert an entry was written, corrupt one to see what happens next, or count what a run left behind.
|
|
13
|
+
*
|
|
14
|
+
* It enumerates, because `Storage` does: `length` and `key` are what a sweep walks, and a stand-in
|
|
15
|
+
* missing them would quietly exercise the un-swept path on every test that thought it was testing one.
|
|
16
|
+
*/
|
|
17
|
+
export function memoryStore(): PaddleCacheStore & { entries: Map<string, string> } {
|
|
18
|
+
const entries = new Map<string, string>();
|
|
19
|
+
return {
|
|
20
|
+
entries,
|
|
21
|
+
get length() {
|
|
22
|
+
return entries.size;
|
|
23
|
+
},
|
|
24
|
+
key: (index: number) => [...entries.keys()][index] ?? null,
|
|
25
|
+
getItem: (key: string) => entries.get(key) ?? null,
|
|
26
|
+
setItem: (key: string, value: string) => {
|
|
27
|
+
entries.set(key, value);
|
|
28
|
+
},
|
|
29
|
+
removeItem: (key: string) => {
|
|
30
|
+
entries.delete(key);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A store that refuses everything — Safari in private browsing, and a quota that is full. */
|
|
36
|
+
export function refusingStore(): PaddleCacheStore {
|
|
37
|
+
const refuse = (): never => {
|
|
38
|
+
throw new Error("the store is unavailable");
|
|
39
|
+
};
|
|
40
|
+
return { getItem: refuse, setItem: refuse, removeItem: refuse };
|
|
41
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { minorUnitDigits } from "../data/money";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Removing a fraction that is entirely zero from a price Paddle already rendered.
|
|
8
|
+
*
|
|
9
|
+
* A seller whose plans are `$6`, `€12` and `KD 6` renders `$6.00`, `12,00 €` and `<U+200F>٦٫٠٠٠ د.ك.<U+200F>`, and on a
|
|
10
|
+
* pricing table the decimal is noise on every row. Two Pithy surfaces reached the same conclusion and each
|
|
11
|
+
* wrote `total.replace(/([.,])00(?=\D*$)/, "")` — the same rule, byte for byte, in two repositories. That
|
|
12
|
+
* regex is wrong in the three-decimal dinars, silent in every Arabic-Indic script, and cannot be made
|
|
13
|
+
* right, because the only thing either surface holds is the finished string. This module is the reason
|
|
14
|
+
* neither of them has to.
|
|
15
|
+
*
|
|
16
|
+
* **The decision is arithmetic, and only this package holds the number.** Paddle sends every figure twice:
|
|
17
|
+
* in minor units for comparing, and rendered for showing. $6.00 is `600`, so whether the fraction is zero
|
|
18
|
+
* is `amount % 10 ** places === 0` — no parsing, no locale, no guess. That is what `PriceLine.totals` says
|
|
19
|
+
* it is for. It also removes the whole ambiguity class: *is the trailing `000` in `$1,000` a fraction or a
|
|
20
|
+
* thousands group?* is never asked, because the fraction is known before the string is touched.
|
|
21
|
+
*
|
|
22
|
+
* **The removal never asks which character the separator is.** It is a property of the locale, not of the
|
|
23
|
+
* currency, so the ISO code cannot give it — `de-DE` renders EUR as `6,00 €` and `en-IE` renders the same
|
|
24
|
+
* currency as `€6.00`. And the browser cannot give it either: `Intl.NumberFormat(undefined, …)` answers
|
|
25
|
+
* for the **visitor's** locale while the string was rendered by **Paddle**, so matching the browser's
|
|
26
|
+
* separator is a silent no-op in exactly the case where the two differ, which is the case that matters.
|
|
27
|
+
*
|
|
28
|
+
* So the fraction is located positionally: the trailing run of `places` decimal digits, in whatever
|
|
29
|
+
* script, with a single non-digit before it that must itself follow a digit. Trailing symbols, spaces and
|
|
30
|
+
* bidi marks sit after the run and are carried through untouched. A run preceded by another digit is a
|
|
31
|
+
* thousands group and is left alone — `$1,000` and `1.000 €` both come back exactly as they arrived.
|
|
32
|
+
*
|
|
33
|
+
* **A digit is treated as its script's zero when the code point below it is not a decimal digit.** Every
|
|
34
|
+
* `Nd` block is ten contiguous points beginning at zero, so that one comparison answers it for
|
|
35
|
+
* Arabic-Indic, Devanagari and most of the rest. The usual shortcuts do not: `Number("٠")` is `NaN`, and
|
|
36
|
+
* `"٠".normalize("NFKD")` is still `"٠"`.
|
|
37
|
+
*
|
|
38
|
+
* **It is not a universal, and the first draft of this paragraph said it was — Jim, 2026-08-21.** Five of
|
|
39
|
+
* Unicode's seventy-two `Nd` blocks sit immediately after another one, so their zero has a decimal digit
|
|
40
|
+
* below it and this test answers `false` for it.
|
|
41
|
+
*
|
|
42
|
+
* **The direction of that error is the reason it is still the right test.** A missed zero means the
|
|
43
|
+
* fraction is not recognized and the figure is returned exactly as Paddle rendered it — a silent refusal
|
|
44
|
+
* to trim, never a mangled price. A false *positive* is what would remove a digit that mattered, and
|
|
45
|
+
* cannot happen here: block lengths are all ten, so the start of a digit run is always a block zero.
|
|
46
|
+
*
|
|
47
|
+
* Brute-forced rather than reasoned about: 152,592 combinations over `Intl.supportedValuesOf("currency")`
|
|
48
|
+
* against 34 locales and 15 amounts produced zero value-changing outputs, holding
|
|
49
|
+
* `digits(out) * 10**places === digits(in)` with exactly one non-digit removed.
|
|
50
|
+
*
|
|
51
|
+
* **Only an all-zero fraction ever goes.** Nothing here divides, rounds or truncates, and a real fraction
|
|
52
|
+
* comes back whole. Both halves have to agree before a character is removed — the arithmetic says the
|
|
53
|
+
* fraction is zero *and* the digits in the string are zeros — because the two figures disagreeing means
|
|
54
|
+
* somebody handed this the wrong pair, and the string is the only thing a visitor sees.
|
|
55
|
+
*
|
|
56
|
+
* **`minorUnitDigits` is shared with the server half, deliberately.** It is `../data/money`'s ISO-4217
|
|
57
|
+
* exponent table, the one the purchase projection already uses, and it imports nothing — so the browser
|
|
58
|
+
* bundle gains a set of currency codes and no graph. A second copy of that table here would be this
|
|
59
|
+
* issue's own defect, one layer down.
|
|
60
|
+
*
|
|
61
|
+
* **#465 corrected `./paddle.ts` about `Intl` and did not touch this module's reasoning — checked, 2026-08-28.**
|
|
62
|
+
* That correction is that `Intl` does know the currency exponents, so a server with only minor units may
|
|
63
|
+
* render a figure. Neither half of the argument here rested on it. This module never needed the exponent
|
|
64
|
+
* from `Intl` — it has ISO 4217, which is what a *store* denominates in, and that turned out to be the
|
|
65
|
+
* stronger choice: CLDR renders `HUF` and `COP` with no fraction at all, while Paddle bills both with two,
|
|
66
|
+
* so a `places` taken from `Intl` would answer 0 for a currency whose minor unit is the hundredth. And the
|
|
67
|
+
* separator claim above is untouched, because it is not a claim about what `Intl` knows: it is that
|
|
68
|
+
* `Intl.NumberFormat(undefined, …)` answers for the **visitor's** locale while the string in hand was
|
|
69
|
+
* rendered in **Paddle's**, and the two differing is exactly the case that matters. Trimming a string
|
|
70
|
+
* Paddle rendered stays right here; rendering one from scratch stays wrong here, and is right only where
|
|
71
|
+
* Paddle rendered nothing — `../data/renderMoney.ts`, on the server, from the negotiated locale.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/** A decimal digit in any script. `Nd`, so Arabic-Indic and Devanagari count as much as ASCII. */
|
|
75
|
+
const DECIMAL_DIGIT = /\p{Nd}/u;
|
|
76
|
+
|
|
77
|
+
/** An amount in minor units, as Paddle writes one: an integer, optionally signed, and nothing else. */
|
|
78
|
+
const MINOR_UNITS = /^-?[0-9]+$/;
|
|
79
|
+
|
|
80
|
+
/** One figure, in both of the forms Paddle sends it in, and what it is denominated in. */
|
|
81
|
+
export interface WholeUnitPrice {
|
|
82
|
+
/** Paddle's own rendering of the figure for this visitor. The only thing ever returned. */
|
|
83
|
+
readonly formatted: string;
|
|
84
|
+
/** The same figure in minor units — `"600"` for $6.00, `"6000"` for KD 6.000. Never rendered. */
|
|
85
|
+
readonly minorAmount: string;
|
|
86
|
+
/** The ISO-4217 code the quote is in. It decides how many digits the fraction occupies. */
|
|
87
|
+
readonly currency: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether a single character is a decimal digit. */
|
|
91
|
+
function isDecimalDigit(character: string | undefined): boolean {
|
|
92
|
+
return character !== undefined && DECIMAL_DIGIT.test(character);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether a single character is the zero of its own script. */
|
|
96
|
+
function isScriptZero(character: string | undefined): boolean {
|
|
97
|
+
if (character === undefined || !isDecimalDigit(character)) return false;
|
|
98
|
+
const code = character.codePointAt(0);
|
|
99
|
+
if (code === undefined) return false;
|
|
100
|
+
return !isDecimalDigit(String.fromCodePoint(code - 1));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The string with its trailing `places`-digit zero fraction and that fraction's separator removed.
|
|
105
|
+
*
|
|
106
|
+
* By code point rather than by UTF-16 unit: a digit outside the BMP would be two units, and a scan that
|
|
107
|
+
* counted units would cut one of them in half. The string itself is returned wherever the shape is not
|
|
108
|
+
* exactly a fraction, which is every refusal in this file — there is no partial removal.
|
|
109
|
+
*/
|
|
110
|
+
function withoutTrailingZeros(formatted: string, places: number): string {
|
|
111
|
+
const characters = [...formatted];
|
|
112
|
+
// Trailing symbols, spaces and bidi marks come after the fraction, so walk back to the last digit.
|
|
113
|
+
let end = characters.length;
|
|
114
|
+
while (end > 0 && !isDecimalDigit(characters[end - 1])) end -= 1;
|
|
115
|
+
const start = end - places;
|
|
116
|
+
// Two characters at least must precede the run: the separator, and the digit it separates from.
|
|
117
|
+
if (start < 2) return formatted;
|
|
118
|
+
for (let index = start; index < end; index += 1) {
|
|
119
|
+
if (!isScriptZero(characters[index])) return formatted;
|
|
120
|
+
}
|
|
121
|
+
// A digit before the run makes it a thousands group. A non-digit before *that* makes it something
|
|
122
|
+
// this cannot read as a price at all.
|
|
123
|
+
if (isDecimalDigit(characters[start - 1])) return formatted;
|
|
124
|
+
if (!isDecimalDigit(characters[start - 2])) return formatted;
|
|
125
|
+
return [...characters.slice(0, start - 1), ...characters.slice(end)].join("");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Paddle's rendering with an all-zero fraction removed, or that rendering exactly as it arrived.
|
|
130
|
+
*
|
|
131
|
+
* Opt-in everywhere it is reachable from, and never a default. Only an all-zero fraction ever goes, so an
|
|
132
|
+
* adopter selling at `$6.99` is unaffected either way — but which figures a seller advertises is a pricing
|
|
133
|
+
* decision, and this kit does not get to restyle somebody's prices on their behalf.
|
|
134
|
+
*/
|
|
135
|
+
export function withoutZeroFraction(price: WholeUnitPrice): string {
|
|
136
|
+
const places = minorUnitDigits(price.currency);
|
|
137
|
+
// The yen has no subunit, so there is no fraction to consider and every digit in the string is part of
|
|
138
|
+
// the figure. This states that rather than enforcing it: with no places the scan below looks at a run
|
|
139
|
+
// of no digits, whose preceding character is the last digit of the figure, and refuses on its own. A
|
|
140
|
+
// test can only see the outcome, so the comment is where the intent lives.
|
|
141
|
+
if (places === 0) return price.formatted;
|
|
142
|
+
if (!MINOR_UNITS.test(price.minorAmount)) return price.formatted;
|
|
143
|
+
const amount = Number(price.minorAmount);
|
|
144
|
+
if (!Number.isSafeInteger(amount)) return price.formatted;
|
|
145
|
+
if (amount % 10 ** places !== 0) return price.formatted;
|
|
146
|
+
return withoutTrailingZeros(price.formatted, places);
|
|
147
|
+
}
|