@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,265 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A price nobody has to fetch twice — opt-in, and only ever where the caller said to put it.
|
|
6
|
+
*
|
|
7
|
+
* One built artifact quotes the marketing site and the dashboard both, and each of them wants the same
|
|
8
|
+
* thing for a different reason: a marketing page a visitor moves around inside should not spend a Paddle
|
|
9
|
+
* round trip per page, and a dashboard that re-mounts a pricing pane should not re-ask a question it
|
|
10
|
+
* asked ten seconds ago. So the quote path can cache. What it may not do is decide any part of how.
|
|
11
|
+
*
|
|
12
|
+
* **Nothing is cached unless the caller names a store, a namespace and a lifetime.** All three, together.
|
|
13
|
+
* A quote resolved from `customerId` is one customer's price, resolved from the address on their account
|
|
14
|
+
* — so where it rests is not a detail this module may pick a default for. `sessionStorage` and
|
|
15
|
+
* `localStorage` are the same interface and very different promises about a shared machine, and only the
|
|
16
|
+
* program that knows who is signed in can choose between them. There is no default. There is no
|
|
17
|
+
* "sensible" fallback. A caller that wants a cache says where.
|
|
18
|
+
*
|
|
19
|
+
* **A lifetime is stated too, for the same reason.** Every figure here is Paddle's, and Paddle's figures
|
|
20
|
+
* move: two recordings of one Japanese price taken minutes apart came back ¥797 and ¥798, because the
|
|
21
|
+
* FX rate moved between the calls. Tax rules change, and a customer changes their billing address. A
|
|
22
|
+
* cached figure is a figure nobody re-checked, so how long that may last is the caller's call and nobody
|
|
23
|
+
* inherits a number they never looked at.
|
|
24
|
+
*
|
|
25
|
+
* **A partial cache is a mistake, and it says so.** Two of the three is what a caller reaches for first,
|
|
26
|
+
* and silently ignoring it would look exactly like caching that works — until the day somebody wonders
|
|
27
|
+
* why the network tab shows a request per page. It warns, names the parts that are missing, and quotes
|
|
28
|
+
* from the network. It never fails the quote: a broken cache must not be able to take a price off a page.
|
|
29
|
+
*
|
|
30
|
+
* **The question is inside the entry's key**, which is what makes caching a signed-in customer's price
|
|
31
|
+
* safe at all. Two visitors are two questions, so one can never be handed the other's answer; and the
|
|
32
|
+
* account is in there beside them, so a sandbox answer cannot survive into production.
|
|
33
|
+
*
|
|
34
|
+
* `console` rather than a logger, and this is the one file in the package that may. The kit's logger is
|
|
35
|
+
* a Worker's — resolved from a request context that does not exist here — and this module compiles into
|
|
36
|
+
* a browser program where `console` *is* the sink an adopter reads. `biome.jsonc` carries the exemption
|
|
37
|
+
* and this sentence is its reason.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The slice of a storage this needs.
|
|
42
|
+
*
|
|
43
|
+
* `localStorage` and `sessionStorage` both satisfy it as they are, and so does a `Map` wrapper, an
|
|
44
|
+
* in-memory object, or a caller's own store with a quota policy of its own. Structural for the reason
|
|
45
|
+
* every seam in this directory is: an adopter must be able to satisfy it without importing anything.
|
|
46
|
+
*/
|
|
47
|
+
export interface PaddleCacheStore {
|
|
48
|
+
/** The stored text under a key, or null when there is none. */
|
|
49
|
+
getItem(key: string): string | null;
|
|
50
|
+
/** Store text under a key, replacing whatever was there. */
|
|
51
|
+
setItem(key: string, value: string): void;
|
|
52
|
+
/** Forget a key. Called on every entry this module decides it may no longer trust. */
|
|
53
|
+
removeItem(key: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* How many entries the store holds, if it can say.
|
|
56
|
+
*
|
|
57
|
+
* `Storage` has this and `key` both, so `localStorage` and `sessionStorage` satisfy them as they are.
|
|
58
|
+
* They are optional because a caller's own three-method store is still a store — it simply does not
|
|
59
|
+
* get swept, which is the difference between a cache that tidies up after itself and one that does not.
|
|
60
|
+
*/
|
|
61
|
+
readonly length?: number;
|
|
62
|
+
/** The nth key, if the store can enumerate. */
|
|
63
|
+
key?(index: number): string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A cache, complete: where entries rest, what namespaces them, and how long one may stand. */
|
|
67
|
+
export interface PaddleQuoteCache {
|
|
68
|
+
/** The namespace entries are stored under. Two surfaces sharing a store share nothing else. */
|
|
69
|
+
readonly key: string;
|
|
70
|
+
/** Where entries rest. Named by the caller, always. */
|
|
71
|
+
readonly store: PaddleCacheStore;
|
|
72
|
+
/** How long an entry may stand, in milliseconds. Stated by the caller, always. */
|
|
73
|
+
readonly ttlMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The three parts as they arrive from outside TypeScript — a script tag's attributes, a JavaScript
|
|
78
|
+
* caller, a config file. Each may be absent, and each may be nonsense.
|
|
79
|
+
*/
|
|
80
|
+
export interface PaddleQuoteCacheParts {
|
|
81
|
+
/** The intended namespace. */
|
|
82
|
+
readonly key?: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* The intended store, or the **name** of one that did not resolve.
|
|
85
|
+
*
|
|
86
|
+
* A string is how a caller says "somebody named a store and nothing came back" — a script tag whose
|
|
87
|
+
* `data-paddle-cache-store` is a typo, an environment with no storage. It is not a store, so it is
|
|
88
|
+
* reported missing; but it *was* asked for, so the request is a partial cache and gets a line rather
|
|
89
|
+
* than the silence a caller who asked for nothing gets.
|
|
90
|
+
*/
|
|
91
|
+
readonly store?: PaddleCacheStore | string | null;
|
|
92
|
+
/** The intended lifetime, in milliseconds. */
|
|
93
|
+
readonly ttlMs?: number | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What every entry this module writes is stored under, so a store shared with a page stays legible. */
|
|
97
|
+
const PREFIX = "pithy.paddle.price";
|
|
98
|
+
|
|
99
|
+
/** Whether a value can actually store: all three methods, not merely an object that arrived. */
|
|
100
|
+
function isStore(value: unknown): value is PaddleCacheStore {
|
|
101
|
+
if (typeof value !== "object" || value === null) return false;
|
|
102
|
+
const candidate = value as Partial<Record<keyof PaddleCacheStore, unknown>>;
|
|
103
|
+
return (
|
|
104
|
+
typeof candidate.getItem === "function" &&
|
|
105
|
+
typeof candidate.setItem === "function" &&
|
|
106
|
+
typeof candidate.removeItem === "function"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read a cache from parts that may be anything, or refuse it out loud.
|
|
112
|
+
*
|
|
113
|
+
* All three or none. Nothing given is a caller who wants no cache, and it says nothing at all. Anything
|
|
114
|
+
* given that does not add up to a cache is a caller who wanted one and will not get one, and that is
|
|
115
|
+
* worth a line in their console rather than a silence they would read as success.
|
|
116
|
+
*/
|
|
117
|
+
export function readQuoteCache(parts: PaddleQuoteCacheParts): PaddleQuoteCache | null {
|
|
118
|
+
const key = typeof parts.key === "string" ? parts.key.trim() : "";
|
|
119
|
+
const store = isStore(parts.store) ? parts.store : null;
|
|
120
|
+
const ttlMs = typeof parts.ttlMs === "number" && Number.isFinite(parts.ttlMs) && parts.ttlMs > 0 ? parts.ttlMs : null;
|
|
121
|
+
if (key !== "" && store !== null && ttlMs !== null) return { key, store, ttlMs };
|
|
122
|
+
|
|
123
|
+
const asked = parts.key ?? parts.store ?? parts.ttlMs;
|
|
124
|
+
if (asked === undefined || asked === null) return null;
|
|
125
|
+
|
|
126
|
+
const missing = [key === "" ? "key" : null, store === null ? "store" : null, ttlMs === null ? "ttlMs" : null]
|
|
127
|
+
.filter((part): part is string => part !== null)
|
|
128
|
+
.join(", ");
|
|
129
|
+
console.warn(
|
|
130
|
+
`Paddle price cache ignored. Missing: ${missing}. A cache needs key, store and ttlMs together, so that where a customer's price rests and how long it stands are both stated. Pass all three, or none.`,
|
|
131
|
+
);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Everything one cache stores sits under this. */
|
|
136
|
+
function namespace(cache: PaddleQuoteCache): string {
|
|
137
|
+
return `${PREFIX}.${cache.key}.`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Where one answer to one question rests. */
|
|
141
|
+
function entryKey(cache: PaddleQuoteCache, of: string): string {
|
|
142
|
+
return `${namespace(cache)}${of}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What one entry holds, or null for text this module will not trust. */
|
|
146
|
+
function readEntry(stored: string | null): { at: number; answer: unknown } | null {
|
|
147
|
+
if (stored === null) return null;
|
|
148
|
+
let parsed: unknown;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(stored);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
155
|
+
const entry = parsed as { at?: unknown; answer?: unknown };
|
|
156
|
+
if (typeof entry.at !== "number" || !("answer" in entry)) return null;
|
|
157
|
+
return { at: entry.at, answer: entry.answer };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether an entry has stopped standing.
|
|
162
|
+
*
|
|
163
|
+
* Both directions. A negative age is a clock that moved backwards — a laptop waking up, a device
|
|
164
|
+
* correcting itself — and an entry stamped in the future would otherwise stand until the clock caught up
|
|
165
|
+
* with it, which is the one expiry nobody can predict the length of.
|
|
166
|
+
*/
|
|
167
|
+
function stale(at: number, ttlMs: number): boolean {
|
|
168
|
+
const age = Date.now() - at;
|
|
169
|
+
return age < 0 || age > ttlMs;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Forget an entry, and never mind if the store will not let us. */
|
|
173
|
+
function forget(cache: PaddleQuoteCache, of: string): void {
|
|
174
|
+
try {
|
|
175
|
+
cache.store.removeItem(entryKey(cache, of));
|
|
176
|
+
} catch {
|
|
177
|
+
// A store that refuses a delete is a store this cannot repair. The entry expires on its own clock.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The answer this cache holds for a question, while it is still fresh.
|
|
183
|
+
*
|
|
184
|
+
* Null for a miss, and *every* way of being wrong is a miss: no entry, unreadable text, an entry from a
|
|
185
|
+
* version that stored something else, one past its lifetime, one stamped in a future the clock has since
|
|
186
|
+
* moved back from, a store that throws on read. Each of those is a reason to ask Paddle again, and none
|
|
187
|
+
* of them is a reason to show a visitor no price.
|
|
188
|
+
*
|
|
189
|
+
* An entry it will not trust is thrown away rather than left to expire, so a store that has collected
|
|
190
|
+
* something unreadable stops being asked about it.
|
|
191
|
+
*/
|
|
192
|
+
export function cachedAnswer(cache: PaddleQuoteCache, of: string): unknown | null {
|
|
193
|
+
let stored: string | null;
|
|
194
|
+
try {
|
|
195
|
+
stored = cache.store.getItem(entryKey(cache, of));
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (stored === null) return null;
|
|
200
|
+
|
|
201
|
+
const entry = readEntry(stored);
|
|
202
|
+
if (entry === null || stale(entry.at, cache.ttlMs)) {
|
|
203
|
+
forget(cache, of);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return entry.answer;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Throw away this cache's own expired entries.
|
|
211
|
+
*
|
|
212
|
+
* **Its own, and judged by its own lifetime.** Two surfaces can share one store under different names
|
|
213
|
+
* and different ttls, and sweeping a neighbor's entries against *this* cache's number would throw away
|
|
214
|
+
* answers that are perfectly fresh by the rule they were kept under. Anything outside the namespace —
|
|
215
|
+
* another cache's, or the page's own — is not this module's to read or to delete.
|
|
216
|
+
*
|
|
217
|
+
* Silent, total, and skipped entirely where the store cannot enumerate. A sweep that fails costs the
|
|
218
|
+
* page nothing; the entry it did not reach expires on its own clock the next time anything asks for it.
|
|
219
|
+
*/
|
|
220
|
+
function sweep(cache: PaddleQuoteCache): void {
|
|
221
|
+
const store = cache.store;
|
|
222
|
+
if (typeof store.key !== "function" || typeof store.length !== "number") return;
|
|
223
|
+
try {
|
|
224
|
+
const expired: string[] = [];
|
|
225
|
+
const mine = namespace(cache);
|
|
226
|
+
for (let index = 0; index < store.length; index += 1) {
|
|
227
|
+
const key = store.key(index);
|
|
228
|
+
if (key === null || key === undefined || !key.startsWith(mine)) continue;
|
|
229
|
+
const entry = readEntry(store.getItem(key));
|
|
230
|
+
if (entry === null || stale(entry.at, cache.ttlMs)) expired.push(key);
|
|
231
|
+
}
|
|
232
|
+
for (const key of expired) store.removeItem(key);
|
|
233
|
+
} catch {
|
|
234
|
+
// A store that will not be walked is a store that does not get tidied. Nothing else changes.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Remember an answer to a question, if the store will have it.
|
|
240
|
+
*
|
|
241
|
+
* Silent about a refusal, deliberately. A full quota and Safari's private browsing both throw on write,
|
|
242
|
+
* and neither is a thing the visitor looking at the price can do anything about — the page already has
|
|
243
|
+
* its figure, and the only cost is asking again next time. The warning this module does emit is for a
|
|
244
|
+
* cache the *caller* got wrong, which is a thing they can fix.
|
|
245
|
+
*
|
|
246
|
+
* **It sweeps first.** Nothing reads a departed customer's key again, so nothing would ever expire it:
|
|
247
|
+
* a dashboard caching per customer on a shared machine writes one entry per person who ever signs in,
|
|
248
|
+
* the quota fills, and every write from then on throws into the silence above — caching stops working
|
|
249
|
+
* for everybody, and by design nobody is told. Expiring on the way past is what bounds it.
|
|
250
|
+
*/
|
|
251
|
+
export function rememberAnswer(cache: PaddleQuoteCache, of: string, answer: unknown): void {
|
|
252
|
+
sweep(cache);
|
|
253
|
+
let text: string;
|
|
254
|
+
try {
|
|
255
|
+
text = JSON.stringify({ at: Date.now(), answer });
|
|
256
|
+
} catch {
|
|
257
|
+
// An answer that will not serialize is one this cannot store. Paddle's never is; a stub's might be.
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
cache.store.setItem(entryKey(cache, of), text);
|
|
262
|
+
} catch {
|
|
263
|
+
// Quota, or a store that only pretends to be one. The quote already happened.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { mountPrices } from "./paddlePricesTag";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The browser build's entry: one classic script, configured by its own tag.
|
|
8
|
+
*
|
|
9
|
+
* ```html
|
|
10
|
+
* <script src="/js/paddle-prices.js"
|
|
11
|
+
* data-paddle-env="sandbox"
|
|
12
|
+
* data-paddle-token="test_…"
|
|
13
|
+
* data-paddle-price-solo="pri_…"
|
|
14
|
+
* data-paddle-price-team="pri_…"></script>
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* It quotes every plan the tag names and writes each formatted total into the `[data-price-plan]` slot
|
|
18
|
+
* that asked for it. A site with no build step gets the same quote path the dashboard imports, which is
|
|
19
|
+
* the whole point: `#416` was one surface's copy of this being wrong while the other surface's copy was
|
|
20
|
+
* right, and nothing ran both.
|
|
21
|
+
*
|
|
22
|
+
* **The answer is also handed back, on `window.pithyPaddlePrices`.** A site with its own formatting rule
|
|
23
|
+
* — `$6.00` rendered as `$6`, say — or its own markup to write sets `data-paddle-paint="off"` and awaits
|
|
24
|
+
* `window.pithyPaddlePrices.quotes` instead. Painting stays with whoever is rendering; only the quote is
|
|
25
|
+
* shared.
|
|
26
|
+
*
|
|
27
|
+
* **Caching is the tag's now**, and off unless it asks: `data-paddle-cache` names it,
|
|
28
|
+
* `data-paddle-cache-store` says `local` or `session`, and `data-paddle-cache-ttl` says for how many
|
|
29
|
+
* seconds. All three or none — a tag naming some of them warns and quotes from the network.
|
|
30
|
+
*
|
|
31
|
+
* `document.currentScript` is read here and nowhere deeper, because it is only itself while the script is
|
|
32
|
+
* running. Everything past that point takes the tag as an argument.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** What the page can reach once this has run. */
|
|
36
|
+
export interface PithyPaddlePrices {
|
|
37
|
+
/** The quote, or the refusal. Resolves once; never rejects. */
|
|
38
|
+
readonly quotes: ReturnType<typeof mountPrices>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
(globalThis as unknown as { pithyPaddlePrices: PithyPaddlePrices }).pithyPaddlePrices = {
|
|
42
|
+
quotes: mountPrices(document, document.currentScript),
|
|
43
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PaymentsResult } from "./api";
|
|
5
|
+
import {
|
|
6
|
+
type PaddleOptions,
|
|
7
|
+
type PaddlePriceQuery,
|
|
8
|
+
type PaddleSetup,
|
|
9
|
+
type PriceSummaryOptions,
|
|
10
|
+
previewPrices,
|
|
11
|
+
priceSummary,
|
|
12
|
+
} from "./paddle";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One quote per named plan — the whole of what two Pithy surfaces share.
|
|
16
|
+
*
|
|
17
|
+
* The marketing site at `pithy.sh/pricing` and the dashboard both quote the same plans from the same
|
|
18
|
+
* Paddle account, and each once carried its own copy of the twelve lines that do it. One of those copies
|
|
19
|
+
* was wrong for months: `#416` read `currencyCode` and `details` off the top of `PricePreview()`'s answer
|
|
20
|
+
* when they live under `data`, so the reader refused every real response — and because a refusal is
|
|
21
|
+
* deliberately not an error, the screen rendered an empty price slot that looked deliberate. It was the
|
|
22
|
+
* *reviewed* copy that was wrong. The hand-written one on the marketing site had it right the whole time.
|
|
23
|
+
* Nothing could tell, because there was nothing that both of them ran.
|
|
24
|
+
*
|
|
25
|
+
* This is that thing. It goes through {@link previewPrices}, so it goes through `readPricePreview`, so
|
|
26
|
+
* the `#416` class of defect has exactly one place left it can exist.
|
|
27
|
+
*
|
|
28
|
+
* **What it deliberately does not do** is the reason it can be shared at all. It does not choose the
|
|
29
|
+
* account — the marketing site decides from the hostname at request time and the dashboard decides from
|
|
30
|
+
* `CLOUDFLARE_ENV` at build time, and both are right for their surface. It does not name a price id —
|
|
31
|
+
* the marketing site holds literals per account and the dashboard has a gate saying only one file in the
|
|
32
|
+
* Worker may name one. It does not cache — the dashboard forbids `localStorage` in a client module
|
|
33
|
+
* outright. And it does not paint: marketing writes text nodes, the dashboard renders React from the
|
|
34
|
+
* same answer. Given an account and a set of price ids, hand back a formatted total per plan. Everything
|
|
35
|
+
* above that stays with whoever is rendering.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Plan name to the Paddle price — `pri_…` — that quotes it. */
|
|
39
|
+
export type PaddlePlanPrices = Readonly<Record<string, string>>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Who the quote is for, and where — every part of a `PricePreview` query but the prices themselves.
|
|
43
|
+
*
|
|
44
|
+
* **A quote must resolve from the same Paddle row the charge will.** Omitted, Paddle resolves location
|
|
45
|
+
* from the browser's IP, which is right for a stranger reading a marketing page and wrong for everybody
|
|
46
|
+
* who has signed in: a customer with an address on file is quoted from whatever network they happen to
|
|
47
|
+
* be on, and `priceSummary` marks the figure `estimated` because no postal code resolved. Pass
|
|
48
|
+
* `customerId` and Paddle prices them from the address `POST /payments/checkout` will bill.
|
|
49
|
+
*
|
|
50
|
+
* Derived from {@link PaddlePriceQuery} rather than restated, so a field Paddle adds arrives here with
|
|
51
|
+
* it. `items` is the one part {@link quotePlans} owns — it is the plans, and there is one source of it.
|
|
52
|
+
*/
|
|
53
|
+
export type PaddleQuoteQuery = Omit<PaddlePriceQuery, "items">;
|
|
54
|
+
|
|
55
|
+
/** What {@link quotePlans} lets a caller replace, plus who the quote is for. */
|
|
56
|
+
export interface PaddleQuoteOptions extends PaddleOptions, PriceSummaryOptions {
|
|
57
|
+
/** Who to quote for, and where they are. Omitted, Paddle resolves it from the visitor's IP. */
|
|
58
|
+
readonly query?: PaddleQuoteQuery;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What one plan costs this visitor, ready to render. */
|
|
62
|
+
export interface PaddlePlanQuote {
|
|
63
|
+
/** The plan this quotes, as the caller named it. */
|
|
64
|
+
readonly plan: string;
|
|
65
|
+
/** The Paddle price it resolved to. */
|
|
66
|
+
readonly priceId: string;
|
|
67
|
+
/**
|
|
68
|
+
* The figure to show, already formatted by Paddle for this visitor.
|
|
69
|
+
*
|
|
70
|
+
* Per unit, and not the same field in every country: where tax is added on top the listed price is the
|
|
71
|
+
* subtotal, and where it is taken out of an inclusive figure the listed price is the total. See
|
|
72
|
+
* {@link priceSummary}, which is where that decision is made and tested.
|
|
73
|
+
*
|
|
74
|
+
* Paddle's own string, unless the caller passed `wholeUnits` — the one thing that ever changes it, and
|
|
75
|
+
* only by removing a fraction that is entirely zero.
|
|
76
|
+
*/
|
|
77
|
+
readonly headline: string;
|
|
78
|
+
/** One sentence about tax, or null where there is nothing true to say. */
|
|
79
|
+
readonly note: string | null;
|
|
80
|
+
/** Whether the tax in this quote may be short of what the buyer is charged. */
|
|
81
|
+
readonly estimated: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The ISO-4217 currency Paddle answered in — `"USD"`, `"JPY"`.
|
|
84
|
+
*
|
|
85
|
+
* {@link headline} is already formatted, so nothing here needs this to render. A caller that formats
|
|
86
|
+
* the figure itself does, and this is the only place it exists: `PriceSummary` carries no currency, so
|
|
87
|
+
* a caller reading only the quote had `preview.currencyCode` held one layer down and dropped.
|
|
88
|
+
*/
|
|
89
|
+
readonly currency: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Quote every named plan, or refuse.
|
|
94
|
+
*
|
|
95
|
+
* One `PricePreview` call for the whole set, because it is one round trip and Paddle answers a line per
|
|
96
|
+
* item. A plan Paddle returned no line for is left out rather than quoted from another plan's line — the
|
|
97
|
+
* caller sees a plan missing, which its own markup already has a sentence for, instead of two plans
|
|
98
|
+
* showing one price.
|
|
99
|
+
*
|
|
100
|
+
* **No plans means no network.** An unconfigured page must cost a visitor nothing, and `PricePreview`
|
|
101
|
+
* with an empty `items` is a request Paddle refuses anyway.
|
|
102
|
+
*
|
|
103
|
+
* **One price is asked for once, however many plans name it.** A pricing table with a highlighted row
|
|
104
|
+
* points two plans at one price often enough; asking for it twice is redundant at best, and whether
|
|
105
|
+
* Paddle tolerates a repeated `priceId` is not a thing to find out in production — a refusal there takes
|
|
106
|
+
* the whole table down to its placeholders, not just the duplicated row. Both plans still resolve, off
|
|
107
|
+
* the one line, because a plan is matched to its line by id.
|
|
108
|
+
*/
|
|
109
|
+
export async function quotePlans(
|
|
110
|
+
setup: PaddleSetup,
|
|
111
|
+
plans: PaddlePlanPrices,
|
|
112
|
+
options?: PaddleQuoteOptions,
|
|
113
|
+
): Promise<PaymentsResult<readonly PaddlePlanQuote[]>> {
|
|
114
|
+
const named = Object.entries(plans);
|
|
115
|
+
if (named.length === 0) return { ok: true, value: [] };
|
|
116
|
+
|
|
117
|
+
const asked = [...new Set(named.map(([, priceId]) => priceId))];
|
|
118
|
+
// The caller's half of the query first and `items` last, so a query naming items — which TypeScript
|
|
119
|
+
// forbids and a caller compiled from JavaScript is not asking TypeScript about — cannot quietly replace
|
|
120
|
+
// the prices the plans named.
|
|
121
|
+
const preview = await previewPrices(
|
|
122
|
+
setup,
|
|
123
|
+
{ ...options?.query, items: asked.map((priceId) => ({ priceId, quantity: 1 })) },
|
|
124
|
+
options,
|
|
125
|
+
);
|
|
126
|
+
if (!preview.ok) return preview;
|
|
127
|
+
|
|
128
|
+
const quotes: PaddlePlanQuote[] = [];
|
|
129
|
+
for (const [plan, priceId] of named) {
|
|
130
|
+
const line = preview.value.lines.find((candidate) => candidate.priceId === priceId);
|
|
131
|
+
if (line === undefined) continue;
|
|
132
|
+
const summary = priceSummary(preview.value, line, { wholeUnits: options?.wholeUnits });
|
|
133
|
+
quotes.push({
|
|
134
|
+
plan,
|
|
135
|
+
priceId,
|
|
136
|
+
headline: summary.headline,
|
|
137
|
+
note: summary.note,
|
|
138
|
+
estimated: summary.estimated,
|
|
139
|
+
currency: preview.value.currencyCode,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, value: quotes };
|
|
143
|
+
}
|