@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,128 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsProviderUnavailableError, PaymentsRailNotConfiguredError } from "../../error/errors";
5
+
6
+ /**
7
+ * The one door out to Apple, and the mapping from how it answered to what that means.
8
+ *
9
+ * **Why the Apple rail reaches the network at all, when verification does not.** A StoreKit transaction is
10
+ * signed, so a client submission and a notification both verify offline against the pinned chain. Neither
11
+ * tells us anything about a subscription we have not heard from — and that silence is precisely what
12
+ * reconciliation exists for: Apple stops renewing and sends nothing, or the notification it did send never
13
+ * arrived. Answering that needs the App Store Server API, and this is the only module that talks to it.
14
+ *
15
+ * ## How an answer is read
16
+ *
17
+ * **A 429 or a 5xx is `payments/provider_unavailable` (503).** Apple is up and not answering, or is asking us
18
+ * to slow down. The Workflow step fails and is retried, which is what durable execution is for.
19
+ *
20
+ * **Every other 4xx is `payments/rail_not_configured` (404), the same call Stripe's door makes.** A request
21
+ * here is built entirely from our own credentials and from rows we wrote, so a 401 (the signing key or the
22
+ * issuer id is wrong), a 400, and a 403 are one statement: this project's Apple rail is not set up to do what
23
+ * it was asked. 503 would ask a caller to retry something that will never succeed, and no client is involved
24
+ * to blame with a 400.
25
+ *
26
+ * **A 404 is an absent purchase only for a caller that said it was probing.** Apple answers 404 for a
27
+ * transaction id it does not know — a sandbox id asked of production, or a purchase from a different app —
28
+ * and only a caller that knows the difference between that and a misconfigured account may claim it.
29
+ *
30
+ * **No refusal carries the URL or the response body.** The URL ends in a transaction id and Apple's error
31
+ * bodies quote the request. `detail` reaches an operator's log, and the same rule that keeps a receipt out of
32
+ * one keeps these out too.
33
+ */
34
+
35
+ /** One outbound request, narrowed to what the App Store Server API needs. Every call is an authorized GET. */
36
+ export interface AppleHttpRequest {
37
+ /** The HTTP method. Defaults to GET. */
38
+ method?: string;
39
+ /** Request headers — the bearer assertion, and nothing else. */
40
+ headers?: Record<string, string>;
41
+ }
42
+
43
+ /** The response shape this module reads. Structural, so a test's transport need not be a whole `Response`. */
44
+ export interface AppleHttpResponse {
45
+ /** Whether the status is 2xx. */
46
+ ok: boolean;
47
+ /** The status code, which is what decides the mapping. */
48
+ status: number;
49
+ /** The body as text. Read as text rather than JSON so a non-JSON answer is a diagnosis, not a throw. */
50
+ text(): Promise<string>;
51
+ }
52
+
53
+ /**
54
+ * The HTTP seam for the App Store Server API.
55
+ *
56
+ * Injectable for one reason: **no test may reach a live store**, and a rail whose network call could not be
57
+ * substituted would have to be tested through a stub of itself. One explicit parameter beats reassigning a
58
+ * global, which leaks between suites and hides which module was exercised.
59
+ */
60
+ export type AppleHttpFetch = (url: string, init?: AppleHttpRequest) => Promise<AppleHttpResponse>;
61
+
62
+ /** The default transport: the runtime's own `fetch`. */
63
+ export const appleHttpFetch: AppleHttpFetch = (url, init) => fetch(url, init) as unknown as Promise<AppleHttpResponse>;
64
+
65
+ /** What one request is for, and how its answers should be read. */
66
+ export interface AppleJsonOptions {
67
+ /** What is being fetched, in a `detail` line: "the subscription statuses". Never the URL. */
68
+ what: string;
69
+ /** Request headers — the bearer assertion. */
70
+ headers?: Record<string, string>;
71
+ /**
72
+ * Whether a 404 means "no such transaction" rather than a misconfigured account. Set only by a caller that
73
+ * is deliberately probing for something that may legitimately not exist.
74
+ */
75
+ absentOn404?: boolean;
76
+ }
77
+
78
+ /**
79
+ * One request to Apple, and its parsed JSON body — or `undefined` when a 404 was a legitimate answer.
80
+ *
81
+ * The body comes back as `unknown`. Reaching an endpoint proves who answered, never what they said, so every
82
+ * caller Zod-parses its own shape.
83
+ */
84
+ export async function appleJson(
85
+ transport: AppleHttpFetch,
86
+ url: string,
87
+ options: AppleJsonOptions,
88
+ ): Promise<unknown | undefined> {
89
+ let response: AppleHttpResponse;
90
+ try {
91
+ response = await transport(url, { headers: options.headers });
92
+ } catch (cause) {
93
+ throw new PaymentsProviderUnavailableError(
94
+ { detail: `Apple did not answer when asked for ${options.what}.` },
95
+ { cause },
96
+ );
97
+ }
98
+
99
+ if (response.status === 404 && options.absentOn404) return undefined;
100
+
101
+ if (!response.ok) {
102
+ if (response.status === 429 || response.status >= 500) {
103
+ throw new PaymentsProviderUnavailableError({
104
+ detail: `Apple answered ${response.status} when asked for ${options.what}.`,
105
+ });
106
+ }
107
+ // 401 is the one an operator can actually fix, and it has one overwhelmingly common cause.
108
+ const hint =
109
+ response.status === 401
110
+ ? " Check the App Store Connect key id, issuer id, and private key in the payments provider secret — a token Apple will not accept is a credential problem, not an outage."
111
+ : "";
112
+ throw new PaymentsRailNotConfiguredError({
113
+ detail: `Apple answered ${response.status} when asked for ${options.what}.${hint}`,
114
+ });
115
+ }
116
+
117
+ const text = await response.text();
118
+ try {
119
+ return JSON.parse(text) as unknown;
120
+ } catch (cause) {
121
+ // An HTML error page behind a proxy is the realistic shape of this. Reading it as an absent purchase would
122
+ // leave a live subscription looking unreconciled forever, so it is a failure to reach the store like any other.
123
+ throw new PaymentsProviderUnavailableError(
124
+ { detail: `Apple answered with a non-JSON body when asked for ${options.what}.` },
125
+ { cause },
126
+ );
127
+ }
128
+ }
@@ -0,0 +1,123 @@
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 { APPLE_ROOT_CERTIFICATES } from "./certs";
7
+ import { decodeBase64, decodeBase64Url } from "./der";
8
+ import { verifyCertificateChain } from "./x509";
9
+
10
+ /**
11
+ * App Store Server Notification V2 signature verification — the authenticity boundary on Apple's webhook,
12
+ * and on every transaction a client submits.
13
+ *
14
+ * Apple signs its notifications and transactions as compact JWS, carrying the whole issuing certificate
15
+ * chain in the header's `x5c`. Verifying one means three things, in this order, and the order matters:
16
+ *
17
+ * 1. **Pin the algorithm before touching the key.** `alg` is attacker-controlled — it is in a header nobody
18
+ * has verified yet — so it is parsed against a literal `ES256` rather than looked up in a table. That is
19
+ * what makes the two published confusions unreachable: `none`, which asks for the signature to be
20
+ * skipped, and `HS256`, which asks the verifier to HMAC using the public key it just found in the
21
+ * header as if it were a shared secret. Neither survives a literal.
22
+ * 2. **Verify the chain, not the leaf.** `x5c` is a claim, not evidence. The chain must link, every window
23
+ * must contain the clock, and the root must be one shipped in `certs.ts` — see `x509.ts`.
24
+ * 3. **Verify the signature with the leaf's key, over the exact received bytes** — `header.payload` as
25
+ * encoded, not re-serialized. A JSON round-trip would reorder keys and change what was signed.
26
+ *
27
+ * `alg: ES256` also fixes the curve at P-256, so a P-384 leaf under an ES256 header is refused. That is the
28
+ * same confusion one layer down, and checking only the string would let it through.
29
+ *
30
+ * The payload is returned as `unknown`. Verification proves who sent the bytes, never what they mean, so
31
+ * the caller Zod-parses. Handing back a typed object here would blur the two.
32
+ */
33
+
34
+ /**
35
+ * The JWS header, exactly as tightly as it can be. `alg` is a literal for the reason above; `x5c` must be
36
+ * at least two certificates because a single one would be trusted on its own word.
37
+ */
38
+ const AppleJwsHeader = z
39
+ .object({
40
+ alg: z
41
+ .literal("ES256")
42
+ .describe(
43
+ "The signing algorithm. A literal, not a lookup: `alg` arrives in an unverified header, and accepting `none` or `HS256` from it is how JWS verifiers are bypassed.",
44
+ ),
45
+ x5c: z
46
+ .array(z.string().min(1).describe("One certificate, base64-encoded DER."))
47
+ .min(2)
48
+ .describe(
49
+ "The issuing certificate chain, leaf first and root last. A claim rather than evidence — every link is verified and the root must be one pinned in certs.ts.",
50
+ ),
51
+ })
52
+ .describe("The header of an Apple-signed JWS, constrained to the one shape Apple sends.");
53
+
54
+ /** Which roots to trust and what clock to judge certificate validity against. */
55
+ export interface VerifyAppleJwsOptions {
56
+ /** base64 DER of every acceptable root. Defaults to the pinned Apple roots. */
57
+ roots?: readonly string[];
58
+ /** The clock. Injected so a certificate-window refusal is deterministic in tests. */
59
+ now?: Date;
60
+ }
61
+
62
+ /**
63
+ * Verify a compact JWS against Apple's pinned certificate chain and return its decoded payload.
64
+ *
65
+ * Throws `payments/invalid_receipt` when the JWS is malformed — wrong segment count, a header Apple would
66
+ * never send, an unreadable certificate — and `payments/verification_failed` when it is well-formed but
67
+ * does not verify. Both are 400s; the distinction is for the operator reading `detail`, and the webhook
68
+ * guard maps either to `payments/webhook_unverified` on its own path.
69
+ */
70
+ export async function verifyAppleJws(jws: string, options: VerifyAppleJwsOptions): Promise<unknown> {
71
+ const segments = jws.split(".");
72
+ if (segments.length !== 3) {
73
+ throw new PaymentsInvalidReceiptError({ detail: `JWS: ${segments.length} segments, expected 3.` });
74
+ }
75
+ const [head, body, mac] = segments as [string, string, string];
76
+ // An empty signature segment is `alg: none` expressed structurally. Refused before anything is decoded.
77
+ if (head.length === 0 || body.length === 0 || mac.length === 0) {
78
+ throw new PaymentsInvalidReceiptError({ detail: "JWS: a segment is empty; an unsigned token is not accepted." });
79
+ }
80
+
81
+ const header = AppleJwsHeader.safeParse(decodeJson(head, "header"));
82
+ if (!header.success) {
83
+ throw new PaymentsInvalidReceiptError({
84
+ detail: `JWS: header rejected — ${header.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ")}.`,
85
+ });
86
+ }
87
+
88
+ const leaf = await verifyCertificateChain(header.data.x5c.map(decodeBase64), {
89
+ roots: options.roots ?? APPLE_ROOT_CERTIFICATES,
90
+ now: options.now ?? new Date(),
91
+ });
92
+ // ES256 is P-256 with SHA-256. The header claimed ES256; if the leaf is on another curve the two
93
+ // disagree, and the header is the half that cannot be trusted.
94
+ if (leaf.curve !== "P-256") {
95
+ throw new PaymentsVerificationFailedError({
96
+ detail: `JWS: header claims ES256 but the leaf certificate is on ${leaf.curve}.`,
97
+ });
98
+ }
99
+
100
+ const signed = new TextEncoder().encode(`${head}.${body}`);
101
+ const verified = await crypto.subtle.verify(
102
+ { name: "ECDSA", hash: "SHA-256" },
103
+ leaf.key,
104
+ decodeBase64Url(mac) as unknown as ArrayBuffer,
105
+ signed as unknown as ArrayBuffer,
106
+ );
107
+ if (!verified) {
108
+ throw new PaymentsVerificationFailedError({ detail: "JWS: the signature does not match the signed segments." });
109
+ }
110
+
111
+ return decodeJson(body, "payload");
112
+ }
113
+
114
+ /** One base64url JSON segment. `unknown` out — a verified byte string is not yet a known shape. */
115
+ function decodeJson(segment: string, label: string): unknown {
116
+ const text = new TextDecoder().decode(decodeBase64Url(segment));
117
+ try {
118
+ return JSON.parse(text) as unknown;
119
+ } catch (cause) {
120
+ // Never echo the text: on the client-submission path it is caller-supplied.
121
+ throw new PaymentsInvalidReceiptError({ detail: `JWS: ${label} is not JSON.` }, { cause });
122
+ }
123
+ }
@@ -0,0 +1,357 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { minorUnitsFromScaled } from "../../data/money";
6
+ import type { PurchaseEnvironment } from "../../data/purchase";
7
+ import type { PurchaseStatus } from "../../data/status";
8
+ import { PaymentsInvalidReceiptError, PaymentsVerificationFailedError } from "../../error/errors";
9
+ import type { UnboundProviderEvent, VerifiedNotification } from "../contract";
10
+ import { verifyAppleJws } from "./jws";
11
+
12
+ /**
13
+ * App Store Server Notifications V2 → the normalized provider event.
14
+ *
15
+ * This is where Apple's vocabulary stops. Apple has more than twenty notification types and a dozen
16
+ * subtypes; the projection has eight statuses and does not branch on any store's names. Getting a row of
17
+ * that table wrong revokes or grants access to somebody real, which is why each mapping below carries the
18
+ * reason rather than only the answer.
19
+ *
20
+ * Two decisions shape the rest:
21
+ *
22
+ * **A notification can be authentic and mean nothing about a purchase.** A test notification, a consumption
23
+ * request, a declined refund, a bulk renewal-extension summary — all real, none a state change. And Apple
24
+ * adds types: a type this package has never heard of must not be an error, because answering non-2xx makes
25
+ * Apple retry it forever and reads as a broken endpoint. So `event` is nullable, the row is recorded either
26
+ * way, and the reconciliation pass is what notices if something meaningful was missed.
27
+ *
28
+ * **The bundle id is checked, twice.** A valid Apple signature proves Apple signed the notification, not
29
+ * that it is about our app — Apple signs every developer's notifications with the same chain. Without a
30
+ * bundle check, another developer's transaction verifies here, and any SKU string that happens to match our
31
+ * catalog becomes a free entitlement. Both the envelope's `data.bundleId` and the nested transaction's own
32
+ * `bundleId` are checked, because a notification assembled around somebody else's transaction would pass a
33
+ * check on either one alone.
34
+ */
35
+
36
+ /** Apple's price scale: `price` is in thousandths of the currency unit, not in minor units. */
37
+ export const APPLE_PRICE_SCALE = 1000;
38
+
39
+ /**
40
+ * The transaction payload, as much of it as the projection uses. `.loose()` keeps the rest: Apple adds
41
+ * fields, the whole payload is stored for reconciliation, and a strict object would reject a notification
42
+ * for carrying something new.
43
+ */
44
+ export const AppleTransactionInfo = z
45
+ .object({
46
+ transactionId: z
47
+ .string()
48
+ .min(1)
49
+ .describe("Apple's own id for this transaction — the projection's idempotency anchor."),
50
+ originalTransactionId: z
51
+ .string()
52
+ .min(1)
53
+ .optional()
54
+ .describe("The transaction that started the subscription, chaining renewals back to it. Absent for a one-off."),
55
+ bundleId: z
56
+ .string()
57
+ .min(1)
58
+ .describe(
59
+ "The app the purchase was made in. Checked against our own — a signature alone does not prove the app.",
60
+ ),
61
+ productId: z.string().min(1).describe("The App Store Connect product identifier — the SKU the catalog maps."),
62
+ purchaseDate: z.number().int().describe("When the store recorded the purchase, in milliseconds since the epoch."),
63
+ expiresDate: z
64
+ .number()
65
+ .int()
66
+ .optional()
67
+ .describe("When the paid period ends, for a subscription. Absent for a one-time purchase."),
68
+ revocationDate: z.number().int().optional().describe("When the purchase was refunded or revoked, if it was."),
69
+ signedDate: z.number().int().describe("When Apple signed this transaction — its own clock, not ours."),
70
+ environment: z
71
+ .string()
72
+ .min(1)
73
+ .describe("`Production`, `Sandbox`, or a local-testing value. Anything but Production is treated as sandbox."),
74
+ appAccountToken: z
75
+ .string()
76
+ .min(1)
77
+ .optional()
78
+ .describe(
79
+ "The UUID the app set at purchase time. The only hook back to a Pithy user, so a purchase without one arrives orphaned.",
80
+ ),
81
+ price: z
82
+ .number()
83
+ .int()
84
+ .optional()
85
+ .describe("The price in thousandths of the currency unit — Apple's milliunits, not minor units."),
86
+ currency: z.string().min(1).optional().describe("The ISO currency the price is in."),
87
+ })
88
+ .loose()
89
+ .describe("Apple's JWSTransactionDecodedPayload, as much of it as the projection reads.");
90
+
91
+ /**
92
+ * The renewal state beside a transaction, as much of it as the projection reads. `.loose()` for the same
93
+ * reason every other Apple schema is: Apple adds fields, and the payload is stored whole.
94
+ *
95
+ * Two fields, and both decide access rather than describe it. Neither is on the transaction, which is the
96
+ * whole reason this payload is read at all: a transaction says when the paid period ended, and only the
97
+ * renewal info says whether a card is being retried past it and until when.
98
+ */
99
+ export const AppleRenewalInfo = z
100
+ .object({
101
+ autoRenewStatus: z
102
+ .number()
103
+ .int()
104
+ .optional()
105
+ .describe(
106
+ "Whether the subscription will renew — 1 on, 0 off. Off with an active status is `canceled`, which still grants until the paid period ends.",
107
+ ),
108
+ gracePeriodExpiresDate: z
109
+ .number()
110
+ .int()
111
+ .optional()
112
+ .describe(
113
+ "When the billing-retry grace window ends, in milliseconds. This is what access runs to while Apple retries the card — the transaction's own `expiresDate` has already passed by then.",
114
+ ),
115
+ })
116
+ .loose()
117
+ .describe("Apple's JWSRenewalInfoDecodedPayload, as much of it as the status and the access window read.");
118
+
119
+ /** The notification envelope. Also `.loose()`, and for the same reason. */
120
+ const AppleNotificationPayload = z
121
+ .object({
122
+ notificationType: z
123
+ .string()
124
+ .min(1)
125
+ .describe("What happened, in Apple's vocabulary. Mapped to a normalized status."),
126
+ subtype: z
127
+ .string()
128
+ .min(1)
129
+ .optional()
130
+ .describe("The qualifier that decides several mappings — GRACE_PERIOD, AUTO_RENEW_DISABLED, INITIAL_BUY."),
131
+ notificationUUID: z
132
+ .string()
133
+ .min(1)
134
+ .describe("Apple's id for this delivery. The dedupe key: Apple delivers at-least-once and retries."),
135
+ signedDate: z
136
+ .number()
137
+ .int()
138
+ .describe(
139
+ "When Apple signed the notification. This is the provider event time the monotonic write rule compares.",
140
+ ),
141
+ data: z
142
+ .object({
143
+ bundleId: z.string().min(1).describe("The app the notification is about. Checked against our own."),
144
+ environment: z.string().min(1).describe("The store environment the event happened in."),
145
+ signedTransactionInfo: z
146
+ .string()
147
+ .min(1)
148
+ .optional()
149
+ .describe("The transaction, as its own signed JWS. Absent for a notification that reports no transaction."),
150
+ signedRenewalInfo: z
151
+ .string()
152
+ .min(1)
153
+ .optional()
154
+ .describe("The subscription's renewal state, as its own signed JWS."),
155
+ })
156
+ .loose()
157
+ .optional()
158
+ .describe("The event's data. Absent on the notification types that carry a summary instead."),
159
+ })
160
+ .loose()
161
+ .describe("Apple's responseBodyV2DecodedPayload — one App Store Server Notification V2.");
162
+
163
+ /** What parsing a notification needs: our bundle id, the roots to trust, and the clock. */
164
+ export interface ParseAppleNotificationOptions {
165
+ /** Our own bundle id. A notification for any other app is refused. */
166
+ bundleId: string;
167
+ /** base64 DER of every acceptable root. Defaults to the pinned Apple roots. */
168
+ roots?: readonly string[];
169
+ /** The clock, for certificate validity. */
170
+ now?: Date;
171
+ }
172
+
173
+ /**
174
+ * Apple's store environment, normalized. Anything that is not literally `Production` is sandbox — including
175
+ * Xcode's `LocalTesting`, and including a value Apple has not published yet. Defaulting the other way is the
176
+ * most common in-app-purchase security defect there is, so the safe side is the default side.
177
+ */
178
+ export function appleEnvironment(environment: string): PurchaseEnvironment {
179
+ return environment === "Production" ? "production" : "sandbox";
180
+ }
181
+
182
+ /**
183
+ * Apple's notificationType/subtype pair → the normalized status, or null when the notification reports no
184
+ * transaction state.
185
+ *
186
+ * Every branch carries its reason. The two worth reading twice: `AUTO_RENEW_DISABLED` is `canceled` and
187
+ * `canceled` still grants — a user who declines the *next* period keeps the one they paid for, and
188
+ * `expiresAt` is what ends it. And `DID_FAIL_TO_RENEW` means two different things depending on its subtype:
189
+ * with `GRACE_PERIOD` the retry window is running and access continues; bare, it is not, and it does not.
190
+ */
191
+ export function appleStatus(notificationType: string, subtype: string | undefined): PurchaseStatus | null {
192
+ switch (notificationType) {
193
+ // A new subscription, a resubscription, a renewal, a redeemed offer, an extended expiry, and a one-time
194
+ // charge are all the same statement: this transaction is paid and current.
195
+ case "SUBSCRIBED":
196
+ case "DID_RENEW":
197
+ case "OFFER_REDEEMED":
198
+ case "RENEWAL_EXTENDED":
199
+ case "ONE_TIME_CHARGE":
200
+ return "active";
201
+ // Apple reverses a refund it granted in error. Leaving the row refunded would strip a paying user.
202
+ case "REFUND_REVERSED":
203
+ return "active";
204
+ // A plan change takes effect at the next renewal, so the current transaction is untouched.
205
+ case "DID_CHANGE_RENEWAL_PREF":
206
+ return "active";
207
+ case "DID_CHANGE_RENEWAL_STATUS":
208
+ return subtype === "AUTO_RENEW_DISABLED" ? "canceled" : "active";
209
+ // Billing failed. `GRACE_PERIOD` says the retry window is open, which is the whole point of grace: a
210
+ // failed card should not lock a subscriber out mid-period. Bare means there is no grace to run. How long
211
+ // the window runs is on the renewal info rather than the transaction — see {@link appleExpiresAt}.
212
+ case "DID_FAIL_TO_RENEW":
213
+ return subtype === "GRACE_PERIOD" ? "in_grace" : "on_hold";
214
+ // Grace is over and the renewal genuinely failed. `on_hold` never grants.
215
+ case "GRACE_PERIOD_EXPIRED":
216
+ return "on_hold";
217
+ case "EXPIRED":
218
+ return "expired";
219
+ case "REFUND":
220
+ return "refunded";
221
+ // Family Sharing access withdrawn, or a purchase revoked.
222
+ case "REVOKE":
223
+ return "revoked";
224
+ default:
225
+ // TEST, CONSUMPTION_REQUEST, REFUND_DECLINED, PRICE_INCREASE, RENEWAL_EXTENSION,
226
+ // EXTERNAL_PURCHASE_TOKEN, METADATA_UPDATE — and every type Apple ships after this package did. All
227
+ // authentic, none a transaction state. Recorded and passed over, never an error.
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * When access ends: the paid period, extended to the grace window while a card is being retried.
234
+ *
235
+ * Grace exists so a failed renewal does not lock a paying subscriber out, and that only works if the row says
236
+ * so — `in_grace` with the expiry that just passed is read as lapsed by every access check, which revokes the
237
+ * subscriber in the same commit that recorded the grace period.
238
+ *
239
+ * Three rules, each holding a real payload up: the window is read **only** for `in_grace`, because Apple
240
+ * leaves `gracePeriodExpiresDate` on the renewal info after the retry is over and reading it on an `EXPIRED`
241
+ * would extend a subscription that has ended. It is the **later** of the two dates, so a grace date behind the
242
+ * expiry cannot shorten a period already paid for. And **no expiry stays no expiry** — a purchase that does not
243
+ * end is not narrowed by a window.
244
+ */
245
+ export function appleExpiresAt(
246
+ status: PurchaseStatus,
247
+ expiresDate: number | undefined,
248
+ gracePeriodExpiresDate: number | undefined,
249
+ ): Date | null {
250
+ if (expiresDate === undefined) return null;
251
+ const grace = status === "in_grace" ? gracePeriodExpiresDate : undefined;
252
+ return new Date(grace === undefined ? expiresDate : Math.max(expiresDate, grace));
253
+ }
254
+
255
+ /** Verify a nested JWS and parse it against `schema`, naming the field in any refusal. */
256
+ async function verifyNested<T extends z.ZodType>(
257
+ jws: string,
258
+ schema: T,
259
+ field: string,
260
+ options: ParseAppleNotificationOptions,
261
+ ): Promise<z.output<T>> {
262
+ const payload = await verifyAppleJws(jws, { roots: options.roots, now: options.now });
263
+ const parsed = schema.safeParse(payload);
264
+ if (!parsed.success) {
265
+ throw new PaymentsInvalidReceiptError({
266
+ detail: `Apple: ${field} rejected — ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ")}.`,
267
+ });
268
+ }
269
+ return parsed.data as z.output<T>;
270
+ }
271
+
272
+ /**
273
+ * Refuse a payload that belongs to another app. `detail` names both ids; the client is told neither.
274
+ *
275
+ * Exported because the client-submission path needs the identical check: a signature proves Apple signed the
276
+ * transaction, never that the transaction is about our app, and one copy of that rule is the only way the two
277
+ * paths cannot diverge.
278
+ */
279
+ export function requireAppleBundle(found: string, expected: string, where: string): void {
280
+ if (found === expected) return;
281
+ throw new PaymentsVerificationFailedError({
282
+ message: "That notification is not for this app.",
283
+ action: "Check that the App Store Connect app and this deployment's bundle id are the same.",
284
+ detail: `Apple: ${where} bundle id is "${found}", this app is "${expected}".`,
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Verify and parse one App Store Server Notification V2.
290
+ *
291
+ * Both the outer notification and the nested transaction are verified independently. The outer signature
292
+ * already covers the inner JWS bytes, so verifying the inner one is belt as well as braces — but it costs
293
+ * one more chain check and removes a whole class of assumption about what the outer signature implies.
294
+ */
295
+ export async function parseAppleNotification(
296
+ signedPayload: string,
297
+ options: ParseAppleNotificationOptions,
298
+ ): Promise<VerifiedNotification> {
299
+ const notification = await verifyNested(signedPayload, AppleNotificationPayload, "notification", options);
300
+ if (notification.data) requireAppleBundle(notification.data.bundleId, options.bundleId, "notification");
301
+
302
+ const status = appleStatus(notification.notificationType, notification.subtype);
303
+ // The payload is stored whole, nested JWS included, which is what makes the row genuinely replayable.
304
+ const payload = notification as Record<string, unknown>;
305
+
306
+ if (status === null)
307
+ return { providerEventId: notification.notificationUUID, payload, event: null, providerAccountId: null };
308
+
309
+ const signedTransaction = notification.data?.signedTransactionInfo;
310
+ if (!signedTransaction) {
311
+ // A state-changing type with no transaction is not something Apple sends. Deriving a transaction id from
312
+ // the envelope instead would key the purchase row on the notification, so a redelivery under a new UUID
313
+ // would become a second purchase.
314
+ throw new PaymentsInvalidReceiptError({
315
+ detail: `Apple: ${notification.notificationType} carries no signedTransactionInfo, so there is no transaction to project.`,
316
+ });
317
+ }
318
+
319
+ const transaction = await verifyNested(signedTransaction, AppleTransactionInfo, "transaction", options);
320
+ requireAppleBundle(transaction.bundleId, options.bundleId, "transaction");
321
+
322
+ // The renewal info is read for one status and one field: `in_grace` runs to the end of the retry window, and
323
+ // that date is beside the transaction rather than on it. Every other status is decided by the transaction
324
+ // alone, and this costs a third chain verification — so it is paid where it changes an answer and nowhere
325
+ // else. A grace notification arriving without it is not something Apple sends; if one ever does, the paid
326
+ // period stands and the reconciliation pass re-asks Apple, which is milder than refusing a notification
327
+ // Apple would then retry forever.
328
+ const renewal =
329
+ status === "in_grace" && notification.data?.signedRenewalInfo !== undefined
330
+ ? await verifyNested(notification.data.signedRenewalInfo, AppleRenewalInfo, "renewal info", options)
331
+ : undefined;
332
+
333
+ const event: UnboundProviderEvent = {
334
+ rail: "apple",
335
+ providerTransactionId: transaction.transactionId,
336
+ providerProductId: transaction.productId,
337
+ status,
338
+ environment: appleEnvironment(transaction.environment),
339
+ purchasedAt: new Date(transaction.purchaseDate),
340
+ expiresAt: appleExpiresAt(status, transaction.expiresDate, renewal?.gracePeriodExpiresDate),
341
+ revokedAt: transaction.revocationDate === undefined ? null : new Date(transaction.revocationDate),
342
+ originalTransactionId: transaction.originalTransactionId ?? null,
343
+ amountMinor: minorUnitsFromScaled(transaction.price ?? null, APPLE_PRICE_SCALE, transaction.currency ?? null),
344
+ currency: transaction.currency ?? null,
345
+ // Apple's own clock on the notification, not the transaction's and certainly not ours. The monotonic
346
+ // write rule compares against it, and a receipt time would only record delivery order.
347
+ providerEventAt: new Date(notification.signedDate),
348
+ payload,
349
+ };
350
+
351
+ return {
352
+ providerEventId: notification.notificationUUID,
353
+ payload,
354
+ event,
355
+ providerAccountId: transaction.appAccountToken ?? null,
356
+ };
357
+ }