@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,174 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PaymentsPurchase } from "../../data/purchase";
5
+ import { PaymentsVerificationFailedError } from "../../error/errors";
6
+ import type { PaymentsGoogleCredentials } from "../../secret/registry";
7
+ import type {
8
+ PaymentsRailProvider,
9
+ RailRequestContext,
10
+ UnboundProviderEvent,
11
+ VerifiedNotification,
12
+ VerifiedPurchase,
13
+ WebhookDelivery,
14
+ } from "../contract";
15
+ import type { GoogleHttpFetch } from "./http";
16
+ import { type GoogleJwk, verifyGoogleOidcToken } from "./oidc";
17
+ import { refreshPlayPurchase, resolvePlayPointer } from "./playApi";
18
+ import { parseGoogleNotification } from "./rtdn";
19
+ import { verifyGooglePurchase } from "./verify";
20
+
21
+ /**
22
+ * The Google Play rail as one provider object — the two halves of the contract, closed over the app's identity.
23
+ *
24
+ * Built per request from credentials the caller resolved through the secrets store, rather than reading them
25
+ * itself. That keeps the rail a pure function of its inputs, and it keeps the secret read at the point of need.
26
+ *
27
+ * ## Three checks, in this order, and each covers what the one before it cannot
28
+ *
29
+ * 1. **The OIDC token proves Google sent the delivery.** It arrives in the `Authorization` header rather than in
30
+ * the body, because a Pub/Sub push is not signed — the token is the whole proof.
31
+ * 2. **The audience claim proves it was minted for *this* endpoint.** Google signs these tokens for every push
32
+ * subscription in the world with the same keys, so the signature alone is worth nothing. See `oidc.ts`.
33
+ * 3. **The package name proves it concerns *this app*.** A token says who delivered it and nothing about what it
34
+ * is about, so without this any Play developer who can reach the endpoint could name a SKU in our catalog.
35
+ *
36
+ * ## And then a fourth call, which is Google's own doing
37
+ *
38
+ * A Real-time Developer Notification is a pointer: a purchase token and a notification type, with no state. So
39
+ * every projectable notification costs a Play Developer API lookup. That is the shape of Play's API rather than
40
+ * a choice here, and it is why an unreachable Play API is `payments/provider_unavailable` (503) — the route
41
+ * rethrows that so Pub/Sub redelivers, because the alternative is projecting a guess.
42
+ *
43
+ * ## One gap, recorded rather than papered over
44
+ *
45
+ * A **voided one-time purchase** cannot be resolved on this path. Play's voided-purchase notification names an
46
+ * order id and no product, and Play's one-time lookup takes the product id as a path segment — so there is no
47
+ * call this module can make that turns that token into a state. The delivery is recorded with a note carrying
48
+ * the order id, which is the key the purchase row is already stored under, and the reconciliation Workflow
49
+ * resolves it with one query. Subscription refunds have no such gap: they arrive as `SUBSCRIPTION_REVOKED` and
50
+ * project immediately.
51
+ */
52
+
53
+ /**
54
+ * How the Google rail's trust and transport may be widened, and the only callers with a reason to.
55
+ *
56
+ * `trustedKeys` is additive, so nothing can narrow production's trust: a token whose `kid` these do not cover is
57
+ * still resolved against Google's published set. It exists for the tests, which mint their own key so the
58
+ * signature check is exercised for real, and for a local **Pub/Sub emulator**, whose tokens are signed by a key
59
+ * Google never saw.
60
+ */
61
+ export interface GoogleRailOptions {
62
+ /** Verification keys accepted in addition to Google's published set, matched by `kid`. */
63
+ trustedKeys?: readonly GoogleJwk[];
64
+ /** The HTTP seam Google's two endpoints are reached through. Defaults to the runtime's `fetch`. */
65
+ transport?: GoogleHttpFetch;
66
+ /**
67
+ * A Play access token already minted, so a batch of refreshes pays for one round-trip instead of one each.
68
+ * Passed down by the reconciliation Workflow; absent everywhere else, where one call mints its own.
69
+ */
70
+ accessToken?: string;
71
+ }
72
+
73
+ /** The `Authorization` scheme a Pub/Sub push uses for its OIDC token. */
74
+ const BEARER = "bearer ";
75
+
76
+ /** The Google Play rail. Verifies client submissions at Play, and Pub/Sub pushes on the webhook. */
77
+ export function googleRail(
78
+ credentials: PaymentsGoogleCredentials,
79
+ options: GoogleRailOptions = {},
80
+ ): PaymentsRailProvider {
81
+ return {
82
+ rail: "google",
83
+
84
+ async verify(receipt: string, context: RailRequestContext): Promise<VerifiedPurchase> {
85
+ // `return await`, not `return`. Returning a promise from an async function makes this frame *adopt* the
86
+ // rejection rather than raising it, and workerd then reports the adopted promise as an unhandled rejection
87
+ // even though Hono's `onError` answers the request correctly. A refused receipt is normal traffic — a
88
+ // stale token, a product the token is not for — so it must not read as a runtime fault in a log.
89
+ return await verifyGooglePurchase(receipt, {
90
+ credentials,
91
+ now: context.now,
92
+ transport: options.transport,
93
+ });
94
+ },
95
+
96
+ async parseNotification(delivery: WebhookDelivery, context: RailRequestContext): Promise<VerifiedNotification> {
97
+ // The header, not the body. A Pub/Sub push body carries no signature at all, so nothing about it is
98
+ // trustworthy until the token beside it verifies.
99
+ const authorization = delivery.headers.get("authorization") ?? "";
100
+ if (!authorization.toLowerCase().startsWith(BEARER)) {
101
+ throw new PaymentsVerificationFailedError({
102
+ detail:
103
+ "Google: the push carries no Authorization bearer token. Configure the Pub/Sub push subscription to authenticate with a service account.",
104
+ });
105
+ }
106
+ await verifyGoogleOidcToken(authorization.slice(BEARER.length).trim(), {
107
+ audience: credentials.pubsubAudience,
108
+ serviceAccountEmail: credentials.serviceAccountEmail,
109
+ now: context.now,
110
+ transport: options.transport,
111
+ trustedKeys: options.trustedKeys,
112
+ });
113
+
114
+ const parsed = parseGoogleNotification(delivery.body, { packageName: credentials.packageName });
115
+ if (parsed.pointer === null) {
116
+ return {
117
+ providerEventId: parsed.providerEventId,
118
+ payload: parsed.payload,
119
+ event: null,
120
+ providerAccountId: null,
121
+ // A void carries no state and needs none: the order id names the row, and the route owns the
122
+ // database that has it. See `VerifiedNotification.voidedOrderId`.
123
+ voidedOrderId: parsed.voidedOrderId,
124
+ // `stated`: `parseGoogleNotification` reads the delivered bytes and nothing else, so this note is
125
+ // what the notification says. The same bytes get the same answer for ever — terminal.
126
+ note: parsed.note === null ? null : { stated: parsed.note },
127
+ };
128
+ }
129
+
130
+ const state = await resolvePlayPointer(parsed.pointer, {
131
+ credentials,
132
+ now: context.now,
133
+ transport: options.transport,
134
+ });
135
+ if (state === undefined) {
136
+ // Authentic, and about a purchase Play will not show us — a token from a deleted app, or a
137
+ // notification that raced its own purchase.
138
+ //
139
+ // **`read`, not `stated`, and that is #341.** This sentence is the output of a call to Play, and the
140
+ // second reason above is a race by name: an RTDN is published before Play's own read-after-write has
141
+ // settled often enough that Google documents the retry. The note said "the answer will not change",
142
+ // which is true of the *bytes* and false of the *lookup* — so a note derived from it finished the row,
143
+ // and the redelivery Pub/Sub was already queuing was answered `duplicate`. Repairable.
144
+ return {
145
+ providerEventId: parsed.providerEventId,
146
+ payload: parsed.payload,
147
+ event: null,
148
+ providerAccountId: null,
149
+ note: {
150
+ read: `google: Play has no ${parsed.pointer.kind === "subscription" ? "subscription" : "one-time purchase"} under the token this notification points at.`,
151
+ },
152
+ };
153
+ }
154
+
155
+ return {
156
+ providerEventId: parsed.providerEventId,
157
+ payload: parsed.payload,
158
+ event: state.event,
159
+ providerAccountId: state.providerAccountId,
160
+ note: null,
161
+ };
162
+ },
163
+
164
+ async refresh(purchase: PaymentsPurchase, context: RailRequestContext): Promise<UnboundProviderEvent | undefined> {
165
+ // `return await`, not `return` — see `verify` above for why the frame must raise rather than adopt.
166
+ return await refreshPlayPurchase(purchase, {
167
+ credentials,
168
+ now: context.now,
169
+ transport: options.transport,
170
+ accessToken: options.accessToken,
171
+ });
172
+ },
173
+ };
174
+ }
@@ -0,0 +1,331 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { PurchaseStatus } from "../../data/status";
6
+ import { PaymentsInvalidReceiptError, PaymentsVerificationFailedError } from "../../error/errors";
7
+ import { base64UrlDecode } from "./jwt";
8
+
9
+ /**
10
+ * Play's Real-time Developer Notification, and the honest shape of what it tells us.
11
+ *
12
+ * **The payload is a pointer, not a state.** Apple and Stripe deliver the transaction inline; Play delivers a
13
+ * purchase token and a notification type, and nothing about the purchase's current condition. So this module
14
+ * produces a {@link GoogleNotificationPointer} — which purchase changed, and nothing more — and resolving it to
15
+ * a state is the *next* call, into the Play Developer API. That is modeled in the type rather than papered
16
+ * over: the pointer has no status field, so there is no place for a guess to hide.
17
+ *
18
+ * Guessing would be the alternative, and it fails in both directions. `SUBSCRIPTION_RENEWED` looks like
19
+ * "active", but the renewal it announces may already have been refunded by the time we read it; `EXPIRED` looks
20
+ * terminal, but Play sends it for a subscription the user has since restarted. Only the API knows, and it is
21
+ * the same API the reconciliation Workflow reads, so both paths converge on one answer.
22
+ *
23
+ * **One exception, and it is not a guess.** A revoked subscription reads as `EXPIRED` in the API once it is
24
+ * revoked, which loses the fact that money went back. The notification type is the only surviving record of
25
+ * that, so `SUBSCRIPTION_REVOKED` travels as an explicit `statusOverride` — a fact the API cannot report,
26
+ * carried in a field named for what it is.
27
+ *
28
+ * ## The envelope
29
+ *
30
+ * A notification does not arrive from Google Play. It arrives from **Pub/Sub**, as a push whose body wraps a
31
+ * base64 Developer Notification and whose `Authorization` header carries the OIDC token that proves it is
32
+ * Google's (see `oidc.ts`). Pub/Sub's `messageId` is stable across redeliveries of one message, which is what
33
+ * makes it the dedupe key `UNIQUE (rail, providerEventId)` needs.
34
+ */
35
+
36
+ /** Play's `SUBSCRIPTION_REVOKED`. The one notification type whose meaning the API does not preserve. */
37
+ const SUBSCRIPTION_REVOKED = 12;
38
+
39
+ /** A string of milliseconds since the epoch — how Google writes a timestamp in an RTDN. */
40
+ const GoogleEventMillis = z
41
+ .string()
42
+ .regex(/^\d+$/, "A Play event time is a string of milliseconds since the epoch.")
43
+ .describe(
44
+ "When Google recorded the event, as a decimal string of milliseconds. A string, not a number — Play's JSON encodes 64-bit integers that way, and parsing it loosely would yield an Invalid Date the monotonic write rule would silently lose to.",
45
+ );
46
+
47
+ /** The Pub/Sub push envelope. `.loose()` so a new envelope field never refuses a real notification. */
48
+ export const GooglePubSubPush = z
49
+ .object({
50
+ message: z
51
+ .object({
52
+ data: z
53
+ .string()
54
+ .min(1)
55
+ .describe("The Developer Notification, base64-encoded. Standard base64 with padding, as Pub/Sub emits."),
56
+ messageId: z
57
+ .string()
58
+ .min(1)
59
+ .describe(
60
+ "Pub/Sub's own id for this message. Stable across redeliveries, which is what makes it the dedupe key.",
61
+ ),
62
+ publishTime: z.string().min(1).optional().describe("When Pub/Sub published the message, RFC 3339."),
63
+ })
64
+ .loose()
65
+ .describe("The Pub/Sub message carrying one Developer Notification."),
66
+ subscription: z
67
+ .string()
68
+ .min(1)
69
+ .optional()
70
+ .describe("The push subscription's resource name. Recorded for diagnosis; nothing is decided from it."),
71
+ })
72
+ .loose()
73
+ .describe("The body Pub/Sub POSTs to a push endpoint.");
74
+ export type GooglePubSubPush = z.infer<typeof GooglePubSubPush>;
75
+
76
+ /** One Play Developer Notification. Exactly one of the four notification blocks is present. */
77
+ export const GoogleDeveloperNotification = z
78
+ .object({
79
+ version: z.string().min(1).describe("The notification schema version Google sent — `1.0` throughout v1."),
80
+ packageName: z
81
+ .string()
82
+ .min(1)
83
+ .describe(
84
+ "The Android application id the event concerns. Checked against our own: a push token proves Google sent the delivery and says nothing about which app it is about.",
85
+ ),
86
+ eventTimeMillis: GoogleEventMillis.describe(
87
+ "When Google recorded the event. This is the provider event time the monotonic write rule compares against.",
88
+ ),
89
+ subscriptionNotification: z
90
+ .object({
91
+ version: z.string().min(1).describe("The block's schema version."),
92
+ notificationType: z
93
+ .number()
94
+ .int()
95
+ .describe(
96
+ "What happened, as Play's own integer. Recorded and reported, but not mapped to a status — the API is what says what the subscription is now.",
97
+ ),
98
+ purchaseToken: z
99
+ .string()
100
+ .min(1)
101
+ .describe("The subscription's purchase token. Stable across renewals, so it is the family's identity."),
102
+ subscriptionId: z.string().min(1).describe("The base subscription product id, as listed in the Play Console."),
103
+ })
104
+ .loose()
105
+ .optional()
106
+ .describe("Present when the event concerns a subscription."),
107
+ oneTimeProductNotification: z
108
+ .object({
109
+ version: z.string().min(1).describe("The block's schema version."),
110
+ notificationType: z.number().int().describe("1 for purchased, 2 for canceled."),
111
+ purchaseToken: z.string().min(1).describe("The purchase token for the one-time order."),
112
+ sku: z
113
+ .string()
114
+ .min(1)
115
+ .describe(
116
+ "The product id. Load-bearing here and nowhere else: Play's one-time lookup takes it as a path segment, so a pointer without it could not be resolved.",
117
+ ),
118
+ })
119
+ .loose()
120
+ .optional()
121
+ .describe("Present when the event concerns a one-time product."),
122
+ voidedPurchaseNotification: z
123
+ .object({
124
+ purchaseToken: z.string().min(1).describe("The voided purchase's token."),
125
+ orderId: z
126
+ .string()
127
+ .min(1)
128
+ .describe("The voided order — which, for a one-time purchase, is the id its purchase row is keyed by."),
129
+ productType: z.number().int().describe("1 for a subscription, 2 for a one-time product."),
130
+ refundType: z.number().int().optional().describe("1 for a full refund, 2 for a partial one."),
131
+ })
132
+ .loose()
133
+ .optional()
134
+ .describe("Present when a purchase was refunded or charged back."),
135
+ testNotification: z
136
+ .object({ version: z.string().min(1).describe("The block's schema version.") })
137
+ .loose()
138
+ .optional()
139
+ .describe("Present for the notification the Play Console's test button sends. Concerns no purchase."),
140
+ })
141
+ .loose()
142
+ .describe("One Play Real-time Developer Notification, decoded out of its Pub/Sub envelope.");
143
+ export type GoogleDeveloperNotification = z.infer<typeof GoogleDeveloperNotification>;
144
+
145
+ /** Which Play lookup resolves a pointer. The two take different endpoints and different path parameters. */
146
+ export type GoogleNotificationKind = "subscription" | "one_time";
147
+
148
+ /**
149
+ * Which purchase a notification is about. Deliberately carries no state — see the module doc.
150
+ */
151
+ export interface GoogleNotificationPointer {
152
+ /** Which Play lookup resolves it. */
153
+ kind: GoogleNotificationKind;
154
+ /** The purchase token the lookup is keyed by. */
155
+ purchaseToken: string;
156
+ /** The product id, when the notification names one. Required for a one-time lookup, informational otherwise. */
157
+ productId: string | null;
158
+ /** Play's own notification type. Reported and audited; never mapped to a status. */
159
+ notificationType: number;
160
+ /**
161
+ * The one status the Play API cannot report and the notification can. `revoked` for a revoked subscription,
162
+ * which the API reports as merely expired, and null for everything else.
163
+ */
164
+ statusOverride: PurchaseStatus | null;
165
+ /** Google's own event time. What the monotonic write rule compares against. */
166
+ eventAt: Date;
167
+ }
168
+
169
+ /** A verified, decoded notification: what to record, and what to resolve. */
170
+ export interface ParsedGoogleNotification {
171
+ /** Pub/Sub's message id — the dedupe key. */
172
+ providerEventId: string;
173
+ /** What to store on the webhook row: the decoded notification plus the envelope's own metadata. */
174
+ payload: Record<string, unknown>;
175
+ /** The app the notification concerns, already checked against ours. */
176
+ packageName: string;
177
+ /** Google's own event time. */
178
+ eventAt: Date;
179
+ /** Which purchase changed, or null when the notification concerns none. */
180
+ pointer: GoogleNotificationPointer | null;
181
+ /**
182
+ * Why a notification with no pointer had none, when that is worth recording. Null when there is nothing to
183
+ * say — a test notification needs no explanation.
184
+ */
185
+ note: string | null;
186
+ /**
187
+ * The order a voided-purchase notification refunded, or null. Reported rather than only described, because
188
+ * an order id is exactly what a Google purchase's `providerTransactionId` is — so the route can find the
189
+ * row this refund is about even though Play offers no lookup that would.
190
+ */
191
+ voidedOrderId: string | null;
192
+ }
193
+
194
+ /** What parsing needs: our own application id, and nothing else. */
195
+ export interface ParseGoogleNotificationOptions {
196
+ /** Our Android application id. A notification for any other app is refused. */
197
+ packageName: string;
198
+ }
199
+
200
+ /**
201
+ * Decode one Pub/Sub push into a pointer.
202
+ *
203
+ * Authenticity is not established here — that is `verifyGoogleOidcToken`, and it runs first. What this adds is
204
+ * the check a token cannot make: Google signs a push token for every Pub/Sub subscription there is, so a valid
205
+ * token says nothing about which app a notification concerns. Without the package check, any Play developer who
206
+ * can reach our endpoint can send us a SKU that happens to match our catalog.
207
+ *
208
+ * @throws {@link PaymentsInvalidReceiptError} when the body cannot be read as a Pub/Sub push carrying a
209
+ * Developer Notification.
210
+ * @throws {@link PaymentsVerificationFailedError} when it is readable and concerns another app.
211
+ */
212
+ export function parseGoogleNotification(
213
+ body: string,
214
+ options: ParseGoogleNotificationOptions,
215
+ ): ParsedGoogleNotification {
216
+ let envelope: unknown;
217
+ try {
218
+ envelope = JSON.parse(body) as unknown;
219
+ } catch (cause) {
220
+ throw malformed("the push body is not JSON.", cause);
221
+ }
222
+
223
+ const push = GooglePubSubPush.safeParse(envelope);
224
+ if (!push.success) throw malformed(`the push body is not a Pub/Sub push — ${issues(push.error)}.`);
225
+
226
+ const decoded = new TextDecoder().decode(base64UrlDecode(push.data.message.data, "the push message data"));
227
+ let raw: unknown;
228
+ try {
229
+ raw = JSON.parse(decoded) as unknown;
230
+ } catch (cause) {
231
+ throw malformed("the push message data is not JSON.", cause);
232
+ }
233
+
234
+ const parsed = GoogleDeveloperNotification.safeParse(raw);
235
+ if (!parsed.success) {
236
+ throw malformed(`the push carries no Play developer notification — ${issues(parsed.error)}.`);
237
+ }
238
+ const notification = parsed.data;
239
+
240
+ if (notification.packageName !== options.packageName) {
241
+ throw new PaymentsVerificationFailedError({
242
+ message: "That notification is not for this app.",
243
+ action: "Check that the Play Console app and this deployment's package name are the same.",
244
+ detail: `Google: the notification is for "${notification.packageName}", this app is "${options.packageName}".`,
245
+ });
246
+ }
247
+
248
+ const eventAt = new Date(Number(notification.eventTimeMillis));
249
+ const common = {
250
+ providerEventId: push.data.message.messageId,
251
+ // The base64 `data` is deliberately dropped: keeping it would store the same bytes twice, and the decoded
252
+ // form is what a human reads and what a replay needs.
253
+ payload: {
254
+ subscription: push.data.subscription,
255
+ message: { messageId: push.data.message.messageId, publishTime: push.data.message.publishTime },
256
+ notification,
257
+ } as Record<string, unknown>,
258
+ packageName: notification.packageName,
259
+ eventAt,
260
+ // Only the voided branch overrides this; every other notification refunds nothing.
261
+ voidedOrderId: null as string | null,
262
+ };
263
+
264
+ if (notification.subscriptionNotification) {
265
+ const block = notification.subscriptionNotification;
266
+ return {
267
+ ...common,
268
+ pointer: {
269
+ kind: "subscription",
270
+ purchaseToken: block.purchaseToken,
271
+ productId: block.subscriptionId,
272
+ notificationType: block.notificationType,
273
+ statusOverride: block.notificationType === SUBSCRIPTION_REVOKED ? "revoked" : null,
274
+ eventAt,
275
+ },
276
+ note: null,
277
+ };
278
+ }
279
+
280
+ if (notification.oneTimeProductNotification) {
281
+ const block = notification.oneTimeProductNotification;
282
+ return {
283
+ ...common,
284
+ pointer: {
285
+ kind: "one_time",
286
+ purchaseToken: block.purchaseToken,
287
+ productId: block.sku,
288
+ notificationType: block.notificationType,
289
+ statusOverride: null,
290
+ eventAt,
291
+ },
292
+ note: null,
293
+ };
294
+ }
295
+
296
+ if (notification.voidedPurchaseNotification) {
297
+ // No pointer, and there cannot be one: a voided notification names no product, and Play's one-time lookup
298
+ // takes the product id as a path segment, so no call this module can make turns the token back into a
299
+ // state. But it does not need to. The notification is authentic and it says the order was refunded — the
300
+ // same statement Apple's `REFUND` makes — and the product it concerns is in our own row, which is keyed by
301
+ // this very order id. So the order travels out as `voidedOrderId` and the route resolves it.
302
+ const block = notification.voidedPurchaseNotification;
303
+ return {
304
+ ...common,
305
+ pointer: null,
306
+ voidedOrderId: block.orderId,
307
+ note: `google: purchase voided (order ${block.orderId}, productType ${block.productType}, refundType ${block.refundType ?? "unspecified"}).`,
308
+ };
309
+ }
310
+
311
+ if (notification.testNotification) return { ...common, pointer: null, note: null };
312
+
313
+ // Google adds notification kinds, and a type this build has never heard of must not be an error: answering
314
+ // non-2xx would make Pub/Sub redeliver it forever and read as a broken endpoint. Recorded, and the note is
315
+ // what makes it visible rather than silently dropped.
316
+ return {
317
+ ...common,
318
+ pointer: null,
319
+ note: "google: the notification carries no notification this build recognizes, so there is nothing to resolve.",
320
+ };
321
+ }
322
+
323
+ /** A structural refusal. The body is never echoed: a purchase token is a bearer artifact. */
324
+ function malformed(detail: string, cause?: unknown): PaymentsInvalidReceiptError {
325
+ return new PaymentsInvalidReceiptError({ detail: `Google: ${detail}` }, cause === undefined ? undefined : { cause });
326
+ }
327
+
328
+ /** Zod issues as `path:code` pairs — never `message` or `received`, which would echo the payload. */
329
+ function issues(error: z.ZodError): string {
330
+ return error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ");
331
+ }
@@ -0,0 +1,127 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { PaymentsInvalidReceiptError, PaymentsVerificationFailedError } from "../../error/errors";
6
+ import type { VerifiedPurchase } from "../contract";
7
+ import {
8
+ fetchPlayProduct,
9
+ fetchPlaySubscription,
10
+ type PlayApiOptions,
11
+ playProductEvent,
12
+ playSubscriptionEvent,
13
+ } from "./playApi";
14
+
15
+ /**
16
+ * The client-submission path for Google: a Play purchase token, verified at Google.
17
+ *
18
+ * **There is no offline verification here, and that is Play's design rather than ours.** Play Billing hands the
19
+ * app a purchase token and, optionally, a signature over the receipt JSON made with the app's licensing key —
20
+ * which is a key Google publishes per app in the Play Console and which is not in this rail's credential bundle
21
+ * on purpose. Checking it would prove the JSON came from Play; it would not prove the purchase still stands, and
22
+ * a refunded purchase's receipt keeps verifying forever. So the token is looked up. One round-trip buys the
23
+ * current state, which is the thing an entitlement should rest on.
24
+ *
25
+ * Nothing about correctness rests on this path in any case. The webhook produces the identical row through the
26
+ * same idempotent writer, so a dropped submission costs nothing and a replayed one changes nothing. What it buys
27
+ * is immediacy: the buyer sees their entitlement the moment they buy, rather than when Pub/Sub gets round to it.
28
+ *
29
+ * ## Why a client-declared product id is safe here
30
+ *
31
+ * `PurchaseSubmission` carries no product id, deliberately — a client-declared product would let a caller
32
+ * present a cheap receipt as an expensive one. What the receipt *does* carry, because Play Billing puts it
33
+ * there, is the product the app thinks it bought, and that value is used as **a lookup key and never as a
34
+ * claim**: Play's one-time endpoint takes the product id as a path segment and answers 404 when the token does
35
+ * not belong to it. So a lie fails at Google rather than being believed here, and the id that lands in the
36
+ * purchase row is the one Google confirmed by answering.
37
+ *
38
+ * ## Why the subscription lookup goes first
39
+ *
40
+ * Play has no call that says what kind of purchase a token is. The subscription endpoint takes a token alone, so
41
+ * it can answer without believing anything the client said; its 404 is then how a one-time purchase identifies
42
+ * itself, and only then is the declared product id used at all.
43
+ */
44
+
45
+ /** The longest a product id may be, matching the catalog's own bound. */
46
+ const MAX_SKU_LENGTH = 200;
47
+
48
+ /** The longest a purchase token may be. Play's are around a hundred characters; this is generous. */
49
+ const MAX_TOKEN_LENGTH = 4096;
50
+
51
+ /**
52
+ * What a client submits for the Google rail: `Purchase.getOriginalJson()`, verbatim.
53
+ *
54
+ * `.loose()` because Play Billing's JSON carries more than this and its shape moves between library versions.
55
+ * Both product spellings are accepted: Play Billing 4 and earlier emit `productId`, 5 and later emit a
56
+ * `productIds` array, and which one arrives depends on the app's library version rather than on anything a
57
+ * server decides.
58
+ */
59
+ export const GooglePlayReceipt = z
60
+ .object({
61
+ purchaseToken: z
62
+ .string()
63
+ .min(1)
64
+ .max(MAX_TOKEN_LENGTH)
65
+ .describe("Play's own token for the purchase. The only field this path cannot do without."),
66
+ productId: z
67
+ .string()
68
+ .min(1)
69
+ .max(MAX_SKU_LENGTH)
70
+ .optional()
71
+ .describe(
72
+ "What the app thinks it bought, as Play Billing 4 and earlier report it. A lookup key, never a claim — Play answers 404 when the token does not belong to it.",
73
+ ),
74
+ productIds: z
75
+ .array(z.string().min(1).max(MAX_SKU_LENGTH).describe("One product the order covers."))
76
+ .min(1)
77
+ .optional()
78
+ .describe("The same thing as Play Billing 5 and later report it. The first entry is the product looked up."),
79
+ })
80
+ .loose()
81
+ .describe("A Play purchase as the Billing Library's `getOriginalJson()` returns it.");
82
+ export type GooglePlayReceipt = z.infer<typeof GooglePlayReceipt>;
83
+
84
+ /** Verify a client-submitted Play purchase and normalize it. The caller binds the authenticated purchaser. */
85
+ export async function verifyGooglePurchase(receipt: string, options: PlayApiOptions): Promise<VerifiedPurchase> {
86
+ let raw: unknown;
87
+ try {
88
+ raw = JSON.parse(receipt) as unknown;
89
+ } catch (cause) {
90
+ // Never echo the receipt: it is caller-supplied, and it is what a stolen one would look like in a log.
91
+ throw new PaymentsInvalidReceiptError({ detail: "Google: the submitted purchase is not JSON." }, { cause });
92
+ }
93
+
94
+ const parsed = GooglePlayReceipt.safeParse(raw);
95
+ if (!parsed.success) {
96
+ throw new PaymentsInvalidReceiptError({
97
+ detail: `Google: the submitted purchase is not a Play receipt — ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ")}.`,
98
+ });
99
+ }
100
+ const { purchaseToken } = parsed.data;
101
+ const productId = parsed.data.productId ?? parsed.data.productIds?.[0];
102
+
103
+ // A client submission is a read of the current state, so the clock is the honest event time: there is no
104
+ // provider timestamp on a Play purchase, and inventing an older one would make a submission lose to a
105
+ // notification it is newer than.
106
+ const context = { purchaseToken, eventAt: options.now };
107
+
108
+ // The token alone. Nothing the client said is used yet.
109
+ const subscription = await fetchPlaySubscription(purchaseToken, options);
110
+ if (subscription !== undefined) return playSubscriptionEvent(subscription, context);
111
+
112
+ if (productId === undefined) {
113
+ throw new PaymentsVerificationFailedError({
114
+ detail:
115
+ "Google: the purchase is not a subscription and the receipt names no product id, so there is no one-time purchase to look up. Submit `Purchase.getOriginalJson()` unaltered.",
116
+ });
117
+ }
118
+
119
+ const product = await fetchPlayProduct(productId, purchaseToken, options);
120
+ if (product !== undefined) return playProductEvent(product, { ...context, productId });
121
+
122
+ // Play knows neither a subscription nor a one-time purchase under this token and product. Either the token is
123
+ // not ours, or it is not for the product it claims. Both are the same statement to a client.
124
+ throw new PaymentsVerificationFailedError({
125
+ detail: `Google: Play has no subscription under this token and no "${productId}" purchase under it either.`,
126
+ });
127
+ }