@pithy-sh/payments 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- package/src/workflows/wrangler.jsonc +55 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { PaymentsPurchase } from "../../data/purchase";
|
|
6
|
+
import { PaymentsInvalidReceiptError } from "../../error/errors";
|
|
7
|
+
import type { PaymentsAppleCredentials } from "../../secret/registry";
|
|
8
|
+
import type {
|
|
9
|
+
PaymentsRailProvider,
|
|
10
|
+
RailRequestContext,
|
|
11
|
+
UnboundProviderEvent,
|
|
12
|
+
VerifiedNotification,
|
|
13
|
+
VerifiedPurchase,
|
|
14
|
+
WebhookDelivery,
|
|
15
|
+
} from "../contract";
|
|
16
|
+
import { APPLE_ROOT_CERTIFICATES } from "./certs";
|
|
17
|
+
import type { AppleHttpFetch } from "./http";
|
|
18
|
+
import { parseAppleNotification } from "./notification";
|
|
19
|
+
import { refreshAppleSubscription } from "./serverApi";
|
|
20
|
+
import { verifyAppleTransaction } from "./verify";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The Apple rail as one provider object — the two halves of the contract, closed over the app's identity.
|
|
24
|
+
*
|
|
25
|
+
* Built per request from credentials the caller resolved through the secrets store, rather than reading them
|
|
26
|
+
* itself. That keeps the rail a pure function of its inputs (so every test above runs with no secrets
|
|
27
|
+
* machinery), and it keeps the read at the point of need, which is what CLAUDE.md asks for.
|
|
28
|
+
*
|
|
29
|
+
* Apple needs no credential to *verify* anything: authenticity rests on the public certificate chain pinned
|
|
30
|
+
* in `certs.ts`. What it needs from the bundle is the `bundleId` — the app's identity, checked on every
|
|
31
|
+
* payload, because Apple signs every developer's notifications with the same chain. The App Store Connect key
|
|
32
|
+
* in the same block is for calling the App Store Server API, which the reconciliation Workflow does.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Apple's webhook body: one field, carrying the whole signed notification.
|
|
37
|
+
*
|
|
38
|
+
* Declared here as well as in `http/schemas.ts`, and the duplication is the lesser evil. The rail parses the
|
|
39
|
+
* bytes it was handed in order to *verify* them; the route's validator gives the handler a typed value. The
|
|
40
|
+
* rail is the lower layer, so it must not import an HTTP schema, and re-exporting one across the seam would
|
|
41
|
+
* be worse than six lines. The two must agree — change one, change the other.
|
|
42
|
+
*/
|
|
43
|
+
const AppleWebhookBody = z
|
|
44
|
+
.object({
|
|
45
|
+
signedPayload: z
|
|
46
|
+
.string()
|
|
47
|
+
.min(1)
|
|
48
|
+
.describe("The App Store Server Notification V2, as a compact JWS. Everything else about the delivery is in it."),
|
|
49
|
+
})
|
|
50
|
+
.describe("The body Apple POSTs to the notification endpoint.");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How the Apple rail's trust set may be widened, and the only two callers with a reason to.
|
|
54
|
+
*
|
|
55
|
+
* Apple's pinned roots are always trusted and can never be removed — `trustedRoots` is additive, so no caller
|
|
56
|
+
* can narrow production's trust to nothing. It exists because two legitimate chains are not Apple's: the tests,
|
|
57
|
+
* which mint their own chain so signature verification is exercised for real, and **Xcode's StoreKit local
|
|
58
|
+
* testing**, whose transactions are signed by a per-machine root that only exists on the developer's Mac.
|
|
59
|
+
*/
|
|
60
|
+
export interface AppleRailOptions {
|
|
61
|
+
/** Roots to accept *in addition to* Apple's pinned ones, base64 DER. Empty in production. */
|
|
62
|
+
trustedRoots?: readonly string[];
|
|
63
|
+
/** The HTTP seam the App Store Server API is reached through. Defaults to the runtime's `fetch`. */
|
|
64
|
+
transport?: AppleHttpFetch;
|
|
65
|
+
/**
|
|
66
|
+
* An App Store Connect token already minted, so a batch of refreshes pays for one. Passed down by the
|
|
67
|
+
* reconciliation Workflow; absent everywhere else, where one call mints its own and drops it.
|
|
68
|
+
*/
|
|
69
|
+
apiToken?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The Apple rail. Verifies client submissions locally, and App Store Server Notifications V2 on the webhook. */
|
|
73
|
+
export function appleRail(credentials: PaymentsAppleCredentials, options: AppleRailOptions = {}): PaymentsRailProvider {
|
|
74
|
+
const roots = options.trustedRoots?.length ? [...APPLE_ROOT_CERTIFICATES, ...options.trustedRoots] : undefined;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
rail: "apple",
|
|
78
|
+
|
|
79
|
+
async verify(receipt: string, context: RailRequestContext): Promise<VerifiedPurchase> {
|
|
80
|
+
return verifyAppleTransaction(receipt, { bundleId: credentials.bundleId, roots, now: context.now });
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async parseNotification(delivery: WebhookDelivery, context: RailRequestContext): Promise<VerifiedNotification> {
|
|
84
|
+
let body: unknown;
|
|
85
|
+
try {
|
|
86
|
+
body = JSON.parse(delivery.body) as unknown;
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
throw new PaymentsInvalidReceiptError({ detail: "Apple: the notification body is not JSON." }, { cause });
|
|
89
|
+
}
|
|
90
|
+
const parsed = AppleWebhookBody.safeParse(body);
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
throw new PaymentsInvalidReceiptError({ detail: "Apple: the notification body carries no signedPayload." });
|
|
93
|
+
}
|
|
94
|
+
return parseAppleNotification(parsed.data.signedPayload, {
|
|
95
|
+
bundleId: credentials.bundleId,
|
|
96
|
+
roots,
|
|
97
|
+
now: context.now,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async refresh(purchase: PaymentsPurchase, context: RailRequestContext): Promise<UnboundProviderEvent | undefined> {
|
|
102
|
+
// `return await`, not `return`. Returning a promise from an async function makes this frame *adopt* the
|
|
103
|
+
// rejection rather than raising it, and workerd then reports the adopted promise as an unhandled
|
|
104
|
+
// rejection even though the Workflow step catches it and retries.
|
|
105
|
+
return await refreshAppleSubscription(purchase, {
|
|
106
|
+
credentials,
|
|
107
|
+
now: context.now,
|
|
108
|
+
transport: options.transport,
|
|
109
|
+
roots,
|
|
110
|
+
token: options.apiToken,
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
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 { PaymentsPurchase, PurchaseEnvironment } from "../../data/purchase";
|
|
7
|
+
import type { PurchaseStatus } from "../../data/status";
|
|
8
|
+
import { PaymentsRailNotConfiguredError, PaymentsVerificationFailedError } from "../../error/errors";
|
|
9
|
+
import type { PaymentsAppleCredentials } from "../../secret/registry";
|
|
10
|
+
import type { UnboundProviderEvent } from "../contract";
|
|
11
|
+
import { decodeBase64 } from "./der";
|
|
12
|
+
import { type AppleHttpFetch, appleHttpFetch, appleJson } from "./http";
|
|
13
|
+
import { verifyAppleJws } from "./jws";
|
|
14
|
+
import {
|
|
15
|
+
APPLE_PRICE_SCALE,
|
|
16
|
+
AppleRenewalInfo,
|
|
17
|
+
AppleTransactionInfo,
|
|
18
|
+
appleEnvironment,
|
|
19
|
+
appleExpiresAt,
|
|
20
|
+
requireAppleBundle,
|
|
21
|
+
} from "./notification";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The App Store Server API — the call that answers "what is this subscription doing now", and the mapping of
|
|
25
|
+
* Apple's status vocabulary into the normalized set.
|
|
26
|
+
*
|
|
27
|
+
* This is the half of Apple's rail nothing else needs. A client submission and a notification both arrive
|
|
28
|
+
* signed, so they verify offline and cost no round-trip. Reconciliation has neither: the whole point of it is
|
|
29
|
+
* the subscription **nothing arrived about**, because Apple stopped renewing and sent nothing, or sent
|
|
30
|
+
* something that never reached us. Asking Apple is the only way to learn that, and this is where it is asked.
|
|
31
|
+
*
|
|
32
|
+
* ## Two facts, and only one of them is in the transaction
|
|
33
|
+
*
|
|
34
|
+
* `GET /inApps/v1/subscriptions/{originalTransactionId}` returns, per subscription group, the last transaction
|
|
35
|
+
* plus a **numeric status** — and the numeric status carries what the signed transaction cannot. A transaction
|
|
36
|
+
* says when it expires; it does not say that Apple is in a billing retry, or that the subscriber is inside a
|
|
37
|
+
* grace period, or how long that grace runs, or that auto-renew was switched off. So the status **and the
|
|
38
|
+
* access window** come from Apple's number and the renewal info beside it, and everything else — the product,
|
|
39
|
+
* the purchase date, the price, the account token — comes from the signed transaction, verified against the
|
|
40
|
+
* same pinned chain every other Apple payload is.
|
|
41
|
+
*
|
|
42
|
+
* ## The two hosts are not interchangeable
|
|
43
|
+
*
|
|
44
|
+
* Sandbox and production are separate services with separate data. A production transaction id asked of the
|
|
45
|
+
* sandbox host is a 404, not an error, so the host is chosen from the **stored row's own environment** rather
|
|
46
|
+
* than from the deployment's or from a probe. That also means a sandbox row in a production database — which
|
|
47
|
+
* the writer refuses to create — could never be refreshed into one.
|
|
48
|
+
*
|
|
49
|
+
* ## What is not here
|
|
50
|
+
*
|
|
51
|
+
* **One-time purchases.** Apple's subscription endpoint is for auto-renewables only, and a consumable or
|
|
52
|
+
* non-consumable does not drift: it is bought once, and the single thing that can change it — a refund —
|
|
53
|
+
* arrives as a `REFUND` notification. There is a transaction-history endpoint that would answer for one, and
|
|
54
|
+
* nothing in this module needs it, so nothing here calls it.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The App Store Server API's two bases, one per store environment.
|
|
59
|
+
*
|
|
60
|
+
* Public and stable, so they are pinned rather than configured. The keying is on the *purchase's* environment,
|
|
61
|
+
* which is why this is a record rather than a single constant.
|
|
62
|
+
*/
|
|
63
|
+
export const APPLE_API_BASES: Readonly<Record<PurchaseEnvironment, string>> = {
|
|
64
|
+
production: "https://api.storekit.itunes.apple.com/inApps/v1",
|
|
65
|
+
sandbox: "https://api.storekit-sandbox.itunes.apple.com/inApps/v1",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The audience every App Store Connect API token claims. Apple's own literal, not a URL. */
|
|
69
|
+
export const APPLE_API_AUDIENCE = "appstoreconnect-v1";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How long a minted token claims to be good for. Apple refuses anything over an hour, and a token minted at
|
|
73
|
+
* the ceiling is one clock-skew away from being refused — so this sits comfortably under it.
|
|
74
|
+
*/
|
|
75
|
+
export const APPLE_TOKEN_LIFETIME_SECONDS = 1800;
|
|
76
|
+
|
|
77
|
+
/** ECDSA over P-256 with SHA-256 — `ES256`, the only algorithm App Store Connect accepts. */
|
|
78
|
+
const ES256 = { name: "ECDSA", namedCurve: "P-256" } as const;
|
|
79
|
+
|
|
80
|
+
/** The PEM armour an App Store Connect `.p8` carries. Apple issues PKCS#8 and nothing else. */
|
|
81
|
+
const PKCS8_HEADER = "-----BEGIN PRIVATE KEY-----";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apple's subscription status codes, mapped.
|
|
85
|
+
*
|
|
86
|
+
* Three are worth reading twice. **3 is billing retry and does not grant**: Apple is retrying the card and the
|
|
87
|
+
* subscriber's access has already ended, which is what `on_hold` means. **4 is the grace period and does
|
|
88
|
+
* grant** — that is the point of grace, a failed card should not lock a paying subscriber out mid-period, and
|
|
89
|
+
* the window it grants through is the renewal info's `gracePeriodExpiresDate` rather than the transaction's
|
|
90
|
+
* expiry, which has already passed by the time Apple says 4. And
|
|
91
|
+
* **auto-renew off is `canceled`, which also grants**: declining the *next* period does not forfeit the one
|
|
92
|
+
* already paid for, and `expiresAt` is what ends it.
|
|
93
|
+
*/
|
|
94
|
+
const SUBSCRIPTION_STATUSES: Readonly<Record<number, PurchaseStatus>> = {
|
|
95
|
+
1: "active",
|
|
96
|
+
2: "expired",
|
|
97
|
+
3: "on_hold",
|
|
98
|
+
4: "in_grace",
|
|
99
|
+
5: "revoked",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Apple's `autoRenewStatus` for a subscription the user has switched off. */
|
|
103
|
+
const AUTO_RENEW_OFF = 0;
|
|
104
|
+
|
|
105
|
+
/** One entry of a subscription group's `lastTransactions` array. */
|
|
106
|
+
const AppleLastTransaction = z
|
|
107
|
+
.object({
|
|
108
|
+
originalTransactionId: z
|
|
109
|
+
.string()
|
|
110
|
+
.min(1)
|
|
111
|
+
.describe("The transaction that started this subscription — how the right entry is picked out of the group."),
|
|
112
|
+
status: z
|
|
113
|
+
.number()
|
|
114
|
+
.int()
|
|
115
|
+
.describe(
|
|
116
|
+
"Apple's numeric subscription status: 1 active, 2 expired, 3 billing retry, 4 grace period, 5 revoked. The one fact the signed transaction does not carry.",
|
|
117
|
+
),
|
|
118
|
+
signedTransactionInfo: z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1)
|
|
121
|
+
.describe("The latest transaction, as its own JWS. Verified against the pinned chain like every other one."),
|
|
122
|
+
signedRenewalInfo: z
|
|
123
|
+
.string()
|
|
124
|
+
.min(1)
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("The renewal state, as its own JWS. Absent for a subscription with no renewal left to describe."),
|
|
127
|
+
})
|
|
128
|
+
.loose()
|
|
129
|
+
.describe("One subscription's latest transaction and its status, as the App Store Server API reports it.");
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The App Store Server API's answer to a subscription-status query.
|
|
133
|
+
*
|
|
134
|
+
* `data` is one entry per subscription group, and each carries every transaction in the family the queried id
|
|
135
|
+
* belongs to. In practice one id resolves to one group with one transaction; the shape allows more, so the
|
|
136
|
+
* caller matches on the id rather than trusting position.
|
|
137
|
+
*/
|
|
138
|
+
export const AppleSubscriptionStatuses = z
|
|
139
|
+
.object({
|
|
140
|
+
environment: z
|
|
141
|
+
.string()
|
|
142
|
+
.min(1)
|
|
143
|
+
.describe("`Production` or `Sandbox`, as the answering host reports it. Checked against the row's own."),
|
|
144
|
+
bundleId: z.string().min(1).describe("The app the subscription belongs to. Checked against our own."),
|
|
145
|
+
data: z
|
|
146
|
+
.array(
|
|
147
|
+
z
|
|
148
|
+
.object({
|
|
149
|
+
lastTransactions: z
|
|
150
|
+
.array(AppleLastTransaction)
|
|
151
|
+
.describe("The latest transaction per subscription in this group, with its status."),
|
|
152
|
+
})
|
|
153
|
+
.loose()
|
|
154
|
+
.describe("One subscription group's latest state."),
|
|
155
|
+
)
|
|
156
|
+
.describe("One entry per subscription group the queried transaction belongs to."),
|
|
157
|
+
})
|
|
158
|
+
.loose()
|
|
159
|
+
.describe("Apple's StatusResponse — the current state of a subscription family.");
|
|
160
|
+
export type AppleSubscriptionStatuses = z.infer<typeof AppleSubscriptionStatuses>;
|
|
161
|
+
|
|
162
|
+
/** What an App Store Server API call needs: the credentials, a clock, the transport, and the trust set. */
|
|
163
|
+
export interface AppleServerApiOptions {
|
|
164
|
+
/** Apple's credential block. The key signs the token; the bundle id is checked on the answer. */
|
|
165
|
+
credentials: PaymentsAppleCredentials;
|
|
166
|
+
/** The clock. It dates the token, and it becomes the refreshed event's provider event time. */
|
|
167
|
+
now: Date;
|
|
168
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
169
|
+
transport?: AppleHttpFetch;
|
|
170
|
+
/** Roots accepted in addition to Apple's pinned ones, base64 DER. Additive only, and empty in production. */
|
|
171
|
+
roots?: readonly string[];
|
|
172
|
+
/**
|
|
173
|
+
* A token already minted, so a batch of lookups pays for one.
|
|
174
|
+
*
|
|
175
|
+
* Deliberately a parameter rather than a module-level cache: a bearer credential derived from a secret is
|
|
176
|
+
* exactly what CLAUDE.md forbids caching in a module variable. So a single call mints one and drops it, and
|
|
177
|
+
* the reconciliation Workflow — the caller that makes hundreds — mints one and passes it down.
|
|
178
|
+
*/
|
|
179
|
+
token?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The DER bytes of an App Store Connect `.p8`.
|
|
184
|
+
*
|
|
185
|
+
* A copy of what the Google rail does with its service-account key, and a copy on purpose: a rail reaching
|
|
186
|
+
* into another rail's module for a helper would make the two fail together, and the same choice was already
|
|
187
|
+
* made for the two spellings of the webhook body schema. Twelve lines is the cheaper coupling.
|
|
188
|
+
*/
|
|
189
|
+
function applePrivateKey(pem: string): Uint8Array {
|
|
190
|
+
// `.p8` contents pasted through a JSON secret arrive with literal backslash-n rather than newlines.
|
|
191
|
+
const text = pem.replaceAll("\\n", "\n");
|
|
192
|
+
if (!text.includes(PKCS8_HEADER)) {
|
|
193
|
+
throw new PaymentsRailNotConfiguredError({
|
|
194
|
+
detail: text.includes("PRIVATE KEY")
|
|
195
|
+
? "Apple: the App Store Connect key is not PKCS#8. Store the downloaded `.p8` verbatim — it begins `-----BEGIN PRIVATE KEY-----`."
|
|
196
|
+
: "Apple: the App Store Connect key carries no PEM armour. Store the downloaded `.p8` file's contents verbatim, header and footer included.",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const body = text
|
|
200
|
+
.slice(text.indexOf(PKCS8_HEADER) + PKCS8_HEADER.length)
|
|
201
|
+
.replace(/-----END PRIVATE KEY-----[\s\S]*$/, "")
|
|
202
|
+
.replaceAll(/\s+/g, "");
|
|
203
|
+
try {
|
|
204
|
+
return decodeBase64(body);
|
|
205
|
+
} catch (cause) {
|
|
206
|
+
throw new PaymentsRailNotConfiguredError(
|
|
207
|
+
{ detail: "Apple: the App Store Connect key's PEM body is not valid base64." },
|
|
208
|
+
{ cause },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** base64url of raw bytes, unpadded — the encoding every JWT segment uses. */
|
|
214
|
+
function base64Url(bytes: Uint8Array): string {
|
|
215
|
+
let binary = "";
|
|
216
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
217
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** base64url of a JSON value — a JWT header or claim set. */
|
|
221
|
+
function base64UrlJson(value: unknown): string {
|
|
222
|
+
return base64Url(new TextEncoder().encode(JSON.stringify(value)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mint an App Store Connect API token from the `.p8` key.
|
|
227
|
+
*
|
|
228
|
+
* A plain ES256 JWT: Apple issues no OAuth flow for this API, so the assertion *is* the credential. The `bid`
|
|
229
|
+
* claim is what scopes it to one app — a token without it is refused, which is why the bundle id sits in the
|
|
230
|
+
* credential block beside the key rather than in config.
|
|
231
|
+
*/
|
|
232
|
+
export async function mintAppleApiToken(
|
|
233
|
+
credentials: PaymentsAppleCredentials,
|
|
234
|
+
options: { now: Date },
|
|
235
|
+
): Promise<string> {
|
|
236
|
+
let key: CryptoKey;
|
|
237
|
+
try {
|
|
238
|
+
key = await crypto.subtle.importKey(
|
|
239
|
+
"pkcs8",
|
|
240
|
+
applePrivateKey(credentials.privateKey) as unknown as ArrayBuffer,
|
|
241
|
+
ES256,
|
|
242
|
+
false,
|
|
243
|
+
["sign"],
|
|
244
|
+
);
|
|
245
|
+
} catch (cause) {
|
|
246
|
+
if (cause instanceof PaymentsRailNotConfiguredError) throw cause;
|
|
247
|
+
// A key that will not import is a provisioning failure, not a store outage, and it will not fix itself.
|
|
248
|
+
throw new PaymentsRailNotConfiguredError(
|
|
249
|
+
{
|
|
250
|
+
detail:
|
|
251
|
+
"Apple: the App Store Connect private key could not be imported as a P-256 key. Check that the `.p8` was stored whole and belongs to an App Store Connect API key.",
|
|
252
|
+
},
|
|
253
|
+
{ cause },
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const issuedAt = Math.floor(options.now.getTime() / 1000);
|
|
258
|
+
const header = base64UrlJson({ alg: "ES256", kid: credentials.keyId, typ: "JWT" });
|
|
259
|
+
const claims = base64UrlJson({
|
|
260
|
+
iss: credentials.issuerId,
|
|
261
|
+
iat: issuedAt,
|
|
262
|
+
exp: issuedAt + APPLE_TOKEN_LIFETIME_SECONDS,
|
|
263
|
+
aud: APPLE_API_AUDIENCE,
|
|
264
|
+
bid: credentials.bundleId,
|
|
265
|
+
});
|
|
266
|
+
const signature = await crypto.subtle.sign(
|
|
267
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
268
|
+
key,
|
|
269
|
+
new TextEncoder().encode(`${header}.${claims}`) as unknown as ArrayBuffer,
|
|
270
|
+
);
|
|
271
|
+
// WebCrypto's ECDSA signature is already the raw r‖s pair a JWS wants, not the DER wrapper X.509 uses.
|
|
272
|
+
return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The token to send: the one the caller minted for the batch, or one minted for this call alone. */
|
|
276
|
+
async function bearer(options: AppleServerApiOptions): Promise<string> {
|
|
277
|
+
return options.token ?? (await mintAppleApiToken(options.credentials, { now: options.now }));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The normalized status for an Apple subscription status code and its renewal flag. */
|
|
281
|
+
export function appleSubscriptionStatus(status: number, autoRenewStatus: number | undefined): PurchaseStatus {
|
|
282
|
+
const mapped = SUBSCRIPTION_STATUSES[status];
|
|
283
|
+
if (mapped === undefined) {
|
|
284
|
+
// Apple could add a code, and the only safe answer to one we have never seen is to project nothing.
|
|
285
|
+
// Guessing `active` would grant on a state that might mean the opposite; guessing `expired` would revoke a
|
|
286
|
+
// paying subscriber. Refusing leaves the row exactly as it stood.
|
|
287
|
+
throw new PaymentsVerificationFailedError({
|
|
288
|
+
detail: `Apple: subscription status ${status} is not one this build maps. The purchase was left as it stood.`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
// Auto-renew off narrows `active` and nothing else. A subscription in grace or in billing retry with renewal
|
|
292
|
+
// switched off is still in grace or in billing retry — that is the more urgent fact and the one that decides
|
|
293
|
+
// access, and overwriting it with `canceled` would say a failed card was the user's choice.
|
|
294
|
+
return mapped === "active" && autoRenewStatus === AUTO_RENEW_OFF ? "canceled" : mapped;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The current state of a subscription family, or `undefined` when Apple has none under that id.
|
|
299
|
+
*
|
|
300
|
+
* `undefined` is a real answer, not a failure: a transaction id from the other store environment, or one from
|
|
301
|
+
* an app this key does not cover, is a 404. A store that cannot be reached throws instead.
|
|
302
|
+
*/
|
|
303
|
+
export async function fetchAppleSubscriptionStatuses(
|
|
304
|
+
originalTransactionId: string,
|
|
305
|
+
environment: PurchaseEnvironment,
|
|
306
|
+
options: AppleServerApiOptions,
|
|
307
|
+
): Promise<AppleSubscriptionStatuses | undefined> {
|
|
308
|
+
const url = `${APPLE_API_BASES[environment]}/subscriptions/${encodeURIComponent(originalTransactionId)}`;
|
|
309
|
+
const answered = await appleJson(options.transport ?? appleHttpFetch, url, {
|
|
310
|
+
what: "the subscription statuses",
|
|
311
|
+
headers: { authorization: `Bearer ${await bearer(options)}` },
|
|
312
|
+
absentOn404: true,
|
|
313
|
+
});
|
|
314
|
+
if (answered === undefined) return undefined;
|
|
315
|
+
|
|
316
|
+
const parsed = AppleSubscriptionStatuses.safeParse(answered);
|
|
317
|
+
if (!parsed.success) {
|
|
318
|
+
// Never echo the body: it carries the signed transaction, which is the artifact a stolen receipt looks like.
|
|
319
|
+
throw new PaymentsVerificationFailedError({
|
|
320
|
+
detail: `Apple: the answer is not a StatusResponse — ${parsed.error.issues
|
|
321
|
+
.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`)
|
|
322
|
+
.join(", ")}.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return parsed.data;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Re-read one stored subscription at Apple, normalized — the reconciliation path for this rail.
|
|
330
|
+
*
|
|
331
|
+
* Returns `undefined` for a purchase Apple's subscription endpoint cannot speak for: a one-time purchase, or a
|
|
332
|
+
* transaction id the queried environment does not know. Both leave the row untouched, which is right — a
|
|
333
|
+
* subscription we cannot read is not evidence of anything.
|
|
334
|
+
*/
|
|
335
|
+
export async function refreshAppleSubscription(
|
|
336
|
+
purchase: PaymentsPurchase,
|
|
337
|
+
options: AppleServerApiOptions,
|
|
338
|
+
): Promise<UnboundProviderEvent | undefined> {
|
|
339
|
+
// Consumables and non-consumables are out of this endpoint's scope, and out of reconciliation's: they are
|
|
340
|
+
// bought once and the only thing that changes them is a refund, which arrives as its own notification.
|
|
341
|
+
if (purchase.type !== "subscription") return undefined;
|
|
342
|
+
|
|
343
|
+
// The family key. A renewal's row carries the id of the transaction that started the subscription; the first
|
|
344
|
+
// row of a family carries none, and is its own family key.
|
|
345
|
+
const familyId = purchase.originalTransactionId ?? purchase.providerTransactionId;
|
|
346
|
+
const statuses = await fetchAppleSubscriptionStatuses(familyId, purchase.environment, options);
|
|
347
|
+
if (statuses === undefined) return undefined;
|
|
348
|
+
|
|
349
|
+
requireAppleBundle(statuses.bundleId, options.credentials.bundleId, "subscription status");
|
|
350
|
+
|
|
351
|
+
const entries = statuses.data.flatMap((group) => group.lastTransactions);
|
|
352
|
+
// Matched on the id rather than taken by position: a query resolves the whole group, and a group can hold
|
|
353
|
+
// more than one subscription. Taking `[0]` would occasionally refresh a row with a sibling's state.
|
|
354
|
+
const entry = entries.find((candidate) => candidate.originalTransactionId === familyId);
|
|
355
|
+
if (entry === undefined) return undefined;
|
|
356
|
+
|
|
357
|
+
const transaction = await verify(entry.signedTransactionInfo, AppleTransactionInfo, "transaction", options);
|
|
358
|
+
requireAppleBundle(transaction.bundleId, options.credentials.bundleId, "transaction");
|
|
359
|
+
const renewal =
|
|
360
|
+
entry.signedRenewalInfo === undefined
|
|
361
|
+
? undefined
|
|
362
|
+
: await verify(entry.signedRenewalInfo, AppleRenewalInfo, "renewal info", options);
|
|
363
|
+
|
|
364
|
+
// A revocation wins over every status code. Apple reports a revoked subscription as status 5, but it also
|
|
365
|
+
// dates the revocation on the transaction, and a refunded purchase inside its paid period must not grant
|
|
366
|
+
// whatever the group says.
|
|
367
|
+
const status =
|
|
368
|
+
transaction.revocationDate === undefined
|
|
369
|
+
? appleSubscriptionStatus(entry.status, renewal?.autoRenewStatus)
|
|
370
|
+
: "refunded";
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
rail: "apple",
|
|
374
|
+
providerTransactionId: transaction.transactionId,
|
|
375
|
+
providerProductId: transaction.productId,
|
|
376
|
+
status,
|
|
377
|
+
environment: appleEnvironment(transaction.environment),
|
|
378
|
+
purchasedAt: new Date(transaction.purchaseDate),
|
|
379
|
+
// Status 4 says a card is being retried, and the window it is retried through is on the renewal info. The
|
|
380
|
+
// transaction's own `expiresDate` is already in the past by then, so carrying it alone would refresh a row
|
|
381
|
+
// into a lapse — the opposite of what Apple just reported.
|
|
382
|
+
expiresAt: appleExpiresAt(status, transaction.expiresDate, renewal?.gracePeriodExpiresDate),
|
|
383
|
+
revokedAt: transaction.revocationDate === undefined ? null : new Date(transaction.revocationDate),
|
|
384
|
+
originalTransactionId: transaction.originalTransactionId ?? null,
|
|
385
|
+
amountMinor: minorUnitsFromScaled(transaction.price ?? null, APPLE_PRICE_SCALE, transaction.currency ?? null),
|
|
386
|
+
currency: transaction.currency ?? null,
|
|
387
|
+
// The clock, not the transaction's `signedDate`. A refresh is a read of the state *now*, and the status it
|
|
388
|
+
// carries comes from the group rather than from the transaction — so dating it by a transaction Apple
|
|
389
|
+
// signed weeks ago would let the monotonic write rule discard the very repair this call was made for.
|
|
390
|
+
providerEventAt: options.now,
|
|
391
|
+
payload: { transaction, status: entry.status, renewalInfo: renewal ?? null },
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Verify one nested JWS against the pinned chain and parse it, naming the field in any refusal. */
|
|
396
|
+
async function verify<T extends z.ZodType>(
|
|
397
|
+
jws: string,
|
|
398
|
+
schema: T,
|
|
399
|
+
field: string,
|
|
400
|
+
options: AppleServerApiOptions,
|
|
401
|
+
): Promise<z.output<T>> {
|
|
402
|
+
const payload = await verifyAppleJws(jws, { roots: options.roots, now: options.now });
|
|
403
|
+
const parsed = schema.safeParse(payload);
|
|
404
|
+
if (!parsed.success) {
|
|
405
|
+
throw new PaymentsVerificationFailedError({
|
|
406
|
+
detail: `Apple: the ${field} in the subscription status rejected — ${parsed.error.issues
|
|
407
|
+
.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`)
|
|
408
|
+
.join(", ")}.`,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return parsed.data as z.output<T>;
|
|
412
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import { minorUnitsFromScaled } from "../../data/money";
|
|
6
|
+
import type { PurchaseStatus } from "../../data/status";
|
|
7
|
+
import { PaymentsInvalidReceiptError } from "../../error/errors";
|
|
8
|
+
import type { UnboundProviderEvent, VerifiedPurchase } from "../contract";
|
|
9
|
+
import { verifyAppleJws } from "./jws";
|
|
10
|
+
import { APPLE_PRICE_SCALE, AppleTransactionInfo, appleEnvironment, requireAppleBundle } from "./notification";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The client-submission path: a StoreKit 2 signed transaction, verified locally.
|
|
14
|
+
*
|
|
15
|
+
* **There is no store round-trip here, and that is Apple's own design.** StoreKit 2 hands the app a JWS that
|
|
16
|
+
* Apple signed; verifying it against Apple's certificate chain is exactly as strong as asking the App Store
|
|
17
|
+
* Server API, and it is what Apple documents. So this route can be fast and offline, which is what it is
|
|
18
|
+
* for: the buying user sees their entitlement the moment they buy, without waiting for a webhook that is
|
|
19
|
+
* authoritative but not prompt.
|
|
20
|
+
*
|
|
21
|
+
* Nothing about correctness rests on this path. The webhook produces the identical row through the same
|
|
22
|
+
* idempotent writer, so a dropped submission costs nothing and a replayed one changes nothing.
|
|
23
|
+
*
|
|
24
|
+
* Status has to be derived differently from the webhook path. A notification says what *happened*; a
|
|
25
|
+
* transaction only says what it *is*, so there are two facts to read and an order to read them in:
|
|
26
|
+
*
|
|
27
|
+
* 1. **A revocation date wins.** A refunded transaction inside its paid period must not grant, and the
|
|
28
|
+
* revocation is the later fact.
|
|
29
|
+
* 2. **Otherwise the expiry decides.** A subscription past its `expiresDate` is expired even though no
|
|
30
|
+
* notification ever said so — which is the case the read-time recheck exists for. Apple stops renewing
|
|
31
|
+
* and sends nothing; a client resubmitting its stale transaction must not be handed an active row.
|
|
32
|
+
*
|
|
33
|
+
* Everything else is active. A transaction Apple signed, that was not revoked and has not lapsed, is paid.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** What verification needs: our bundle id, the roots to trust, and the clock the expiry is judged against. */
|
|
37
|
+
export interface VerifyAppleTransactionOptions {
|
|
38
|
+
/** Our own bundle id. A transaction from any other app is refused, whoever signed it. */
|
|
39
|
+
bundleId: string;
|
|
40
|
+
/** base64 DER of every acceptable root. Defaults to the pinned Apple roots. */
|
|
41
|
+
roots?: readonly string[];
|
|
42
|
+
/** The clock. Also what `expiresDate` is compared against, so a lapse is deterministic in tests. */
|
|
43
|
+
now: Date;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Verify a StoreKit 2 signed transaction and normalize it. The caller binds the authenticated purchaser. */
|
|
47
|
+
export async function verifyAppleTransaction(
|
|
48
|
+
receipt: string,
|
|
49
|
+
options: VerifyAppleTransactionOptions,
|
|
50
|
+
): Promise<VerifiedPurchase> {
|
|
51
|
+
const payload = await verifyAppleJws(receipt, { roots: options.roots, now: options.now });
|
|
52
|
+
const parsed = AppleTransactionInfo.safeParse(payload);
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
// Never echo the receipt: it is caller-supplied and it is what a stolen one would look like in a log.
|
|
55
|
+
throw new PaymentsInvalidReceiptError({
|
|
56
|
+
detail: `Apple: the submitted transaction is not a StoreKit transaction — ${issues(parsed.error)}.`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const transaction = parsed.data;
|
|
60
|
+
requireAppleBundle(transaction.bundleId, options.bundleId, "transaction");
|
|
61
|
+
|
|
62
|
+
const event: UnboundProviderEvent = {
|
|
63
|
+
rail: "apple",
|
|
64
|
+
providerTransactionId: transaction.transactionId,
|
|
65
|
+
providerProductId: transaction.productId,
|
|
66
|
+
status: transactionStatus(transaction, options.now),
|
|
67
|
+
environment: appleEnvironment(transaction.environment),
|
|
68
|
+
purchasedAt: new Date(transaction.purchaseDate),
|
|
69
|
+
expiresAt: transaction.expiresDate === undefined ? null : new Date(transaction.expiresDate),
|
|
70
|
+
revokedAt: transaction.revocationDate === undefined ? null : new Date(transaction.revocationDate),
|
|
71
|
+
originalTransactionId: transaction.originalTransactionId ?? null,
|
|
72
|
+
amountMinor: minorUnitsFromScaled(transaction.price ?? null, APPLE_PRICE_SCALE, transaction.currency ?? null),
|
|
73
|
+
currency: transaction.currency ?? null,
|
|
74
|
+
// Apple's clock on the transaction itself. There is no notification here to take an event time from, and
|
|
75
|
+
// ours would order by delivery, which is the one ordering providers do not guarantee.
|
|
76
|
+
providerEventAt: new Date(transaction.signedDate),
|
|
77
|
+
payload: transaction as Record<string, unknown>,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return { event, providerAccountId: transaction.appAccountToken ?? null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The status a transaction implies on its own: revoked, then lapsed, then paid. */
|
|
84
|
+
function transactionStatus(transaction: z.output<typeof AppleTransactionInfo>, now: Date): PurchaseStatus {
|
|
85
|
+
if (transaction.revocationDate !== undefined) return "refunded";
|
|
86
|
+
if (transaction.expiresDate !== undefined && transaction.expiresDate <= now.getTime()) return "expired";
|
|
87
|
+
return "active";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Zod issues as `path:code` pairs — never `message` or `received`, which would echo the receipt. */
|
|
91
|
+
function issues(error: z.ZodError): string {
|
|
92
|
+
return error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ");
|
|
93
|
+
}
|