@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,598 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { pauseResumesAt } from "../../data/pause";
6
+ import type { PaymentsPurchase, PurchaseEnvironment } from "../../data/purchase";
7
+ import type { PurchaseStatus } from "../../data/status";
8
+ import {
9
+ PaymentsProviderUnavailableError,
10
+ PaymentsRailNotConfiguredError,
11
+ PaymentsVerificationFailedError,
12
+ } from "../../error/errors";
13
+ import type { PaymentsGoogleCredentials } from "../../secret/registry";
14
+ import type { UnboundProviderEvent } from "../contract";
15
+ import { type GoogleHttpFetch, googleHttpFetch, googleJson } from "./http";
16
+ import { base64UrlEncode, pemPrivateKey } from "./jwt";
17
+ import type { GoogleNotificationPointer } from "./rtdn";
18
+
19
+ /**
20
+ * The Play Developer API — the call that turns a purchase token into a state, and the mapping of Play's
21
+ * vocabulary into the normalized status set.
22
+ *
23
+ * This is the half of Google's rail that has no Apple equivalent. A StoreKit transaction *is* the state, signed;
24
+ * a Play purchase token is a pointer, so every Google projection costs a network round-trip. Two, in fact — the
25
+ * API is OAuth-protected, so a service-account assertion buys an access token first.
26
+ *
27
+ * ## Two statuses Play has and the other rails do not
28
+ *
29
+ * `on_hold` and `paused` are Play's own. **On hold** is a subscription whose billing retry window has run out:
30
+ * Google keeps trying for up to 30 days and access is withdrawn throughout, which is why `on_hold` never
31
+ * grants. **Paused** is a subscription the *user* suspended, usually with an `autoResumeTime` set; they will
32
+ * come back, and until they do they are not entitled — so that date lands on the row as `resumesAt`, and its
33
+ * absence is Play reporting an indefinite pause rather than this rail declining to look (`data/pause.ts`).
34
+ * Neither state has an Apple analog, and both would be lost by a status set built from Apple's
35
+ * notifications alone.
36
+ *
37
+ * ## What Play does not report, and what follows
38
+ *
39
+ * **No price.** Neither `purchases.subscriptionsv2.get` nor `purchases.products.get` returns an amount or a
40
+ * currency, so `amountMinor` and `currency` are null on every Google purchase. That is what those columns are
41
+ * nullable for. Revenue figures for the Play rail come from Google's own reporting, not from here.
42
+ *
43
+ * **No last-modified time.** So the caller supplies the event time: the notification's `eventTimeMillis` on the
44
+ * webhook path, and the clock on a client submission. Both are honest — a submission is a read of the current
45
+ * state, so treating it as the freshest fact is right — and both feed the monotonic write rule, which is why
46
+ * neither may be invented here.
47
+ *
48
+ * ## The environment check
49
+ *
50
+ * A test purchase is a real Play purchase made by a license-tested account, and it must never grant a
51
+ * production entitlement. Play marks one differently in each shape: `testPurchase` is present on a
52
+ * subscription, `purchaseType: 0` on a one-time product. Both are read, and anything unmarked is production —
53
+ * which is safe because Play only ever omits the marker on a real purchase.
54
+ */
55
+
56
+ /** Google's OAuth token endpoint. The audience of the service-account assertion as well as its destination. */
57
+ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
58
+
59
+ /** The Play Developer API's base. Public, so it is pinned here rather than configured. */
60
+ export const PLAY_API_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3";
61
+
62
+ /** The one OAuth scope payments asks for. Read-and-acknowledge on purchases, nothing else in the console. */
63
+ export const PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
64
+
65
+ /** RSASSA-PKCS1-v1_5 with SHA-256 — `RS256`, the algorithm a Google service-account assertion is signed with. */
66
+ const RS256 = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
67
+
68
+ /** How long an assertion claims to be good for. Google's ceiling is an hour and there is no reason to ask less. */
69
+ const ASSERTION_LIFETIME_SECONDS = 3600;
70
+
71
+ /** Play's `purchaseType` for a license-test purchase. The one-time equivalent of `testPurchase`. */
72
+ const PLAY_PURCHASE_TYPE_TEST = 0;
73
+
74
+ /**
75
+ * Play's subscription states, mapped.
76
+ *
77
+ * Two are worth reading twice. **`CANCELED` still grants**: the user turned auto-renew off and keeps the period
78
+ * they paid for, which `expiresAt` ends — mapping it to anything non-granting would strip access from somebody
79
+ * who is still paid up. And **`PENDING` does not**: a deferred-payment purchase (cash, direct debit) has not
80
+ * been paid yet, so it is treated as `on_hold` — not `expired`, because it may still become active.
81
+ *
82
+ * `PENDING_PURCHASE_CANCELED` is `never_paid`: the deferred payment never arrived and never will. Deliberately
83
+ * not `canceled`, which grants; not `refunded`, because no money moved; and **not `expired`**, because an
84
+ * expired period is one we were paid for and a `grants` clause credits on it.
85
+ */
86
+ const SUBSCRIPTION_STATES: Readonly<Record<string, PurchaseStatus>> = {
87
+ SUBSCRIPTION_STATE_ACTIVE: "active",
88
+ SUBSCRIPTION_STATE_IN_GRACE_PERIOD: "in_grace",
89
+ SUBSCRIPTION_STATE_ON_HOLD: "on_hold",
90
+ SUBSCRIPTION_STATE_PAUSED: "paused",
91
+ SUBSCRIPTION_STATE_CANCELED: "canceled",
92
+ SUBSCRIPTION_STATE_EXPIRED: "expired",
93
+ SUBSCRIPTION_STATE_PENDING: "on_hold",
94
+ SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED: "never_paid",
95
+ };
96
+
97
+ /**
98
+ * Play's one-time purchase states, mapped.
99
+ *
100
+ * `1` is Play's `Canceled`, and it is the trap in this table. It must **not** become the normalized `canceled`,
101
+ * which means "auto-renew off, still granting" — Play means the order was taken back. It is mapped to
102
+ * `refunded`, which is the normalized member for "taken back", does not grant, and is the state an opt-in
103
+ * clawback acts on. Play cannot distinguish a refunded order from a deferred purchase that was canceled before
104
+ * payment, so a clawback on the latter finds nothing to debit and is refused into a recorded state — which is
105
+ * the designed behavior for a failed clawback rather than a defect.
106
+ *
107
+ * `2` is `Pending`: awaiting a deferred payment, so it does not grant, and `on_hold` is the member that says so
108
+ * without claiming the purchase is over.
109
+ */
110
+ const PRODUCT_STATES: Readonly<Record<number, PurchaseStatus>> = { 0: "active", 1: "refunded", 2: "on_hold" };
111
+
112
+ /** Google's OAuth token response, narrowed to the one field that matters. */
113
+ const GoogleAccessToken = z
114
+ .object({
115
+ access_token: z.string().min(1).describe("The bearer token the Play Developer API is called with."),
116
+ expires_in: z.number().int().optional().describe("How long it lasts, in seconds. Informational here."),
117
+ token_type: z.string().min(1).optional().describe("Always `Bearer` in practice."),
118
+ })
119
+ .loose()
120
+ .describe("Google's OAuth 2.0 token response to a service-account assertion.");
121
+
122
+ /** One line item on a subscription — the product, and when its paid period ends. */
123
+ const PlaySubscriptionLineItem = z
124
+ .object({
125
+ productId: z
126
+ .string()
127
+ .min(1)
128
+ .describe("The subscription product id as listed in the Play Console — the SKU the catalog maps."),
129
+ expiryTime: z
130
+ .string()
131
+ .min(1)
132
+ .optional()
133
+ .describe("When the paid period ends, RFC 3339. Absent on a paused or never-paid subscription."),
134
+ autoRenewingPlan: z
135
+ .object({
136
+ autoRenewEnabled: z.boolean().optional().describe("Whether the plan will renew. Reported, never mapped."),
137
+ })
138
+ .loose()
139
+ .optional()
140
+ .describe("The auto-renewing plan's own state, when the line item is one."),
141
+ })
142
+ .loose()
143
+ .describe("One line item of a Play subscription purchase.");
144
+
145
+ /**
146
+ * `purchases.subscriptionsv2.get`, as much of it as the projection reads. `.loose()` keeps the rest: Play adds
147
+ * fields, the whole response is stored for reconciliation, and a strict object would refuse a real purchase for
148
+ * carrying something new.
149
+ */
150
+ export const PlaySubscriptionPurchase = z
151
+ .object({
152
+ subscriptionState: z
153
+ .string()
154
+ .min(1)
155
+ .describe("Play's own state for the subscription. Mapped to a normalized status; an unknown one is refused."),
156
+ latestOrderId: z
157
+ .string()
158
+ .min(1)
159
+ .optional()
160
+ .describe(
161
+ "The most recent order. This is the transaction identity — it changes every renewal, which is what makes each period its own purchase row.",
162
+ ),
163
+ startTime: z.string().min(1).optional().describe("When the subscription began, RFC 3339."),
164
+ linkedPurchaseToken: z
165
+ .string()
166
+ .min(1)
167
+ .optional()
168
+ .describe("The token this purchase replaced, on an upgrade or a resubscribe."),
169
+ acknowledgementState: z
170
+ .string()
171
+ .min(1)
172
+ .optional()
173
+ .describe(
174
+ "Whether the app has acknowledged the purchase. Recorded, never mapped: an unacknowledged purchase is still paid, and acknowledging is the app's job through Play Billing.",
175
+ ),
176
+ testPurchase: z
177
+ .object({})
178
+ .loose()
179
+ .optional()
180
+ .describe(
181
+ "Present only for a license-test purchase. Its presence is the whole environment decision for a subscription.",
182
+ ),
183
+ pausedStateContext: z
184
+ .object({
185
+ autoResumeTime: z.string().min(1).optional().describe("When a paused subscription resumes, RFC 3339."),
186
+ })
187
+ .loose()
188
+ .optional()
189
+ .describe("Present while the subscription is paused."),
190
+ externalAccountIdentifiers: z
191
+ .object({
192
+ obfuscatedExternalAccountId: z
193
+ .string()
194
+ .min(1)
195
+ .optional()
196
+ .describe("The identifier the app set at purchase time — the only hook back to the subject that bought."),
197
+ obfuscatedExternalProfileId: z
198
+ .string()
199
+ .min(1)
200
+ .optional()
201
+ .describe("The profile identifier, if the app set one."),
202
+ })
203
+ .loose()
204
+ .optional()
205
+ .describe("The identifiers the app attached to the purchase."),
206
+ lineItems: z
207
+ .array(PlaySubscriptionLineItem)
208
+ .min(1)
209
+ .describe("What the subscription includes. The first line item is the product this purchase is projected as."),
210
+ })
211
+ .loose()
212
+ .describe("Play's SubscriptionPurchaseV2, as much of it as the projection reads.");
213
+ export type PlaySubscriptionPurchase = z.infer<typeof PlaySubscriptionPurchase>;
214
+
215
+ /** `purchases.products.get`, as much of it as the projection reads. `.loose()` for the same reason. */
216
+ export const PlayProductPurchase = z
217
+ .object({
218
+ purchaseState: z
219
+ .number()
220
+ .int()
221
+ .describe("0 purchased, 1 canceled, 2 pending. Mapped to a normalized status; an unknown one is refused."),
222
+ purchaseTimeMillis: z
223
+ .string()
224
+ .regex(/^\d+$/)
225
+ .optional()
226
+ .describe("When the purchase was made, as a decimal string of milliseconds since the epoch."),
227
+ orderId: z
228
+ .string()
229
+ .min(1)
230
+ .optional()
231
+ .describe("The order. This is the transaction identity for a one-time purchase. Absent until it is paid."),
232
+ purchaseType: z
233
+ .number()
234
+ .int()
235
+ .optional()
236
+ .describe(
237
+ "0 test, 1 promo, 2 rewarded — and absent for an ordinary paid purchase. Only 0 makes it a sandbox purchase.",
238
+ ),
239
+ acknowledgementState: z
240
+ .number()
241
+ .int()
242
+ .optional()
243
+ .describe("0 unacknowledged, 1 acknowledged. Recorded, never mapped — the app owns acknowledging."),
244
+ consumptionState: z.number().int().optional().describe("0 yet to be consumed, 1 consumed. Recorded only."),
245
+ quantity: z.number().int().optional().describe("How many were bought. One unless the product allows more."),
246
+ obfuscatedExternalAccountId: z
247
+ .string()
248
+ .min(1)
249
+ .optional()
250
+ .describe("The identifier the app set at purchase time — the only hook back to the subject that bought."),
251
+ regionCode: z.string().min(1).optional().describe("Where the purchase was made. Recorded only."),
252
+ })
253
+ .loose()
254
+ .describe("Play's ProductPurchase, as much of it as the projection reads.");
255
+ export type PlayProductPurchase = z.infer<typeof PlayProductPurchase>;
256
+
257
+ /** What a Play lookup needs: the credentials, a clock, and the transport. */
258
+ export interface PlayApiOptions {
259
+ /** Google's credential block. The package name is part of every URL; the key signs the assertion. */
260
+ credentials: PaymentsGoogleCredentials;
261
+ /** The clock. Injected so an assertion's `iat` is deterministic in tests. */
262
+ now: Date;
263
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
264
+ transport?: GoogleHttpFetch;
265
+ /**
266
+ * An access token already minted, so a batch of lookups pays for one.
267
+ *
268
+ * Deliberately a parameter rather than a module-level cache: an access token is a bearer credential derived
269
+ * from a secret, and CLAUDE.md is explicit that a resolved secret is never cached in a module variable. So a
270
+ * single webhook mints one and drops it, and the reconciliation Workflow — which is the caller that makes
271
+ * hundreds of calls — mints one and passes it down.
272
+ */
273
+ accessToken?: string;
274
+ }
275
+
276
+ /** What one lookup resolved: the normalized transaction, and the store account to link it by. */
277
+ export interface PlayPurchaseState {
278
+ /** The transaction, normalized. The route binds the owner onto it. */
279
+ event: UnboundProviderEvent;
280
+ /** The identifier the app attached at purchase time, or null. */
281
+ providerAccountId: string | null;
282
+ }
283
+
284
+ /**
285
+ * Mint an access token for the Play Developer API from the service-account key.
286
+ *
287
+ * The JWT-bearer grant: we sign an assertion saying who we are and what we want, Google hands back a token. No
288
+ * refresh token, no user consent, nothing stored — which is why the credential bundle holds a private key
289
+ * rather than an OAuth client.
290
+ */
291
+ export async function mintPlayAccessToken(
292
+ credentials: PaymentsGoogleCredentials,
293
+ options: { now: Date; transport?: GoogleHttpFetch },
294
+ ): Promise<string> {
295
+ let key: CryptoKey;
296
+ try {
297
+ key = await crypto.subtle.importKey(
298
+ "pkcs8",
299
+ pemPrivateKey(credentials.privateKey) as unknown as ArrayBuffer,
300
+ RS256,
301
+ false,
302
+ ["sign"],
303
+ );
304
+ } catch (cause) {
305
+ if (cause instanceof PaymentsRailNotConfiguredError) throw cause;
306
+ // A key that will not import is a provisioning failure, not a store outage, and it will not fix itself.
307
+ throw new PaymentsRailNotConfiguredError(
308
+ { detail: "Google: the service-account private key could not be imported as a PKCS#8 RSA key." },
309
+ { cause },
310
+ );
311
+ }
312
+
313
+ const issuedAt = Math.floor(options.now.getTime() / 1000);
314
+ const encoder = new TextEncoder();
315
+ const head = base64UrlEncode(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT" })));
316
+ const body = base64UrlEncode(
317
+ encoder.encode(
318
+ JSON.stringify({
319
+ iss: credentials.serviceAccountEmail,
320
+ scope: PLAY_SCOPE,
321
+ aud: GOOGLE_TOKEN_URL,
322
+ iat: issuedAt,
323
+ exp: issuedAt + ASSERTION_LIFETIME_SECONDS,
324
+ }),
325
+ ),
326
+ );
327
+ const signature = new Uint8Array(
328
+ await crypto.subtle.sign(RS256.name, key, encoder.encode(`${head}.${body}`) as unknown as ArrayBuffer),
329
+ );
330
+ const assertion = `${head}.${body}.${base64UrlEncode(signature)}`;
331
+
332
+ const answered = await googleJson(options.transport ?? googleHttpFetch, GOOGLE_TOKEN_URL, {
333
+ what: "an access token for the Play Developer API",
334
+ method: "POST",
335
+ headers: { "content-type": "application/x-www-form-urlencoded" },
336
+ body: new URLSearchParams({
337
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
338
+ assertion,
339
+ }).toString(),
340
+ });
341
+
342
+ const parsed = GoogleAccessToken.safeParse(answered);
343
+ if (!parsed.success) {
344
+ throw new PaymentsProviderUnavailableError({
345
+ detail: "Google: the token endpoint answered without an access token.",
346
+ });
347
+ }
348
+ return parsed.data.access_token;
349
+ }
350
+
351
+ /** The access token for one lookup: the caller's, or a freshly minted one. */
352
+ async function bearer(options: PlayApiOptions): Promise<string> {
353
+ return options.accessToken ?? (await mintPlayAccessToken(options.credentials, options));
354
+ }
355
+
356
+ /**
357
+ * The current state of a subscription purchase token, or `undefined` when Play has no subscription under it.
358
+ *
359
+ * `undefined` is how a one-time purchase identifies itself: Play has no "what kind of purchase is this" call, so
360
+ * the subscription endpoint's 404 is the answer. That is why this is the probe and the product lookup is the
361
+ * fallback — the subscription call takes a token alone, while the product call needs a product id we would
362
+ * otherwise have to take on a client's word.
363
+ */
364
+ export async function fetchPlaySubscription(
365
+ purchaseToken: string,
366
+ options: PlayApiOptions,
367
+ ): Promise<PlaySubscriptionPurchase | undefined> {
368
+ const url = `${PLAY_API_BASE}/applications/${encodeURIComponent(options.credentials.packageName)}/purchases/subscriptionsv2/tokens/${encodeURIComponent(purchaseToken)}`;
369
+ const answered = await googleJson(options.transport ?? googleHttpFetch, url, {
370
+ what: "the subscription purchase",
371
+ headers: { authorization: `Bearer ${await bearer(options)}` },
372
+ absentOn404: true,
373
+ });
374
+ if (answered === undefined) return undefined;
375
+ return parseOrRefuse(PlaySubscriptionPurchase, answered, "SubscriptionPurchaseV2");
376
+ }
377
+
378
+ /**
379
+ * The current state of a one-time purchase token under one product id, or `undefined` when Play has none.
380
+ *
381
+ * The product id is part of the URL, which is what makes a client-declared one safe to use: Play answers 404
382
+ * when the token does not belong to that product, so presenting a cheap token under an expensive SKU fails at
383
+ * Google rather than being believed here.
384
+ */
385
+ export async function fetchPlayProduct(
386
+ productId: string,
387
+ purchaseToken: string,
388
+ options: PlayApiOptions,
389
+ ): Promise<PlayProductPurchase | undefined> {
390
+ const url = `${PLAY_API_BASE}/applications/${encodeURIComponent(options.credentials.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}`;
391
+ const answered = await googleJson(options.transport ?? googleHttpFetch, url, {
392
+ what: "the one-time purchase",
393
+ headers: { authorization: `Bearer ${await bearer(options)}` },
394
+ absentOn404: true,
395
+ });
396
+ if (answered === undefined) return undefined;
397
+ return parseOrRefuse(PlayProductPurchase, answered, "ProductPurchase");
398
+ }
399
+
400
+ /** The normalized status for a Play subscription state. An unmapped state is refused, never granted. */
401
+ export function playSubscriptionStatus(state: string): PurchaseStatus {
402
+ const status = SUBSCRIPTION_STATES[state];
403
+ if (status === undefined) {
404
+ // Play could add a state, and the only safe answer to one we have never seen is to project nothing. Guessing
405
+ // `active` would grant on a state that might mean the opposite; guessing `expired` would revoke a paying
406
+ // subscriber. Refusing leaves the row untouched and the notification recorded for reconciliation.
407
+ throw new PaymentsVerificationFailedError({
408
+ detail: `Google: subscription state "${state}" is not one this build maps. The purchase was left as it stood.`,
409
+ });
410
+ }
411
+ return status;
412
+ }
413
+
414
+ /** The normalized status for a Play one-time purchase state. An unmapped state is refused, never granted. */
415
+ export function playProductStatus(purchaseState: number): PurchaseStatus {
416
+ const status = PRODUCT_STATES[purchaseState];
417
+ if (status === undefined) {
418
+ throw new PaymentsVerificationFailedError({
419
+ detail: `Google: purchase state ${purchaseState} is not one this build maps. The purchase was left as it stood.`,
420
+ });
421
+ }
422
+ return status;
423
+ }
424
+
425
+ /** What both mappings need from their caller: the token, the event time, and any override. */
426
+ export interface PlayEventContext {
427
+ /** The purchase token, which is the subscription family's identity and the lookup's key. */
428
+ purchaseToken: string;
429
+ /** The provider event time — the notification's, or the clock on a client submission. Never invented here. */
430
+ eventAt: Date;
431
+ /**
432
+ * A status the notification knows and the API does not. Only ever `revoked`, from
433
+ * `SUBSCRIPTION_REVOKED` — see `rtdn.ts`.
434
+ */
435
+ statusOverride?: PurchaseStatus | null;
436
+ }
437
+
438
+ /** One Play subscription purchase, normalized. */
439
+ export function playSubscriptionEvent(
440
+ purchase: PlaySubscriptionPurchase,
441
+ context: PlayEventContext,
442
+ ): PlayPurchaseState {
443
+ // The first line item. Play's multi-line subscriptions bundle add-ons under one token, which would be several
444
+ // purchases rather than one, and that is not something v1 sells — so the first is the product, and a bundle
445
+ // would need a design rather than a loop.
446
+ const item = purchase.lineItems[0] as z.infer<typeof PlaySubscriptionLineItem>;
447
+ const status = context.statusOverride ?? playSubscriptionStatus(purchase.subscriptionState);
448
+ const expiresAt = item.expiryTime === undefined ? null : timestamp(item.expiryTime, "the expiry time");
449
+
450
+ const event: UnboundProviderEvent = {
451
+ rail: "google",
452
+ // The order id, not the token: it changes every renewal, so each period is its own row and a `grants` clause
453
+ // fires once per period. A subscription that has never been paid has no order yet, and keying it on the
454
+ // token is right — there is no transaction to key it on, and the first order will replace it.
455
+ providerTransactionId: purchase.latestOrderId ?? context.purchaseToken,
456
+ providerProductId: item.productId,
457
+ status,
458
+ environment: subscriptionEnvironment(purchase),
459
+ // Play reports no start time on a purchase that has not been paid for. The event time is then the closest
460
+ // honest answer, and `purchasedAt` is not what any access decision rests on.
461
+ purchasedAt: purchase.startTime === undefined ? context.eventAt : timestamp(purchase.startTime, "the start time"),
462
+ expiresAt,
463
+ // Play does not date a revocation. The notification's own time is when we learned of it, which is the only
464
+ // timestamp either side actually has.
465
+ revokedAt: status === "revoked" || status === "refunded" ? context.eventAt : null,
466
+ // Play's own answer to "when does this come back", and the reason the pause context is read at all.
467
+ // Absent for an indefinite pause, which `pauseResumesAt` keeps distinct from "not paused".
468
+ resumesAt: pauseResumesAt({
469
+ rail: "google",
470
+ status,
471
+ reported: purchase.pausedStateContext?.autoResumeTime,
472
+ }),
473
+ // The token is the family key: every renewal of one subscription carries the same one, which is what lets a
474
+ // renewal's owner be resolved from the purchase that started it.
475
+ originalTransactionId: context.purchaseToken,
476
+ // Play's purchase API reports no price. See the module doc: these columns are nullable for exactly this.
477
+ amountMinor: null,
478
+ currency: null,
479
+ providerEventAt: context.eventAt,
480
+ payload: purchase as Record<string, unknown>,
481
+ };
482
+
483
+ return { event, providerAccountId: purchase.externalAccountIdentifiers?.obfuscatedExternalAccountId ?? null };
484
+ }
485
+
486
+ /** One Play one-time purchase, normalized. */
487
+ export function playProductEvent(
488
+ purchase: PlayProductPurchase,
489
+ context: PlayEventContext & { productId: string },
490
+ ): PlayPurchaseState {
491
+ const status = context.statusOverride ?? playProductStatus(purchase.purchaseState);
492
+
493
+ const event: UnboundProviderEvent = {
494
+ rail: "google",
495
+ // A one-time purchase has exactly one order, so the order id is its identity. A pending purchase has none
496
+ // yet; the token stands in until it does, and the upsert replaces the row on the same key when it arrives.
497
+ providerTransactionId: purchase.orderId ?? context.purchaseToken,
498
+ providerProductId: context.productId,
499
+ status,
500
+ environment: purchase.purchaseType === PLAY_PURCHASE_TYPE_TEST ? "sandbox" : "production",
501
+ purchasedAt:
502
+ purchase.purchaseTimeMillis === undefined ? context.eventAt : new Date(Number(purchase.purchaseTimeMillis)),
503
+ // A one-time purchase never lapses. That is the whole difference between a non-consumable and a
504
+ // subscription, and it is why a null expiry beats every dated one when an entitlement is derived.
505
+ expiresAt: null,
506
+ revokedAt: status === "revoked" || status === "refunded" ? context.eventAt : null,
507
+ // Nothing chains a one-time purchase to anything else.
508
+ originalTransactionId: null,
509
+ amountMinor: null,
510
+ currency: null,
511
+ providerEventAt: context.eventAt,
512
+ payload: purchase as Record<string, unknown>,
513
+ };
514
+
515
+ return { event, providerAccountId: purchase.obfuscatedExternalAccountId ?? null };
516
+ }
517
+
518
+ /**
519
+ * Resolve one notification pointer to a state, or `undefined` when Play has no purchase under that token.
520
+ *
521
+ * The pointer says which lookup to make, so there is no probing on this path — the notification already told us
522
+ * whether it concerns a subscription or a one-time product.
523
+ */
524
+ export async function resolvePlayPointer(
525
+ pointer: GoogleNotificationPointer,
526
+ options: PlayApiOptions,
527
+ ): Promise<PlayPurchaseState | undefined> {
528
+ const context = {
529
+ purchaseToken: pointer.purchaseToken,
530
+ eventAt: pointer.eventAt,
531
+ statusOverride: pointer.statusOverride,
532
+ };
533
+
534
+ if (pointer.kind === "subscription") {
535
+ const purchase = await fetchPlaySubscription(pointer.purchaseToken, options);
536
+ return purchase === undefined ? undefined : playSubscriptionEvent(purchase, context);
537
+ }
538
+
539
+ // A one-time notification always names its sku, so there is nothing to fall back on and nothing to guess.
540
+ if (pointer.productId === null) return undefined;
541
+ const purchase = await fetchPlayProduct(pointer.productId, pointer.purchaseToken, options);
542
+ return purchase === undefined ? undefined : playProductEvent(purchase, { ...context, productId: pointer.productId });
543
+ }
544
+
545
+ /**
546
+ * Re-read one stored purchase at Play, normalized — the reconciliation path for this rail.
547
+ *
548
+ * **Subscriptions only, and the reason is what the row keeps.** A Play purchase is addressed by its *purchase
549
+ * token*, and only a subscription row stores one: `originalTransactionId` is the token, because the token is
550
+ * the family key every renewal shares. A one-time row is keyed on Play's order id and stores no token at all,
551
+ * and `purchases.products.get` takes a token — so there is no call this function could make for one. That is a
552
+ * real gap rather than a shortcut: a refunded one-time Play purchase reaches this deployment as a
553
+ * voided-purchase notification, which is recorded with its order id and its reason, and repairing it needs the
554
+ * token recovered from somewhere the row does not have it.
555
+ *
556
+ * A subscription that has never been paid has no order id yet and is keyed on its own token, so
557
+ * `originalTransactionId` is null and `providerTransactionId` is the token. Both spellings resolve.
558
+ */
559
+ export async function refreshPlayPurchase(
560
+ purchase: PaymentsPurchase,
561
+ options: PlayApiOptions,
562
+ ): Promise<UnboundProviderEvent | undefined> {
563
+ if (purchase.type !== "subscription") return undefined;
564
+
565
+ const purchaseToken = purchase.originalTransactionId ?? purchase.providerTransactionId;
566
+ const found = await fetchPlaySubscription(purchaseToken, options);
567
+ if (found === undefined) return undefined;
568
+
569
+ // The clock, not a Play timestamp — Play dates neither the state nor the read, and a refresh is the freshest
570
+ // fact anyone has. Dating it older would let the monotonic write rule discard the repair.
571
+ return playSubscriptionEvent(found, { purchaseToken, eventAt: options.now }).event;
572
+ }
573
+
574
+ /** A subscription's environment. `testPurchase` present is the whole decision; absent means a real purchase. */
575
+ function subscriptionEnvironment(purchase: PlaySubscriptionPurchase): PurchaseEnvironment {
576
+ return purchase.testPurchase === undefined ? "production" : "sandbox";
577
+ }
578
+
579
+ /** One RFC 3339 timestamp from Play. An unreadable one is refused rather than becoming an Invalid Date. */
580
+ function timestamp(value: string, what: string): Date {
581
+ const parsed = new Date(value);
582
+ if (Number.isNaN(parsed.getTime())) {
583
+ // An Invalid Date would encode as NaN and lose every comparison the monotonic write rule makes.
584
+ throw new PaymentsVerificationFailedError({ detail: `Google: ${what} "${value}" is not a timestamp.` });
585
+ }
586
+ return parsed;
587
+ }
588
+
589
+ /** Parse a Play response, or refuse it. Never echoes the body: the response quotes the purchase token. */
590
+ function parseOrRefuse<T extends z.ZodType>(schema: T, value: unknown, what: string): z.output<T> {
591
+ const parsed = schema.safeParse(value);
592
+ if (!parsed.success) {
593
+ throw new PaymentsVerificationFailedError({
594
+ detail: `Google: the answer is not a ${what} — ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ")}.`,
595
+ });
596
+ }
597
+ return parsed.data as z.output<T>;
598
+ }