@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.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. package/src/workflows/wrangler.jsonc +55 -0
@@ -0,0 +1,734 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The framework-free half of the client surface: five calls against this project's own payments routes,
6
+ * and the guards that make their answers safe to read.
7
+ *
8
+ * **This module lives in the package on purpose.** `pithy ui add` writes a screen once and may never
9
+ * rewrite it, and a frozen paywall ages badly — store rules move under it, so price-change consent
10
+ * prompts, external purchase link entitlements, and subscription-management requirements all arrive
11
+ * after the file was written. So the calls, the error mapping, and the redirect-and-return dance ship
12
+ * here and upgrade with a minor release; the scaffolded stub renders and styles, and calls these.
13
+ *
14
+ * **Cookie/session, same origin.** Every request carries `credentials: "include"` and nothing else. No
15
+ * token in `localStorage` or `sessionStorage`, no `Authorization` header, no refresh rotation. The SPA
16
+ * and its Worker share an origin, so the session rides an httpOnly cookie that JavaScript cannot read
17
+ * and the server's same-origin check covers CSRF. Bearer is the mobile path — supported by the same
18
+ * routes, documented rather than scaffolded.
19
+ *
20
+ * **Nothing here throws, and nothing here hides a failure either.** An unreachable Worker, an HTML error
21
+ * page from a proxy, a 500 — each becomes a renderable {@link PaymentsFailure} on a `PaymentsResult`, and
22
+ * every reader answers one. Failing shut is a decision for a caller to make and to write down: a route
23
+ * guard does `result.ok && result.value.some(…)`, a screen that names the plan reports the failure
24
+ * instead. A reader that collapsed the two would make the choice for callers that must not have it made
25
+ * for them, which is what `api.test.ts` now holds the module to.
26
+ *
27
+ * The server's `requireEntitlement()` is still the security boundary. Nothing on this side of the wire
28
+ * protects a feature; it only decides what a screen shows.
29
+ *
30
+ * **No absolute URL literals, and no schema library.** Paths are relative, so the calls follow whatever
31
+ * origin the bundle is served from. Answers are narrowed by hand-written `is…(value: unknown): value is T`
32
+ * guards rather than Zod: this file is compiled into a browser bundle, and it must not drag the server's
33
+ * schema graph in behind it.
34
+ */
35
+
36
+ /** Where the payments routes mount by default — the same default `PaymentsConfig.basePath` carries. */
37
+ export const PAYMENTS_BASE_PATH = "/payments";
38
+
39
+ /**
40
+ * The query parameter a returning Stripe buyer carries the Checkout Session id in.
41
+ *
42
+ * It is a default, not a rule: `config.stripe.successUrl` is the adopter's own URL, and the documented
43
+ * shape puts `{CHECKOUT_SESSION_ID}` in `?session=`. A project that named it something else passes the
44
+ * name to {@link returnedCheckoutSession}.
45
+ */
46
+ export const CHECKOUT_SESSION_PARAM = "session";
47
+
48
+ /**
49
+ * The wire unions, declared literally rather than imported from `../data/*`.
50
+ *
51
+ * Those modules pull core's codecs and Zod into whatever TypeScript program typechecks this file, and
52
+ * this file is typechecked inside an adopter's DOM-typed *browser* program, where the Worker's type
53
+ * graph does not belong. `api.test.ts` pins each of these against the schema it mirrors, so the
54
+ * duplication cannot drift.
55
+ */
56
+ export type PaymentsClientRail = "apple" | "google" | "stripe" | "lemonSqueezy" | "paddle";
57
+
58
+ /**
59
+ * The rails that create a hosted checkout a browser can be sent to.
60
+ *
61
+ * A strict subset of {@link PaymentsClientRail}: Apple and Google purchases happen inside a store SDK, so
62
+ * there is no page to send anyone to. Nameable from a browser because a rail decides *who takes the money*,
63
+ * not how much or on whose behalf — a paywall for a product sold on both can put two buttons on the page,
64
+ * and a product sold on one needs no field at all.
65
+ */
66
+ export type PaymentsHostedRail = "stripe" | "lemonSqueezy" | "paddle";
67
+
68
+ /**
69
+ * The runtime mirror of {@link PaymentsHostedRail} — and the one list a screen gates a billing button on.
70
+ *
71
+ * Exported rather than private, because the screens need it. A scaffolded subscription page shows
72
+ * "Manage billing" when any hosted rail is on, and a scaffolded paywall offers to sell on any hosted
73
+ * rail a product is listed on: both are the same question, and both were open-coding the answer. A rail
74
+ * added to the package now reaches a template that was copied into an adopter's repo a year ago,
75
+ * because the template imports this rather than repeating it.
76
+ *
77
+ * Selling and portal-minting are one list, not two that agree — `CheckoutRail` declares both methods
78
+ * together, so a rail cannot have one without the other. `../data/rail.ts` carries the argument in full,
79
+ * and `providers.test.ts` holds this list to the rails that actually implement the interface.
80
+ */
81
+ export const PAYMENTS_HOSTED_RAILS: readonly PaymentsHostedRail[] = ["stripe", "lemonSqueezy", "paddle"];
82
+
83
+ /** Which Paddle account a handoff belongs to. `Paddle.Environment.set` takes it verbatim. */
84
+ export type PaymentsPaddleEnvironment = "sandbox" | "production";
85
+
86
+ /** Whether a Paddle checkout opens over the page or inside a container the screen provides. */
87
+ export type PaymentsPaddleDisplayMode = "overlay" | "inline";
88
+
89
+ /** The runtime mirrors of the two unions above, for the guards. */
90
+ const PADDLE_ENVIRONMENTS: readonly PaymentsPaddleEnvironment[] = ["sandbox", "production"];
91
+ const PADDLE_DISPLAY_MODES: readonly PaymentsPaddleDisplayMode[] = ["overlay", "inline"];
92
+
93
+ /**
94
+ * How the browser reaches checkout — a page to go to, or a transaction to open in place.
95
+ *
96
+ * A union rather than `{ url }`, because one rail has no URL to give: Paddle's overlay and inline modes
97
+ * never leave this page. A screen narrows on `kind`, which is a compile error where it is missing rather
98
+ * than a runtime navigation to an empty string.
99
+ */
100
+ export type PaymentsCheckoutHandoff = { kind: "redirect"; url: string } | PaddleCheckoutHandoff;
101
+
102
+ /**
103
+ * The handoff a Paddle checkout returns: a transaction, and what a browser needs to open it.
104
+ *
105
+ * Named rather than inlined because a screen holds one — `useCheckout` exposes it, and a `Paddle.Checkout
106
+ * .open` call site needs a type to write against.
107
+ */
108
+ export interface PaddleCheckoutHandoff {
109
+ /** Discriminates this from a redirect. */
110
+ kind: "paddle";
111
+ /** The transaction the server created — `txn_…`. */
112
+ transactionId: string;
113
+ /** Paddle's publishable client token, which is designed to reach a browser. */
114
+ clientToken: string;
115
+ /** Which Paddle account the token belongs to. */
116
+ environment: PaymentsPaddleEnvironment;
117
+ /** Whether the checkout opens over the page or inside a container. */
118
+ displayMode: PaymentsPaddleDisplayMode;
119
+ /**
120
+ * Where a buyer who paid is sent, from `config.paddle.successUrl`.
121
+ *
122
+ * **On the handoff rather than in the screen's hands, and that is the same rule the redirect rails
123
+ * follow.** `settings.successUrl` is a URL the browser is sent to with a completed purchase behind it;
124
+ * a screen that could name it could send a paying customer to a page it controls. So it comes from the
125
+ * server, on the response, exactly as a Stripe session's return URLs do — and it is checked here as a
126
+ * navigable URL before anything hands it to Paddle.
127
+ */
128
+ successUrl: string;
129
+ }
130
+
131
+ /** One subscription's authenticated portal deep links. */
132
+ export interface PaymentsPortalSubscription {
133
+ /** The store's own subscription id. */
134
+ subscriptionId: string;
135
+ /** Where this subscription is canceled. */
136
+ cancel: string;
137
+ /** Where this subscription's payment method is changed. */
138
+ updatePaymentMethod: string;
139
+ }
140
+
141
+ /**
142
+ * The caller's billing portal: an overview page, and any per-subscription deep links the store minted.
143
+ *
144
+ * **Every URL here is a bearer credential for that customer's billing.** Paddle's overview link carries a
145
+ * token good for 24 hours with scopes covering subscription updates and transaction creation, so a screen
146
+ * navigates to one and never stores it, logs it, or puts it in a query string something else will read.
147
+ */
148
+ export interface PaymentsPortalHandoff {
149
+ /** The portal's overview page. */
150
+ url: string;
151
+ /** Per-subscription deep links, for the store that offers them. */
152
+ subscriptions?: readonly PaymentsPortalSubscription[];
153
+ }
154
+
155
+ /** What kind of thing a product is, as a browser reads it. */
156
+ export type PaymentsClientProductType = "consumable" | "non_consumable" | "subscription";
157
+
158
+ /** The normalized purchase status, as a browser reads it. */
159
+ export type PaymentsClientStatus =
160
+ | "active"
161
+ | "in_grace"
162
+ | "on_hold"
163
+ | "canceled"
164
+ | "expired"
165
+ | "never_paid"
166
+ | "refunded"
167
+ | "revoked"
168
+ | "paused";
169
+
170
+ /** Which store environment a purchase happened in, as a browser reads it. */
171
+ export type PaymentsClientEnvironment = "production" | "sandbox";
172
+
173
+ /** One entitlement as `GET /payments/entitlements` returns it. */
174
+ export interface EntitlementView {
175
+ /** The entitlement key gating code names — `pro`, never a SKU. */
176
+ key: string;
177
+ /** Whether it grants access right now. The server rechecks expiry on every read. */
178
+ granted: boolean;
179
+ /** When it lapses, as an ISO string, or null for one that never does. */
180
+ expiresAt: string | null;
181
+ }
182
+
183
+ /** One purchase as the write routes return it. Deliberately not the row — a client has no use for its own receipt. */
184
+ export interface PurchaseView {
185
+ /** The purchase's id. */
186
+ id: string;
187
+ /** Which store it came from. */
188
+ rail: PaymentsClientRail;
189
+ /** The logical catalog product id. */
190
+ productId: string;
191
+ /** What kind of product it is. */
192
+ type: PaymentsClientProductType;
193
+ /** The normalized status. */
194
+ status: PaymentsClientStatus;
195
+ /** Which store environment it happened in. */
196
+ environment: PaymentsClientEnvironment;
197
+ /** When the store recorded it, as an ISO string. */
198
+ purchasedAt: string;
199
+ /** When access lapses, as an ISO string, or null. */
200
+ expiresAt: string | null;
201
+ /**
202
+ * When a paused subscription resumes, as an ISO string, or null.
203
+ *
204
+ * Null means two different things and `status` tells them apart: with `paused`, the store put no end on
205
+ * the pause; with anything else, the subscription is not paused. A screen that renders "paused until"
206
+ * has to branch on both.
207
+ */
208
+ resumesAt: string | null;
209
+ /** What this submission did to the projection. `ignored` is a replay, and a replay is a success. */
210
+ outcome: "created" | "updated" | "ignored";
211
+ }
212
+
213
+ /** A refusal a screen can render: the namespaced code, the public message, and what to do next. */
214
+ export interface PaymentsFailure {
215
+ /** The namespaced code — `payments/product_not_found`, or a `client/*` sentinel this module minted. */
216
+ code: string;
217
+ /** The public message. The server's `detail` never crosses the HTTP codec, so this is all there is. */
218
+ message: string;
219
+ /** What to do next, when the server offered one. */
220
+ action: string | null;
221
+ /**
222
+ * The values a translating client interpolates into its own wording for `code`, when the server sent
223
+ * any.
224
+ *
225
+ * **Carried because the documented contract is `t.maybe(code, params) ?? message`**, and a decoder
226
+ * that drops `params` makes the first half of it a lie: `interpolate` leaves an unsupplied
227
+ * placeholder exactly as written, so a screen renders `That board does not exist: {board}.` — worse
228
+ * than the English it was trying to improve on. No kit throw site passes `params` today, which is
229
+ * precisely why this is easy to leave out and expensive to notice later.
230
+ */
231
+ params?: Record<string, string | number | boolean>;
232
+ }
233
+
234
+ /** Either the value, or a failure to render. Never a throw. */
235
+ export type PaymentsResult<T> = { ok: true; value: T } | { ok: false; failure: PaymentsFailure };
236
+
237
+ /** What `POST /payments/purchases` answers with. */
238
+ export interface PurchaseReceipt {
239
+ /** The purchase as it now stands. */
240
+ purchase: PurchaseView;
241
+ /** The caller's entitlements after the write, so a screen needs no second round trip. */
242
+ entitlements: readonly EntitlementView[];
243
+ }
244
+
245
+ /** What `POST /payments/restore` answers with. */
246
+ export interface RestoredPurchases {
247
+ /** Every purchase the batch projected. */
248
+ purchases: readonly PurchaseView[];
249
+ /** The caller's entitlements after the restore. */
250
+ entitlements: readonly EntitlementView[];
251
+ }
252
+
253
+ /**
254
+ * The slice of `fetch` this module uses, declared structurally.
255
+ *
256
+ * Not `typeof fetch`: the package compiles against `@cloudflare/workers-types`, whose `RequestInit` has
257
+ * no `credentials` — and `credentials: "include"` is the entire cookie story. Declaring the shape keeps
258
+ * one signature true in a Worker-typed program, in a browser, and in a test that injects a stub.
259
+ */
260
+ export interface PaymentsRequestInit {
261
+ /** The HTTP method. Absent means GET. */
262
+ method?: string;
263
+ /** Request headers. */
264
+ headers?: Record<string, string>;
265
+ /** The JSON body, already serialized. */
266
+ body?: string;
267
+ /** Cookie policy. Always `include` here — same-origin, httpOnly session cookie. */
268
+ credentials?: "include" | "same-origin" | "omit";
269
+ }
270
+
271
+ /** The slice of `Response` this module reads. */
272
+ export interface PaymentsResponse {
273
+ /** Whether the status was 2xx. */
274
+ ok: boolean;
275
+ /** The HTTP status. */
276
+ status: number;
277
+ /** The parsed body, or a rejection when it was not JSON. */
278
+ json(): Promise<unknown>;
279
+ }
280
+
281
+ /** A fetch this module can call. */
282
+ export type PaymentsFetch = (input: string, init?: PaymentsRequestInit) => Promise<PaymentsResponse>;
283
+
284
+ /** Leaving the page for a hosted Stripe flow. */
285
+ export type Navigate = (url: string) => void;
286
+
287
+ /**
288
+ * The browser globals this module reaches for, as an injectable seam.
289
+ *
290
+ * Reached through an object rather than `window` because the package compiles in a program with no DOM
291
+ * lib, alongside the Worker code — the same collision `@pithy-sh/ui-react` keeps a second tsconfig to
292
+ * avoid. Injecting it is also what lets a test prove the no-browser path without a DOM.
293
+ */
294
+ export interface PaymentsGlobal {
295
+ /** The browser's fetch. */
296
+ fetch?: PaymentsFetch;
297
+ /** The browser's location — read for the return query string, called to leave for a hosted page. */
298
+ location?: { search?: string; assign?: (url: string) => void };
299
+ }
300
+
301
+ /** What every call takes: where the routes are, and the seams a test replaces. */
302
+ export interface PaymentsClientOptions {
303
+ /** Where the payments routes mount. Defaults to {@link PAYMENTS_BASE_PATH}. */
304
+ basePath?: string;
305
+ /** The fetch to use. Defaults to the one on {@link PaymentsClientOptions.global}. */
306
+ fetch?: PaymentsFetch;
307
+ /** How to leave for a hosted page. Defaults to the browser's own `location.assign`. */
308
+ navigate?: Navigate;
309
+ /** The global object the defaults come off. Defaults to `globalThis`. */
310
+ global?: PaymentsGlobal;
311
+ }
312
+
313
+ /** The worker could not be reached at all. Offline, or a DNS failure, or a hard CORS refusal. */
314
+ export const PAYMENTS_UNREACHABLE: PaymentsFailure = {
315
+ code: "client/unreachable",
316
+ message: "We couldn't reach the store.",
317
+ action: "Check your connection, then try again.",
318
+ };
319
+
320
+ /** The worker answered with something this client cannot read. A proxy's HTML page, or a shape change. */
321
+ export const PAYMENTS_UNREADABLE: PaymentsFailure = {
322
+ code: "client/unreadable",
323
+ message: "The store answered with something we couldn't read.",
324
+ action: "Try again. If it keeps happening, the app and the backend are out of step.",
325
+ };
326
+
327
+ /** There is no browser here to send anywhere — server-side rendering, or a test with no DOM. */
328
+ export const PAYMENTS_NO_BROWSER: PaymentsFailure = {
329
+ code: "client/no_browser",
330
+ message: "There's no browser to send you to the payment page.",
331
+ action: "Start checkout from the page in the browser, not before it has loaded.",
332
+ };
333
+
334
+ /** Whether a value is a plain record — the first step of every guard below. */
335
+ function isRecord(value: unknown): value is Record<string, unknown> {
336
+ return typeof value === "object" && value !== null && !Array.isArray(value);
337
+ }
338
+
339
+ /** Whether a value is a string, or explicitly null. The shape every nullable timestamp arrives in. */
340
+ function isNullableString(value: unknown): value is string | null {
341
+ return value === null || typeof value === "string";
342
+ }
343
+
344
+ /** Whether a value is one of a fixed set of strings. */
345
+ function isMember<T extends string>(value: unknown, members: readonly T[]): value is T {
346
+ return typeof value === "string" && (members as readonly string[]).includes(value);
347
+ }
348
+
349
+ const RAILS: readonly PaymentsClientRail[] = ["apple", "google", "stripe", "lemonSqueezy", "paddle"];
350
+ const PRODUCT_TYPES: readonly PaymentsClientProductType[] = ["consumable", "non_consumable", "subscription"];
351
+ const STATUSES: readonly PaymentsClientStatus[] = [
352
+ "active",
353
+ "in_grace",
354
+ "on_hold",
355
+ "canceled",
356
+ "expired",
357
+ "never_paid",
358
+ "refunded",
359
+ "revoked",
360
+ "paused",
361
+ ];
362
+ const ENVIRONMENTS: readonly PaymentsClientEnvironment[] = ["production", "sandbox"];
363
+ const OUTCOMES: readonly PurchaseView["outcome"][] = ["created", "updated", "ignored"];
364
+
365
+ /** Whether a value is one entitlement as the route returns it. */
366
+ export function isEntitlementView(value: unknown): value is EntitlementView {
367
+ return (
368
+ isRecord(value) &&
369
+ typeof value.key === "string" &&
370
+ typeof value.granted === "boolean" &&
371
+ isNullableString(value.expiresAt)
372
+ );
373
+ }
374
+
375
+ /** Whether a value is one purchase as the write routes return it. */
376
+ export function isPurchaseView(value: unknown): value is PurchaseView {
377
+ return (
378
+ isRecord(value) &&
379
+ typeof value.id === "string" &&
380
+ isMember(value.rail, RAILS) &&
381
+ typeof value.productId === "string" &&
382
+ isMember(value.type, PRODUCT_TYPES) &&
383
+ isMember(value.status, STATUSES) &&
384
+ isMember(value.environment, ENVIRONMENTS) &&
385
+ typeof value.purchasedAt === "string" &&
386
+ isNullableString(value.expiresAt) &&
387
+ isMember(value.outcome, OUTCOMES)
388
+ );
389
+ }
390
+
391
+ /** Every element, or nothing. A half-read list is worse than none — a screen renders the gap as absence. */
392
+ function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] {
393
+ return Array.isArray(value) && value.every(guard);
394
+ }
395
+
396
+ /**
397
+ * Whether a string is a URL this client may navigate to.
398
+ *
399
+ * The scheme check is the point. Such a URL is handed straight to `location.assign`, and
400
+ * `javascript:` there executes in the current page — the same guard the scaffolded sign-in screen makes
401
+ * on a social redirect, made here because here is where the value is read.
402
+ */
403
+ function isNavigable(value: unknown): value is string {
404
+ if (typeof value !== "string") return false;
405
+ try {
406
+ const { protocol } = new URL(value);
407
+ return protocol === "https:" || protocol === "http:";
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Whether a value is a checkout handoff this client can act on.
415
+ *
416
+ * Two shapes, and each is checked for what acting on it would do. A `redirect` is navigated to, so its
417
+ * URL goes through {@link isNavigable}. A `paddle` handoff is passed to `Paddle.Checkout.open`, so every
418
+ * field it carries has to be a string of the right kind — an environment or a display mode this client
419
+ * does not know is a server it does not understand, and guessing would open a checkout in the wrong
420
+ * Paddle account.
421
+ */
422
+ function isCheckoutHandoff(value: unknown): value is PaymentsCheckoutHandoff {
423
+ if (!isRecord(value)) return false;
424
+ if (value.kind === "redirect") return isNavigable(value.url);
425
+ if (value.kind !== "paddle") return false;
426
+ return (
427
+ isNonEmpty(value.transactionId) &&
428
+ isNonEmpty(value.clientToken) &&
429
+ isMember(value.environment, PADDLE_ENVIRONMENTS) &&
430
+ isMember(value.displayMode, PADDLE_DISPLAY_MODES) &&
431
+ // The same check a redirect's URL gets, and for the same reason: Paddle sends the browser here when
432
+ // the card clears, so `javascript:` in it would run in this page with a purchase behind it.
433
+ isNavigable(value.successUrl)
434
+ );
435
+ }
436
+
437
+ /** Whether a value is a portal handoff: an overview page, and any per-subscription deep links. */
438
+ function isPortalHandoff(value: unknown): value is PaymentsPortalHandoff {
439
+ if (!isRecord(value) || !isNavigable(value.url)) return false;
440
+ if (value.subscriptions === undefined) return true;
441
+ return isArrayOf(value.subscriptions, isPortalSubscription);
442
+ }
443
+
444
+ /** Whether a value is one subscription's deep links. Every URL is navigated to, so every URL is checked. */
445
+ function isPortalSubscription(value: unknown): value is PaymentsPortalSubscription {
446
+ return (
447
+ isRecord(value) &&
448
+ isNonEmpty(value.subscriptionId) &&
449
+ isNavigable(value.cancel) &&
450
+ isNavigable(value.updatePaymentMethod)
451
+ );
452
+ }
453
+
454
+ /** A non-empty string. */
455
+ function isNonEmpty(value: unknown): value is string {
456
+ return typeof value === "string" && value !== "";
457
+ }
458
+
459
+ /** The globals to use — the injected ones, else the real ones. */
460
+ function globals(options: PaymentsClientOptions | undefined): PaymentsGlobal {
461
+ return options?.global ?? (globalThis as PaymentsGlobal);
462
+ }
463
+
464
+ /** A failure read off `{ error: { code, message, action } }`, or the generic one when the body is not that. */
465
+ function readFailure(body: unknown): PaymentsFailure {
466
+ if (!isRecord(body) || !isRecord(body.error)) return PAYMENTS_UNREADABLE;
467
+ const { code, message, action, params } = body.error;
468
+ if (typeof code !== "string" || typeof message !== "string") return PAYMENTS_UNREADABLE;
469
+ // `params` is scalars keyed by placeholder name on the wire. Anything else is dropped rather than
470
+ // passed on: this module is Zod-free by design, so the check is the shape a caller depends on and
471
+ // nothing more.
472
+ const scalars = isRecord(params)
473
+ ? Object.fromEntries(
474
+ Object.entries(params).filter(
475
+ (entry): entry is [string, string | number | boolean] =>
476
+ typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
477
+ ),
478
+ )
479
+ : undefined;
480
+ return {
481
+ code,
482
+ message,
483
+ action: typeof action === "string" ? action : null,
484
+ ...(scalars && Object.keys(scalars).length > 0 ? { params: scalars } : {}),
485
+ };
486
+ }
487
+
488
+ /**
489
+ * One call: same-origin, cookie-carrying, never throwing. **The only producer of that request.**
490
+ *
491
+ * The body is read before the status is judged, because a refusal's body is the failure a screen renders.
492
+ * A body that will not parse is the generic failure rather than a crash — a corporate proxy's HTML page
493
+ * reaches a browser far more often than anyone expects.
494
+ *
495
+ * **Exported, and that is the point of it.** This was private, so `../pricing/visitor.ts` wrote a second
496
+ * fetch with the same base-path default, the same `credentials: "include"`, and the same three failure
497
+ * directions — two producers of one rule, which is the shape of every recurring defect this kit has
498
+ * shipped. The second producer is always the cheap one to add and the one that drifts. Every browser
499
+ * program that asks this Worker a question calls this, and `sameOrigin.test.ts` fails the build on any
500
+ * module that grows its own (#346).
501
+ *
502
+ * **Zod-free, like everything on this side of the wire.** The answer is narrowed by the hand-written
503
+ * `guard` a caller passes, never by a schema: this compiles into an adopter's browser bundle, and
504
+ * dragging the Worker's schema graph in behind it would break their build.
505
+ */
506
+ export async function callPayments<T>(
507
+ path: string,
508
+ init: PaymentsRequestInit,
509
+ options: PaymentsClientOptions | undefined,
510
+ guard: (value: unknown) => value is T,
511
+ ): Promise<PaymentsResult<T>> {
512
+ const fetcher = options?.fetch ?? globals(options).fetch;
513
+ if (!fetcher) return { ok: false, failure: PAYMENTS_UNREACHABLE };
514
+
515
+ const base = options?.basePath ?? PAYMENTS_BASE_PATH;
516
+ let response: PaymentsResponse;
517
+ try {
518
+ response = await fetcher(`${base}${path}`, { ...init, credentials: "include" });
519
+ } catch {
520
+ return { ok: false, failure: PAYMENTS_UNREACHABLE };
521
+ }
522
+
523
+ let body: unknown;
524
+ try {
525
+ body = await response.json();
526
+ } catch {
527
+ // Success or refusal, an unparseable body says the same thing: whatever answered was not this Worker.
528
+ return { ok: false, failure: PAYMENTS_UNREADABLE };
529
+ }
530
+
531
+ if (!response.ok) return { ok: false, failure: readFailure(body) };
532
+ return guard(body) ? { ok: true, value: body } : { ok: false, failure: PAYMENTS_UNREADABLE };
533
+ }
534
+
535
+ /** A JSON POST body, serialized once so the request signature stays one shape. */
536
+ function jsonPost(body: unknown): PaymentsRequestInit {
537
+ return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
538
+ }
539
+
540
+ /** Whether a value is the entitlements envelope. */
541
+ function isEntitlementsBody(value: unknown): value is { entitlements: EntitlementView[] } {
542
+ return isRecord(value) && isArrayOf(value.entitlements, isEntitlementView);
543
+ }
544
+
545
+ /** Whether a value is the purchase envelope both write routes' single-purchase form returns. */
546
+ function isPurchaseBody(value: unknown): value is { purchase: PurchaseView; entitlements: EntitlementView[] } {
547
+ return isRecord(value) && isPurchaseView(value.purchase) && isArrayOf(value.entitlements, isEntitlementView);
548
+ }
549
+
550
+ /** Whether a value is the restore envelope. */
551
+ function isRestoreBody(value: unknown): value is { purchases: PurchaseView[]; entitlements: EntitlementView[] } {
552
+ return (
553
+ isRecord(value) && isArrayOf(value.purchases, isPurchaseView) && isArrayOf(value.entitlements, isEntitlementView)
554
+ );
555
+ }
556
+
557
+ /**
558
+ * The caller's own entitlements. Always the caller's — the id comes from the session, never from here.
559
+ *
560
+ * **A failure is not an empty list**, and the distinction is the whole point of the return type. Failing
561
+ * shut is still the right default for a *lock*, because a lock is a refusal and the caller can carry an
562
+ * escape route on it. It is wrong for a caller that **names** the plan: free is the floor of every ladder,
563
+ * carrying no entitlement key and matching unconditionally, so `[]` is a positive assertion that this
564
+ * customer is on the cheapest tier. A screen rendering that from a failed read tells an Enterprise
565
+ * customer they are on Free and offers to sell them what they already pay for.
566
+ *
567
+ * So the failure `call` already built travels out intact, and a caller that wants fail-shut writes
568
+ * `result.ok ? result.value : []` — one line, and it reads as the decision it is rather than as the
569
+ * absence of one. {@link useEntitlement} does exactly that; {@link useSubscription} reports the failure.
570
+ */
571
+ export async function getEntitlements(
572
+ options?: PaymentsClientOptions,
573
+ ): Promise<PaymentsResult<readonly EntitlementView[]>> {
574
+ const result = await callPayments("/entitlements", {}, options, isEntitlementsBody);
575
+ return result.ok ? { ok: true, value: result.value.entitlements } : result;
576
+ }
577
+
578
+ /**
579
+ * Submit one receipt for verification, so the buyer sees their entitlement without waiting for the webhook.
580
+ *
581
+ * On the web this is the Stripe return path: `successUrl` carries the Checkout Session id, and posting it
582
+ * here projects the purchase at once. On a native client it is the store SDK's transaction. A replay by
583
+ * its own owner is a 200 with `outcome: "ignored"` — the write path is idempotent, so repeating is free.
584
+ */
585
+ export function submitPurchase(
586
+ input: { rail: PaymentsClientRail; receipt: string },
587
+ options?: PaymentsClientOptions,
588
+ ): Promise<PaymentsResult<PurchaseReceipt>> {
589
+ return callPayments("/purchases", jsonPost(input), options, isPurchaseBody);
590
+ }
591
+
592
+ /**
593
+ * Restore Purchases — rebind a store account's history to the signed-in user.
594
+ *
595
+ * Native only in practice: only the device can enumerate what its store account owns. One bad receipt
596
+ * fails the whole batch by design, and every receipt that did project stays projected.
597
+ */
598
+ export function restorePurchases(
599
+ input: { rail: PaymentsClientRail; receipts: readonly string[] },
600
+ options?: PaymentsClientOptions,
601
+ ): Promise<PaymentsResult<RestoredPurchases>> {
602
+ return callPayments("/restore", jsonPost(input), options, isRestoreBody);
603
+ }
604
+
605
+ /** Create a checkout for a product, and say how the browser reaches it. */
606
+ export function createCheckout(
607
+ input: { productId: string; rail?: PaymentsHostedRail; discountCode?: string },
608
+ options?: PaymentsClientOptions,
609
+ ): Promise<PaymentsResult<PaymentsCheckoutHandoff>> {
610
+ return callPayments("/checkout", jsonPost(input), options, isCheckoutHandoff);
611
+ }
612
+
613
+ /**
614
+ * Create a Billing Portal session for the caller's own store account.
615
+ *
616
+ * No body, and that is the request contract: there is exactly one customer this caller may manage, and
617
+ * the server resolves it from the provider-account map. A `customer` field here would be the whole
618
+ * vulnerability.
619
+ */
620
+ export function createPortal(options?: PaymentsClientOptions): Promise<PaymentsResult<PaymentsPortalHandoff>> {
621
+ return callPayments("/portal", { method: "POST" }, options, isPortalHandoff);
622
+ }
623
+
624
+ /** How to leave the page, from the injected navigator or the browser's own. */
625
+ function navigator(options: PaymentsClientOptions | undefined): Navigate | undefined {
626
+ if (options?.navigate) return options.navigate;
627
+ const location = globals(options).location;
628
+ return location?.assign ? (url) => location.assign?.(url) : undefined;
629
+ }
630
+
631
+ /** Go to a URL a guard has already vetted. Null means the browser left; anything else is a failure. */
632
+ function leaveFor(url: string, options: PaymentsClientOptions | undefined): PaymentsFailure | null {
633
+ const go = navigator(options);
634
+ if (!go) return PAYMENTS_NO_BROWSER;
635
+ go(url);
636
+ return null;
637
+ }
638
+
639
+ /**
640
+ * What starting a checkout did. Three outcomes, and a screen must tell them apart.
641
+ *
642
+ * `left` is a redirect rail: the browser is already going, and there is nothing to render. `paddle` is a
643
+ * rail with nowhere to go — the screen opens the handoff with Paddle.js, over its own page. `refused` is
644
+ * a failure worth showing.
645
+ *
646
+ * Deliberately not `PaymentsFailure | null` any more. That shape could only say "went" or "did not", and
647
+ * a Paddle handoff is neither: collapsing it into `null` would leave a buyer on a paywall whose button
648
+ * silently did nothing.
649
+ */
650
+ export type PaymentsCheckoutStart =
651
+ | { kind: "left" }
652
+ | { kind: "paddle"; handoff: PaddleCheckoutHandoff }
653
+ | { kind: "refused"; failure: PaymentsFailure };
654
+
655
+ /**
656
+ * Buy a product: create the checkout, and either hand the browser over or hand the caller the handoff.
657
+ *
658
+ * A redirect rail needs no SDK script and no client-side key — the server mints a session and answers
659
+ * with a URL, and a full page load is the whole integration. Paddle's overlay and inline modes need
660
+ * Paddle.js and the publishable token the server returned, and opening it is the screen's job because
661
+ * that is where the container lives. Pithy never owns payment UI, SCA, tax, or proration either way.
662
+ */
663
+ export async function startCheckout(
664
+ input: { productId: string; rail?: PaymentsHostedRail; discountCode?: string },
665
+ options?: PaymentsClientOptions,
666
+ ): Promise<PaymentsCheckoutStart> {
667
+ const result = await createCheckout(input, options);
668
+ if (!result.ok) return { kind: "refused", failure: result.failure };
669
+ if (result.value.kind === "paddle") return { kind: "paddle", handoff: result.value };
670
+ const refused = leaveFor(result.value.url, options);
671
+ return refused === null ? { kind: "left" } : { kind: "refused", failure: refused };
672
+ }
673
+
674
+ /** Open the billing portal — where a subscriber changes a card, or cancels, under the store's own rules. */
675
+ export async function openBillingPortal(options?: PaymentsClientOptions): Promise<PaymentsFailure | null> {
676
+ const result = await createPortal(options);
677
+ return result.ok ? leaveFor(result.value.url, options) : result.failure;
678
+ }
679
+
680
+ /**
681
+ * Where a store sends a subscriber to manage a subscription they bought in an app.
682
+ *
683
+ * These live in the package rather than in a scaffolded screen for the reason the whole client surface
684
+ * does: they are Apple's and Google's URLs, not the adopter's, and when a store moves one the fix should
685
+ * be a minor release rather than an edit to a file Pithy may never rewrite. It is also what keeps the
686
+ * scaffolded templates free of absolute URLs, which the CLI's own scaffold check enforces.
687
+ *
688
+ * There is no Stripe entry. Stripe's equivalent is a *session* the server mints per caller — see
689
+ * {@link openBillingPortal} — and a static URL there would be a link to nobody's account.
690
+ */
691
+ export const STORE_SUBSCRIPTION_URLS: Readonly<Record<"apple" | "google", string>> = {
692
+ apple: "https://apps.apple.com/account/subscriptions",
693
+ google: "https://play.google.com/store/account/subscriptions",
694
+ };
695
+
696
+ /**
697
+ * Send the browser to a store's own subscription-management page.
698
+ *
699
+ * Returns false only when there is no browser to send. A web page cannot cancel a StoreKit or Play
700
+ * Billing subscription — the store owns that, by its own rules — so linking there is the whole of what a
701
+ * web screen can honestly offer.
702
+ */
703
+ export function openStoreSubscriptions(rail: "apple" | "google", options?: PaymentsClientOptions): boolean {
704
+ const go = navigator(options);
705
+ if (!go) return false;
706
+ go(STORE_SUBSCRIPTION_URLS[rail]);
707
+ return true;
708
+ }
709
+
710
+ /** What {@link returnedCheckoutSession} reads, and where from. */
711
+ export interface CheckoutReturnOptions {
712
+ /** The query string to read. Defaults to the browser's own `location.search`. */
713
+ search?: string;
714
+ /** The parameter carrying the session id. Defaults to {@link CHECKOUT_SESSION_PARAM}. */
715
+ param?: string;
716
+ /** The global object `location` comes off. Defaults to `globalThis`. */
717
+ global?: PaymentsGlobal;
718
+ }
719
+
720
+ /**
721
+ * The Checkout Session id a returning buyer arrived with, or null.
722
+ *
723
+ * This is the second half of the redirect-and-return dance. Stripe substitutes the real id into the
724
+ * `{CHECKOUT_SESSION_ID}` token in the adopter's `successUrl`, and posting it to `/payments/purchases`
725
+ * projects the purchase immediately instead of waiting for the webhook. The webhook is still
726
+ * authoritative and still arrives — this only decides whether the buyer sees their entitlement now or in
727
+ * a few seconds.
728
+ */
729
+ export function returnedCheckoutSession(options?: CheckoutReturnOptions): string | null {
730
+ const search = options?.search ?? (options?.global ?? (globalThis as PaymentsGlobal)).location?.search ?? "";
731
+ if (search === "") return null;
732
+ const value = new URLSearchParams(search).get(options?.param ?? CHECKOUT_SESSION_PARAM);
733
+ return value === null || value === "" ? null : value;
734
+ }