@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,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PortalHandoff, PortalSessionInput } from "../contract";
|
|
5
|
+
import { stripeHttpFetch, stripeJson } from "./api";
|
|
6
|
+
import { hostedSession, type StripeCheckoutOptions } from "./checkout";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Billing Portal: Stripe's own page for managing a subscription, and the whole of Pithy's plan-management
|
|
10
|
+
* surface **on this rail**.
|
|
11
|
+
*
|
|
12
|
+
* Two parameters go out — which customer, and where to send them afterwards — and that is deliberate. Everything
|
|
13
|
+
* a subscriber may do in the portal (cancel, switch plan, update a card, download invoices) is configured in
|
|
14
|
+
* Stripe's dashboard, per environment, by the adopter. A parameter here that decided any of it would be this
|
|
15
|
+
* capability computing proration and tax, which #79's locked decision 2 still says it never does. It also means
|
|
16
|
+
* store rules and dunning copy stay Stripe's to keep current rather than ours to age.
|
|
17
|
+
*
|
|
18
|
+
* **"The whole of it on this rail" is a narrower claim than it was.** The decision's other half was amended on
|
|
19
|
+
* 2026-08-28 and the kit may now invoke a plan change from the server, passing the provider's figures through
|
|
20
|
+
* unmodified — `{base}/subscription/preview|change|cancel|keep`, behind {@link SubscriptionRail}. Only Paddle
|
|
21
|
+
* implements it. The amendment permits the seam rather than requiring one per rail, so Stripe's answer to
|
|
22
|
+
* "change my plan" is still this page, and `providers.test.ts` holds that to what the providers actually
|
|
23
|
+
* declare rather than to this sentence.
|
|
24
|
+
*
|
|
25
|
+
* The customer is resolved from the provider-account map, never from a request. That map is written from
|
|
26
|
+
* `client_reference_id` when a checkout completes, so the caller can only ever reach their own billing.
|
|
27
|
+
*
|
|
28
|
+
* The failure every adopter meets once is a Billing Portal with no configuration: Stripe answers 400 until one is
|
|
29
|
+
* created in the dashboard. `api.ts` maps that to `payments/rail_not_configured`, which reads to a caller as "not
|
|
30
|
+
* available here" — true — and carries Stripe's own sentence in `detail` for whoever can fix it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Create a hosted Billing Portal session for one customer, and return where to send the browser. */
|
|
34
|
+
export async function createStripePortalSession(
|
|
35
|
+
input: PortalSessionInput,
|
|
36
|
+
options: StripeCheckoutOptions,
|
|
37
|
+
): Promise<PortalHandoff> {
|
|
38
|
+
const created = await stripeJson(options.transport ?? stripeHttpFetch, "/billing_portal/sessions", {
|
|
39
|
+
what: "a Billing Portal session",
|
|
40
|
+
secretKey: options.credentials.secretKey,
|
|
41
|
+
form: { customer: input.providerAccountId, return_url: input.returnUrl },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return hostedSession(created, "a Billing Portal session");
|
|
45
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { SubscriptionPricing } from "../../data/discount";
|
|
6
|
+
import type { PaymentsPurchase } from "../../data/purchase";
|
|
7
|
+
import type { PaymentsStripeCredentials } from "../../secret/registry";
|
|
8
|
+
import { type StripeHttpFetch, stripeHttpFetch, stripeJson } from "./api";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a Stripe subscription pays now, what it becomes, and when.
|
|
12
|
+
*
|
|
13
|
+
* Read from the store rather than computed. Stripe carries the discount on the subscription and the
|
|
14
|
+
* arithmetic on an upcoming invoice, so both are asked for and neither is derived: Pithy never multiplies a
|
|
15
|
+
* price by a percentage, here or anywhere.
|
|
16
|
+
*
|
|
17
|
+
* **`discountEndsAt` is the field this exists for.** Stripe reports a repeating discount's `end` on the
|
|
18
|
+
* subscription's discount object, and that date is what a customer must be told — a rate that lapses with
|
|
19
|
+
* nothing having said so is, from their seat, a billing error.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What reading pricing needs. */
|
|
23
|
+
export interface StripePricingOptions {
|
|
24
|
+
/** Stripe's credential block. Only `secretKey` is used here. */
|
|
25
|
+
credentials: PaymentsStripeCredentials;
|
|
26
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
27
|
+
transport?: StripeHttpFetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A subscription, narrowed to the discount in force. */
|
|
31
|
+
const DiscountedSubscription = z
|
|
32
|
+
.object({
|
|
33
|
+
currency: z.string().nullish(),
|
|
34
|
+
discount: z
|
|
35
|
+
.object({
|
|
36
|
+
end: z.number().nullish(),
|
|
37
|
+
promotion_code: z.unknown().nullish(),
|
|
38
|
+
coupon: z.object({ name: z.string().nullish() }).loose().nullish(),
|
|
39
|
+
})
|
|
40
|
+
.loose()
|
|
41
|
+
.nullish(),
|
|
42
|
+
items: z
|
|
43
|
+
.object({ data: z.array(z.object({ price: z.object({ unit_amount: z.number().nullish() }).loose() }).loose()) })
|
|
44
|
+
.loose()
|
|
45
|
+
.nullish(),
|
|
46
|
+
})
|
|
47
|
+
.loose();
|
|
48
|
+
|
|
49
|
+
/** The upcoming invoice, which is Stripe's own answer to "what comes off and what is left". */
|
|
50
|
+
const UpcomingInvoice = z
|
|
51
|
+
.object({ currency: z.string().nullish(), total: z.number().nullish(), subtotal: z.number().nullish() })
|
|
52
|
+
.loose();
|
|
53
|
+
|
|
54
|
+
/** Read what this subscription pays, or `undefined` when Stripe has nothing to say. */
|
|
55
|
+
export async function readStripePricing(
|
|
56
|
+
purchase: PaymentsPurchase,
|
|
57
|
+
options: StripePricingOptions,
|
|
58
|
+
): Promise<SubscriptionPricing | undefined> {
|
|
59
|
+
// A Stripe subscription row's family key is the `sub_…` it belongs to; a one-off has none and has no
|
|
60
|
+
// renewal to price.
|
|
61
|
+
const subscriptionId = purchase.originalTransactionId;
|
|
62
|
+
if (subscriptionId === null || !subscriptionId.startsWith("sub_")) return undefined;
|
|
63
|
+
|
|
64
|
+
const transport = options.transport ?? stripeHttpFetch;
|
|
65
|
+
const found = await stripeJson(transport, `/subscriptions/${encodeURIComponent(subscriptionId)}`, {
|
|
66
|
+
what: `subscription ${subscriptionId} pricing`,
|
|
67
|
+
secretKey: options.credentials.secretKey,
|
|
68
|
+
absentOn404: true,
|
|
69
|
+
});
|
|
70
|
+
if (found === undefined) return undefined;
|
|
71
|
+
|
|
72
|
+
const parsed = DiscountedSubscription.safeParse(found);
|
|
73
|
+
if (!parsed.success) return undefined;
|
|
74
|
+
const subscription = parsed.data;
|
|
75
|
+
|
|
76
|
+
// `/v1/invoices/create_preview`, not `/v1/invoices/upcoming`. The latter is what every older integration
|
|
77
|
+
// guide shows and it is **gone** in the API version this rail pins (`2025-04-30.basil`) — so calling it
|
|
78
|
+
// 404s, `absentOn404` swallows the 404, and every amount below comes back null. A pricing read that
|
|
79
|
+
// silently reports nothing is worse than one that fails, because a screen renders the nothing.
|
|
80
|
+
const upcoming = await stripeJson(transport, "/invoices/create_preview", {
|
|
81
|
+
what: `subscription ${subscriptionId} next invoice`,
|
|
82
|
+
secretKey: options.credentials.secretKey,
|
|
83
|
+
form: { subscription: subscriptionId },
|
|
84
|
+
absentOn404: true,
|
|
85
|
+
});
|
|
86
|
+
const invoice = upcoming === undefined ? undefined : UpcomingInvoice.safeParse(upcoming);
|
|
87
|
+
const amounts = invoice?.success === true ? invoice.data : undefined;
|
|
88
|
+
|
|
89
|
+
// The list price falls back to the item's own unit amount when there is no upcoming invoice to read —
|
|
90
|
+
// a subscription canceled at period end has none, and its list price is still a fact.
|
|
91
|
+
const listed = subscription.items?.data[0]?.price.unit_amount ?? null;
|
|
92
|
+
const end = subscription.discount?.end;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
currency: amounts?.currency ?? subscription.currency ?? null,
|
|
96
|
+
currentAmountMinor: amounts?.total ?? null,
|
|
97
|
+
listAmountMinor: amounts?.subtotal ?? listed,
|
|
98
|
+
// The code a customer typed, never the `promo_…` id. `SubscriptionPricing.discountCode` is typed as a
|
|
99
|
+
// `DiscountCode` and is rendered on a billing screen; showing an opaque id there is showing the
|
|
100
|
+
// customer a value that is not theirs and that they cannot match to the code they entered. Stripe
|
|
101
|
+
// returns the id unless the discount is expanded, so an unexpanded one reports null rather than a lie.
|
|
102
|
+
discountCode: promotionCode(subscription.discount?.promotion_code),
|
|
103
|
+
// Seconds since the epoch on Stripe's side. Null means no discount, or one that runs forever — a screen
|
|
104
|
+
// must read it beside `discountCode` to know which, which `SubscriptionPricing` says out loud.
|
|
105
|
+
discountEndsAt: typeof end === "number" ? new Date(end * 1000) : null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The customer-facing code off Stripe's `promotion_code` field, or null.
|
|
111
|
+
*
|
|
112
|
+
* Stripe returns either the id (`promo_…`) or, when expanded, the object carrying `code`. Only the second
|
|
113
|
+
* is a code a customer would recognize; a bare id is reported as null, because "no code shown" is honest
|
|
114
|
+
* and "PROMO_1QxYz" on a billing screen is not.
|
|
115
|
+
*/
|
|
116
|
+
function promotionCode(value: unknown): string | null {
|
|
117
|
+
if (typeof value === "string") return value.startsWith("promo_") ? null : value;
|
|
118
|
+
if (typeof value === "object" && value !== null) {
|
|
119
|
+
const code = (value as { code?: unknown }).code;
|
|
120
|
+
return typeof code === "string" && code !== "" ? code : null;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CreatedDiscount, DiscountTerms, SubscriptionPricing } from "../../data/discount";
|
|
5
|
+
import type { PaymentsPurchase } from "../../data/purchase";
|
|
6
|
+
import type { PaymentsStripeCredentials } from "../../secret/registry";
|
|
7
|
+
import type {
|
|
8
|
+
CheckoutHandoff,
|
|
9
|
+
CheckoutRail,
|
|
10
|
+
CheckoutSessionInput,
|
|
11
|
+
DiscountRail,
|
|
12
|
+
ListedDiscount,
|
|
13
|
+
PaymentsRailProvider,
|
|
14
|
+
PortalHandoff,
|
|
15
|
+
PortalSessionInput,
|
|
16
|
+
PricingRail,
|
|
17
|
+
RailRequestContext,
|
|
18
|
+
UnboundProviderEvent,
|
|
19
|
+
VerifiedNotification,
|
|
20
|
+
VerifiedPurchase,
|
|
21
|
+
WebhookDelivery,
|
|
22
|
+
} from "../contract";
|
|
23
|
+
import type { StripeHttpFetch } from "./api";
|
|
24
|
+
import { createStripeCheckoutSession } from "./checkout";
|
|
25
|
+
import { createStripeDiscount, listStripeDiscounts } from "./discounts";
|
|
26
|
+
import { mapStripeEvent, StripeEvent } from "./objects";
|
|
27
|
+
import { createStripePortalSession } from "./portal";
|
|
28
|
+
import { readStripePricing } from "./pricing";
|
|
29
|
+
import { refreshStripeSubscription } from "./refresh";
|
|
30
|
+
import { verifyStripeSession } from "./verify";
|
|
31
|
+
import { parseStripeNotification } from "./webhook";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Stripe rail as one provider object — and the only rail that implements both halves of the contract.
|
|
35
|
+
*
|
|
36
|
+
* Apple and Google purchases have already happened inside a store SDK by the time the server hears of them. A
|
|
37
|
+
* Stripe purchase is one Pithy initiates: `/checkout` creates a hosted session and sends the browser to it. That
|
|
38
|
+
* asymmetry is why {@link CheckoutRail} is a separate interface rather than two methods Apple would have to stub,
|
|
39
|
+
* and this is the object that satisfies both.
|
|
40
|
+
*
|
|
41
|
+
* Built per request from credentials the caller resolved through the secrets store, rather than reading them
|
|
42
|
+
* itself. That keeps the rail a pure function of its inputs and keeps the secret read at the point of need.
|
|
43
|
+
*
|
|
44
|
+
* **Hosted only, and that is a decision rather than a stage.** No Payment Element, no card fields, no proration
|
|
45
|
+
* or plan-change logic — Stripe owns the payment page, SCA, tax, and every rule that changes underneath them.
|
|
46
|
+
* Nothing in this module would be a small edit away from owning any of that, which is the point.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** How the Stripe rail's transport and replay window may be varied. Both exist for the tests. */
|
|
50
|
+
export interface StripeRailOptions {
|
|
51
|
+
/** The HTTP seam Stripe's API is reached through. Defaults to the runtime's `fetch`. */
|
|
52
|
+
transport?: StripeHttpFetch;
|
|
53
|
+
/** The signature freshness window, in seconds. Defaults to Stripe's own five minutes. */
|
|
54
|
+
toleranceSeconds?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The Stripe rail. Verifies a returning Checkout Session, verifies webhooks, and creates hosted sessions. */
|
|
58
|
+
export function stripeRail(
|
|
59
|
+
credentials: PaymentsStripeCredentials,
|
|
60
|
+
options: StripeRailOptions = {},
|
|
61
|
+
): PaymentsRailProvider & CheckoutRail & DiscountRail & PricingRail {
|
|
62
|
+
return {
|
|
63
|
+
rail: "stripe",
|
|
64
|
+
|
|
65
|
+
async verify(receipt: string, context: RailRequestContext): Promise<VerifiedPurchase> {
|
|
66
|
+
// `return await`, not `return`. Returning a promise from an async function makes this frame *adopt* the
|
|
67
|
+
// rejection rather than raising it, and workerd then reports the adopted promise as an unhandled rejection
|
|
68
|
+
// even though Hono's `onError` answers the request correctly. A refused session is normal traffic.
|
|
69
|
+
return await verifyStripeSession(receipt, {
|
|
70
|
+
credentials,
|
|
71
|
+
now: context.now,
|
|
72
|
+
transport: options.transport,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async parseNotification(delivery: WebhookDelivery, context: RailRequestContext): Promise<VerifiedNotification> {
|
|
77
|
+
return await parseStripeNotification(delivery, {
|
|
78
|
+
credentials,
|
|
79
|
+
now: context.now,
|
|
80
|
+
toleranceSeconds: options.toleranceSeconds,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// The clock and the deployment travel with every rail call, and this one needs neither: the map reads
|
|
85
|
+
// the event's own `created` and Stripe stamps no deployment. Named `_context` so the signature still
|
|
86
|
+
// matches the contract rather than diverging from it.
|
|
87
|
+
async replay(
|
|
88
|
+
payload: Record<string, unknown>,
|
|
89
|
+
_context: RailRequestContext,
|
|
90
|
+
): Promise<VerifiedNotification | undefined> {
|
|
91
|
+
// The recorded body is the whole Stripe event, and the map takes an event and reads nothing else — so
|
|
92
|
+
// this replay makes no call to Stripe at all. The signature is not re-checked: it covers a header this
|
|
93
|
+
// table never stored, and the row's authenticity was established when it was written.
|
|
94
|
+
const parsed = StripeEvent.safeParse(payload);
|
|
95
|
+
if (!parsed.success) return undefined;
|
|
96
|
+
const mapped = mapStripeEvent(parsed.data);
|
|
97
|
+
return {
|
|
98
|
+
providerEventId: parsed.data.id,
|
|
99
|
+
payload: parsed.data as Record<string, unknown>,
|
|
100
|
+
event: mapped.event,
|
|
101
|
+
providerAccountId: mapped.providerAccountId,
|
|
102
|
+
accountReference: mapped.accountReference,
|
|
103
|
+
note: mapped.note,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async refresh(purchase: PaymentsPurchase, context: RailRequestContext): Promise<UnboundProviderEvent | undefined> {
|
|
108
|
+
// `return await`, not `return` — see `verify` above for why the frame must raise rather than adopt.
|
|
109
|
+
return await refreshStripeSubscription(purchase, {
|
|
110
|
+
credentials,
|
|
111
|
+
now: context.now,
|
|
112
|
+
transport: options.transport,
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async createCheckoutSession(input: CheckoutSessionInput): Promise<CheckoutHandoff> {
|
|
117
|
+
return await createStripeCheckoutSession(input, { credentials, transport: options.transport });
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async createPortalSession(input: PortalSessionInput): Promise<PortalHandoff> {
|
|
121
|
+
return await createStripePortalSession(input, { credentials, transport: options.transport });
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async readPricing(
|
|
125
|
+
purchase: PaymentsPurchase,
|
|
126
|
+
_context: RailRequestContext,
|
|
127
|
+
): Promise<SubscriptionPricing | undefined> {
|
|
128
|
+
return await readStripePricing(purchase, { credentials, transport: options.transport });
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async listDiscounts(): Promise<readonly ListedDiscount[]> {
|
|
132
|
+
return await listStripeDiscounts({ credentials, transport: options.transport });
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async createDiscount(terms: DiscountTerms): Promise<CreatedDiscount> {
|
|
136
|
+
return await createStripeDiscount(terms, { credentials, transport: options.transport });
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { PaymentsStripeCredentials } from "../../secret/registry";
|
|
7
|
+
import type { UnboundProviderEvent } from "../contract";
|
|
8
|
+
import { type StripeHttpFetch, stripeHttpFetch, stripeJson } from "./api";
|
|
9
|
+
import { mapStripeSubscription, StripeSubscription } from "./objects";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Re-read one stored subscription at Stripe, normalized — the reconciliation path for this rail.
|
|
13
|
+
*
|
|
14
|
+
* **A subscription retrieve, not an invoice one, and that is the whole design.** A row is keyed on the invoice
|
|
15
|
+
* because each billing period is its own purchase; the *subscription* is what has a current state, and it is
|
|
16
|
+
* what `originalTransactionId` holds. So the retrieve is by `sub_…` and the mapping is the identical one the
|
|
17
|
+
* webhook path uses — `mapStripeSubscription`, not a second reading of Stripe's vocabulary that could drift
|
|
18
|
+
* from it. The invoice the answer names becomes the row's key, which means a period this deployment never
|
|
19
|
+
* heard a webhook for projects as its own new row rather than overwriting the last one it did hear about.
|
|
20
|
+
*
|
|
21
|
+
* **Subscriptions only.** A one-time Stripe purchase is a payment intent, and a payment intent does not change
|
|
22
|
+
* after it succeeds — the one thing that changes it is a refund, which arrives as `charge.refunded`. There is
|
|
23
|
+
* nothing for a periodic re-read to discover, so there is no call here to make.
|
|
24
|
+
*
|
|
25
|
+
* **Unlike Google there is nothing to batch.** Stripe authenticates every request with the secret key
|
|
26
|
+
* directly, so a hundred refreshes are a hundred round-trips and no token minting at all.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** What a Stripe refresh needs: the API key, the clock, and the transport. */
|
|
30
|
+
export interface StripeRefreshOptions {
|
|
31
|
+
/** Stripe's credential block. Only `secretKey` is used here. */
|
|
32
|
+
credentials: PaymentsStripeCredentials;
|
|
33
|
+
/** The clock. A retrieve is a read of the state now, so this is the honest provider event time. */
|
|
34
|
+
now: Date;
|
|
35
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
36
|
+
transport?: StripeHttpFetch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Re-read a stored Stripe subscription, or `undefined` when there is nothing addressable to read. */
|
|
40
|
+
export async function refreshStripeSubscription(
|
|
41
|
+
purchase: PaymentsPurchase,
|
|
42
|
+
options: StripeRefreshOptions,
|
|
43
|
+
): Promise<UnboundProviderEvent | undefined> {
|
|
44
|
+
if (purchase.type !== "subscription") return undefined;
|
|
45
|
+
|
|
46
|
+
// The subscription id, which every invoice row of the family carries. A row written from a Checkout Session
|
|
47
|
+
// whose subscription was never expanded has none, and there is nothing to retrieve.
|
|
48
|
+
const subscriptionId = purchase.originalTransactionId;
|
|
49
|
+
if (subscriptionId === null) return undefined;
|
|
50
|
+
|
|
51
|
+
const retrieved = await stripeJson(
|
|
52
|
+
options.transport ?? stripeHttpFetch,
|
|
53
|
+
`/subscriptions/${encodeURIComponent(subscriptionId)}`,
|
|
54
|
+
{
|
|
55
|
+
what: "the subscription",
|
|
56
|
+
secretKey: options.credentials.secretKey,
|
|
57
|
+
absentOn404: true,
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
// Deleted at Stripe, or belonging to the other mode's account. Either way the row stays exactly as it stood:
|
|
61
|
+
// a subscription we cannot read is not evidence that anybody's access ended.
|
|
62
|
+
if (retrieved === undefined) return undefined;
|
|
63
|
+
|
|
64
|
+
const parsed = StripeSubscription.safeParse(retrieved);
|
|
65
|
+
if (!parsed.success) {
|
|
66
|
+
throw new PaymentsVerificationFailedError({
|
|
67
|
+
detail: `Stripe: the answer is not a Subscription — ${parsed.error.issues
|
|
68
|
+
.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`)
|
|
69
|
+
.join(", ")}.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The clock as the provider event time. A retrieve has none of its own, and dating it by the subscription's
|
|
74
|
+
// `created` would put every repair behind every notification it is meant to correct.
|
|
75
|
+
//
|
|
76
|
+
// `?? undefined` rather than passing the null through: the mapper's `null` means "this object described no
|
|
77
|
+
// transaction", which is the same statement as "nothing to refresh" and must not read as a distinct outcome.
|
|
78
|
+
return mapStripeSubscription(parsed.data, options.now).event ?? undefined;
|
|
79
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
checkSignedWebhook,
|
|
6
|
+
parseSignedWebhookHeader,
|
|
7
|
+
type SignedWebhookHeader,
|
|
8
|
+
type SignedWebhookRefusal,
|
|
9
|
+
} from "@pithy-sh/core/src/http/signedWebhook";
|
|
10
|
+
import { PaymentsVerificationFailedError } from "../../error/errors";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stripe's authenticity proof: an HMAC over the exact received bytes, dated, in one header.
|
|
14
|
+
*
|
|
15
|
+
* `Stripe-Signature: t=1768435200,v1=5257a8…` — the timestamp the delivery claims, then one hex HMAC-SHA256 per
|
|
16
|
+
* active signing secret, each computed over `<timestamp>.<body>` and keyed with the endpoint's `whsec_…`.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the scheme itself is not here
|
|
19
|
+
*
|
|
20
|
+
* It is `@pithy-sh/core`'s `signed-webhook` primitive, and this module is one of its callers. Stripe's format is
|
|
21
|
+
* the one every other sender copied, so the kit implements it once — the freshness window in both directions, the
|
|
22
|
+
* timestamp inside the signed payload, `crypto.subtle.verify` instead of a hand-written compare, every listed
|
|
23
|
+
* signature tried, and a cap on how many. Each of those is a security property with a reason written down beside
|
|
24
|
+
* it, and a second copy here would be a second set of reasons to keep in step: a parser fix landing in one and
|
|
25
|
+
* not the other, with money on the side that missed it.
|
|
26
|
+
*
|
|
27
|
+
* What stays is what is Stripe's: the header name, the accepted scheme key, the tolerance, and the wording of a
|
|
28
|
+
* refusal.
|
|
29
|
+
*
|
|
30
|
+
* ## Why the window matters to this rail in particular
|
|
31
|
+
*
|
|
32
|
+
* A captured `customer.subscription.updated` replayed months later is a state claim about a subscription that has
|
|
33
|
+
* since lapsed. The projection's monotonic rule would correctly ignore it *only because* the stored event time is
|
|
34
|
+
* newer — and relying on that would make the window a happy accident of another rule. Five minutes is Stripe's own
|
|
35
|
+
* default and is generous against delivery latency and clock skew.
|
|
36
|
+
*
|
|
37
|
+
* Freshness is not uniqueness. Inside the window a captured delivery replays as often as it is sent, which is what
|
|
38
|
+
* the guard's `UNIQUE (rail, providerEventId)` insert is for.
|
|
39
|
+
*
|
|
40
|
+
* ## What a refusal says
|
|
41
|
+
*
|
|
42
|
+
* `payments/verification_failed`, with the reason in `detail` and nothing else. Never the secret, never the body,
|
|
43
|
+
* never the signature: `detail` reaches an operator's log, and the webhook guard maps this to
|
|
44
|
+
* `payments/webhook_unverified` (401) before anything reaches the sender — a forger learns only that it failed.
|
|
45
|
+
* That code is this rail's contract with its guard, which is why verification goes through the primitive's
|
|
46
|
+
* reporting seam rather than its throwing one.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** The header Stripe puts its proof in. Lower case, because that is how Hono presents a header name. */
|
|
50
|
+
export const STRIPE_SIGNATURE_HEADER = "stripe-signature";
|
|
51
|
+
|
|
52
|
+
/** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
|
|
53
|
+
export const STRIPE_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
54
|
+
|
|
55
|
+
/** The one signature scheme this build accepts. `v0` is Stripe's thin-event variant and proves nothing here. */
|
|
56
|
+
const ACCEPTED_SCHEME = "v1";
|
|
57
|
+
|
|
58
|
+
/** The timestamp key Stripe writes. The primitive's default, named here so this file states its own format. */
|
|
59
|
+
const TIMESTAMP_KEY = "t";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A parsed `Stripe-Signature` header: when the delivery says it was signed, and with what. The primitive's
|
|
63
|
+
* shape under this rail's name — the format is Stripe's, the parser is the kit's.
|
|
64
|
+
*/
|
|
65
|
+
export type StripeSignatureHeader = SignedWebhookHeader;
|
|
66
|
+
|
|
67
|
+
/** What verification needs beyond the bytes: the clock, and how wide the freshness window is. */
|
|
68
|
+
export interface VerifyStripeSignatureOptions {
|
|
69
|
+
/** The clock. Injected so the replay window is deterministic in tests rather than wall-clock dependent. */
|
|
70
|
+
now: Date;
|
|
71
|
+
/** The freshness window, in seconds. Defaults to {@link STRIPE_SIGNATURE_TOLERANCE_SECONDS}. */
|
|
72
|
+
toleranceSeconds?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read a `Stripe-Signature` header, or `undefined` when it is not one.
|
|
77
|
+
*
|
|
78
|
+
* Undefined rather than a throw for every unreadable shape, because the caller turns them all into one refusal:
|
|
79
|
+
* distinguishing "no `t`" from "no `v1`" would describe our parser to a sender, and neither is actionable by
|
|
80
|
+
* anyone but an operator reading the endpoint's configuration.
|
|
81
|
+
*
|
|
82
|
+
* Unknown schemes are dropped rather than refused. Stripe already sends `v0` alongside `v1` for thin Connect
|
|
83
|
+
* events and may add another after this package shipped, and a delivery whose `v1` is good is authentic whatever
|
|
84
|
+
* else it carries. A header carrying *only* unknown schemes has proved nothing, so it yields undefined.
|
|
85
|
+
*/
|
|
86
|
+
export function parseStripeSignatureHeader(header: string): StripeSignatureHeader | undefined {
|
|
87
|
+
return parseSignedWebhookHeader(header, TIMESTAMP_KEY, ACCEPTED_SCHEME);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Verify one delivery's `Stripe-Signature`. Resolves when Stripe signed these exact bytes inside the window, and
|
|
92
|
+
* throws `payments/verification_failed` otherwise.
|
|
93
|
+
*/
|
|
94
|
+
export async function verifyStripeSignature(
|
|
95
|
+
body: string,
|
|
96
|
+
header: string | null,
|
|
97
|
+
secret: string,
|
|
98
|
+
options: VerifyStripeSignatureOptions,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const refusal = await checkSignedWebhook(body, header, {
|
|
101
|
+
header: STRIPE_SIGNATURE_HEADER,
|
|
102
|
+
timestampKey: TIMESTAMP_KEY,
|
|
103
|
+
signatureKey: ACCEPTED_SCHEME,
|
|
104
|
+
secret,
|
|
105
|
+
toleranceSeconds: options.toleranceSeconds ?? STRIPE_SIGNATURE_TOLERANCE_SECONDS,
|
|
106
|
+
now: options.now,
|
|
107
|
+
});
|
|
108
|
+
if (refusal === undefined) return;
|
|
109
|
+
throw new PaymentsVerificationFailedError({ detail: stripeRefusal(refusal) });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* This rail's wording for the primitive's three refusals.
|
|
114
|
+
*
|
|
115
|
+
* Prefixed `Stripe:` because a payments Worker composes several rails and `detail` is what an operator reads to
|
|
116
|
+
* find out which one refused. The strings say what failed and what to check, and nothing the delivery carried.
|
|
117
|
+
*/
|
|
118
|
+
function stripeRefusal(refusal: SignedWebhookRefusal): string {
|
|
119
|
+
switch (refusal.reason) {
|
|
120
|
+
case "unreadable":
|
|
121
|
+
return "Stripe: the delivery carries no readable Stripe-Signature header with a t= timestamp and a v1= HMAC.";
|
|
122
|
+
case "stale":
|
|
123
|
+
return `Stripe: the delivery is dated ${refusal.skew}s from now, outside the ${refusal.tolerance}s tolerance. Check this Worker's clock, or a replayed delivery.`;
|
|
124
|
+
case "unmatched":
|
|
125
|
+
return "Stripe: no signature in the Stripe-Signature header matches these bytes under the configured signing secret. Check that the secret belongs to this endpoint and this environment.";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PaymentsInvalidReceiptError, PaymentsVerificationFailedError } from "../../error/errors";
|
|
5
|
+
import type { PaymentsStripeCredentials } from "../../secret/registry";
|
|
6
|
+
import { noteText, type VerifiedPurchase } from "../contract";
|
|
7
|
+
import { type StripeHttpFetch, stripeHttpFetch, stripeJson } from "./api";
|
|
8
|
+
import { mapStripeSession, StripeCheckoutSession } from "./objects";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The client-submission path for Stripe: the Checkout Session id a returning browser carries.
|
|
12
|
+
*
|
|
13
|
+
* Hosted Checkout sends the buyer back to `success_url`, and Stripe substitutes the session id into it wherever
|
|
14
|
+
* the adopter put `{CHECKOUT_SESSION_ID}`. Posting that id to `/payments/purchases` is the Stripe equivalent of
|
|
15
|
+
* submitting a StoreKit transaction: **nothing about correctness rests on it**, because the webhook produces the
|
|
16
|
+
* identical row through the same idempotent writer. What it buys is immediacy — the buyer sees their entitlement
|
|
17
|
+
* when they land on the thank-you page rather than when Stripe gets round to delivering.
|
|
18
|
+
*
|
|
19
|
+
* ## Why a session id is safe to accept, and where it stops
|
|
20
|
+
*
|
|
21
|
+
* A session id is not a bearer artifact the way a receipt is: it is not a secret, and it is not proof of anything
|
|
22
|
+
* on its own. What makes this path safe is that the session **names its own purchaser**. Pithy set
|
|
23
|
+
* `client_reference_id` from the authenticated caller when it created the session, and that value comes back on
|
|
24
|
+
* the retrieve — so a caller submitting somebody else's session id is refused by the route, before the projection
|
|
25
|
+
* writer's owner check ever has to catch it. The shape is checked here first, so an Apple receipt posted to the
|
|
26
|
+
* Stripe rail costs no round-trip.
|
|
27
|
+
*
|
|
28
|
+
* ## Why all three expansions
|
|
29
|
+
*
|
|
30
|
+
* `line_items` because a session's price is otherwise unknowable, and Stripe's own answer is better than the copy
|
|
31
|
+
* this deployment stamped into metadata. `subscription` because a subscription-mode session carries only an id,
|
|
32
|
+
* and expanding it turns one retrieve into the whole state — the same row the subscription's own webhook will
|
|
33
|
+
* later produce. And **`payment_intent.latest_charge` because a completed session is refund-blind**: its
|
|
34
|
+
* `payment_status` reads `paid` for ever, so without the charge this path would answer `active` on a purchase
|
|
35
|
+
* Stripe has already given back. The charge is the only object here that knows what became of the money.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** A Checkout Session id. Stripe's are `cs_` plus base62; the bound is generous and the shape is exact. */
|
|
39
|
+
const SESSION_ID = /^cs_[A-Za-z0-9_]{1,255}$/;
|
|
40
|
+
|
|
41
|
+
/** What verifying a submitted session needs: the API key, a clock, and the transport. */
|
|
42
|
+
export interface StripeVerifyOptions {
|
|
43
|
+
/** Stripe's credential block. Only `secretKey` is used here. */
|
|
44
|
+
credentials: PaymentsStripeCredentials;
|
|
45
|
+
/** The clock. A retrieve is a read of the state now, so this is the honest provider event time. */
|
|
46
|
+
now: Date;
|
|
47
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
48
|
+
transport?: StripeHttpFetch;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Verify a client-submitted Checkout Session and normalize it. The caller binds the authenticated purchaser. */
|
|
52
|
+
export async function verifyStripeSession(receipt: string, options: StripeVerifyOptions): Promise<VerifiedPurchase> {
|
|
53
|
+
if (!SESSION_ID.test(receipt)) {
|
|
54
|
+
// Never echo the submitted value: it is caller-supplied and lands in an operator's log.
|
|
55
|
+
throw new PaymentsInvalidReceiptError({
|
|
56
|
+
detail: "Stripe: the submitted value is not a Checkout Session id. Submit the `cs_…` id from your success URL.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const retrieved = await stripeJson(
|
|
61
|
+
options.transport ?? stripeHttpFetch,
|
|
62
|
+
`/checkout/sessions/${encodeURIComponent(receipt)}`,
|
|
63
|
+
{
|
|
64
|
+
what: "the Checkout Session",
|
|
65
|
+
secretKey: options.credentials.secretKey,
|
|
66
|
+
query: { expand: ["subscription", "line_items", "payment_intent.latest_charge"] },
|
|
67
|
+
absentOn404: true,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (retrieved === undefined) {
|
|
72
|
+
throw new PaymentsVerificationFailedError({
|
|
73
|
+
detail: "Stripe: no Checkout Session exists under the submitted id in this environment.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parsed = StripeCheckoutSession.safeParse(retrieved);
|
|
78
|
+
if (!parsed.success) {
|
|
79
|
+
throw new PaymentsVerificationFailedError({
|
|
80
|
+
detail: `Stripe: the answer is not a Checkout Session — ${parsed.error.issues
|
|
81
|
+
.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`)
|
|
82
|
+
.join(", ")}.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A retrieve is not an event: Stripe timed it with nothing, so `eventAt` is null and the mapping dates what it
|
|
87
|
+
// found. A live subscription it read is dated now; a frozen one-time snapshot is dated by Stripe's own clock,
|
|
88
|
+
// which is what stops a re-posted session id outranking the refund that followed it. See `objects.ts`.
|
|
89
|
+
const mapped = mapStripeSession(parsed.data, { eventAt: null, now: options.now });
|
|
90
|
+
if (mapped.event === null) {
|
|
91
|
+
// An abandoned session, one still awaiting a bank debit, or one whose subscription does not exist yet. The
|
|
92
|
+
// buyer is told rather than left with a 200 and no entitlement.
|
|
93
|
+
throw new PaymentsVerificationFailedError({
|
|
94
|
+
detail:
|
|
95
|
+
noteText(mapped.note) ??
|
|
96
|
+
"Stripe: that checkout has not completed into a purchase yet. It will project from the webhook when it does.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
event: mapped.event,
|
|
102
|
+
providerAccountId: mapped.providerAccountId,
|
|
103
|
+
accountReference: mapped.accountReference,
|
|
104
|
+
};
|
|
105
|
+
}
|