@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,169 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { CreatedDiscount, DiscountTerms } from "../../data/discount";
|
|
6
|
+
import { PaymentsDiscountInvalidError, PaymentsProviderUnavailableError } from "../../error/errors";
|
|
7
|
+
import type { PaymentsStripeCredentials } from "../../secret/registry";
|
|
8
|
+
import type { ListedDiscount } from "../contract";
|
|
9
|
+
import { type StripeFormObject, type StripeHttpFetch, stripeHttpFetch, stripeJson } from "./api";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minting a discount at Stripe, from the normalized terms.
|
|
13
|
+
*
|
|
14
|
+
* Stripe models a discount as **two objects**: a Coupon, which carries the money and the duration, and a
|
|
15
|
+
* Promotion Code, which is the string a customer types and carries the redemption limit and the redeem-by
|
|
16
|
+
* date. Both are created here, in that order, because the second references the first.
|
|
17
|
+
*
|
|
18
|
+
* ## The two translations that decide whether a customer is charged correctly
|
|
19
|
+
*
|
|
20
|
+
* **Duration.** `DiscountDuration` counts **billing periods**, because that is what a customer experiences.
|
|
21
|
+
* Stripe's `duration_in_months` counts **months**. On a monthly plan they coincide, which is exactly why the
|
|
22
|
+
* mistranslation survives review: on an annual plan `12` means one year to the customer and twelve years to
|
|
23
|
+
* Stripe. So the conversion multiplies by the plan's interval, and `billingInterval` is required on the
|
|
24
|
+
* terms rather than assumed — `discounts.test.ts` pins an annual plan in both directions.
|
|
25
|
+
*
|
|
26
|
+
* **Expiry.** `redeemableUntil` means the code can no longer be *claimed*, and anyone already holding it
|
|
27
|
+
* keeps their rate. Stripe's `redeem_by` on a Promotion Code is exactly that, and deliberately not the
|
|
28
|
+
* Coupon's own `redeem_by`, which would be the same word applied one level up. A test asserts an existing
|
|
29
|
+
* redemption survives the date.
|
|
30
|
+
*
|
|
31
|
+
* ## What Stripe decides and Pithy does not
|
|
32
|
+
*
|
|
33
|
+
* Whether a code is still usable, what a discounted invoice comes to, and whether a fixed amount can apply
|
|
34
|
+
* to a given subscription. Pithy refuses a currency mismatch it can see before creation, because that
|
|
35
|
+
* failure would otherwise arrive at the customer at redemption rather than at the adopter at creation — but
|
|
36
|
+
* it computes no prices and re-checks no redemptions.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** What creating a discount needs: the key, and the transport to reach Stripe through. */
|
|
40
|
+
export interface StripeDiscountOptions {
|
|
41
|
+
/** Stripe's credential block. Only `secretKey` is used here. */
|
|
42
|
+
credentials: PaymentsStripeCredentials;
|
|
43
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
44
|
+
transport?: StripeHttpFetch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A created Coupon, narrowed to its id. */
|
|
48
|
+
const StripeCoupon = z.object({ id: z.string().min(1) }).loose();
|
|
49
|
+
|
|
50
|
+
/** A created Promotion Code, narrowed to what an adopter needs back. */
|
|
51
|
+
const StripePromotionCode = z.object({ id: z.string().min(1), code: z.string().min(1) }).loose();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How many months Stripe should be told, for a duration this package counts in billing periods.
|
|
55
|
+
*
|
|
56
|
+
* The whole of the annual-plan defect lives in this function, which is why it is one named thing with a test
|
|
57
|
+
* against it rather than an expression inline.
|
|
58
|
+
*/
|
|
59
|
+
export function durationInMonths(billingPeriods: number, interval: "month" | "year"): number {
|
|
60
|
+
return interval === "year" ? billingPeriods * 12 : billingPeriods;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Create a Coupon and a Promotion Code for it, and return the code a customer will type. */
|
|
64
|
+
export async function createStripeDiscount(
|
|
65
|
+
terms: DiscountTerms,
|
|
66
|
+
options: StripeDiscountOptions,
|
|
67
|
+
): Promise<CreatedDiscount> {
|
|
68
|
+
const transport = options.transport ?? stripeHttpFetch;
|
|
69
|
+
|
|
70
|
+
const coupon: StripeFormObject =
|
|
71
|
+
terms.amount.kind === "percent"
|
|
72
|
+
? { percent_off: terms.amount.percent }
|
|
73
|
+
: { amount_off: terms.amount.amountMinor, currency: terms.amount.currency };
|
|
74
|
+
|
|
75
|
+
// `once` and `forever` are Stripe's own words for the same ideas; only `repeating` needs converting.
|
|
76
|
+
coupon.duration = terms.duration.kind;
|
|
77
|
+
if (terms.duration.kind === "repeating") {
|
|
78
|
+
if (terms.billingInterval === undefined) {
|
|
79
|
+
// `DiscountTerms` refuses this combination, so reaching it means somebody built the object without
|
|
80
|
+
// parsing. Refusing here too rather than defaulting to "month": the default would be right for a
|
|
81
|
+
// monthly plan and would turn an annual one into a twelve-year discount — exactly the failure the
|
|
82
|
+
// required field exists to prevent, committed silently.
|
|
83
|
+
throw new PaymentsDiscountInvalidError({
|
|
84
|
+
detail:
|
|
85
|
+
"A repeating discount reached the Stripe rail with no `billingInterval`. Stripe counts a duration in months, so billing periods cannot be converted without it. Build terms through `DiscountTerms.parse`.",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
coupon.duration_in_months = durationInMonths(terms.duration.billingPeriods, terms.billingInterval);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const created = await stripeJson(transport, "/coupons", {
|
|
92
|
+
what: "a discount",
|
|
93
|
+
secretKey: options.credentials.secretKey,
|
|
94
|
+
form: coupon,
|
|
95
|
+
});
|
|
96
|
+
const parsedCoupon = StripeCoupon.safeParse(created);
|
|
97
|
+
if (!parsedCoupon.success) {
|
|
98
|
+
throw new PaymentsProviderUnavailableError({ detail: "Stripe created a coupon and returned no id." });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const promotion: StripeFormObject = { coupon: parsedCoupon.data.id };
|
|
102
|
+
if (terms.code !== undefined) promotion.code = terms.code;
|
|
103
|
+
if (terms.maxRedemptions !== undefined) promotion.max_redemptions = terms.maxRedemptions;
|
|
104
|
+
// Seconds since the epoch, and on the Promotion Code rather than the Coupon: this date stops the code
|
|
105
|
+
// being *claimed*, and a customer who already redeemed it keeps their rate for its full duration.
|
|
106
|
+
if (terms.redeemableUntil !== undefined) {
|
|
107
|
+
promotion.redeem_by = Math.floor(terms.redeemableUntil.getTime() / 1000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const madeCode = await stripeJson(transport, "/promotion_codes", {
|
|
111
|
+
what: "a discount code",
|
|
112
|
+
secretKey: options.credentials.secretKey,
|
|
113
|
+
form: promotion,
|
|
114
|
+
});
|
|
115
|
+
const parsedCode = StripePromotionCode.safeParse(madeCode);
|
|
116
|
+
if (!parsedCode.success) {
|
|
117
|
+
throw new PaymentsDiscountInvalidError({
|
|
118
|
+
detail: `Stripe created coupon ${parsedCoupon.data.id} but would not attach a promotion code to it.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { code: parsedCode.data.code, providerDiscountId: parsedCode.data.id, terms };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stripe's promotion-code list, narrowed to what a management pane shows. */
|
|
126
|
+
const StripePromotionList = z
|
|
127
|
+
.object({
|
|
128
|
+
data: z.array(
|
|
129
|
+
z
|
|
130
|
+
.object({
|
|
131
|
+
id: z.string().min(1),
|
|
132
|
+
code: z.string().min(1),
|
|
133
|
+
times_redeemed: z.number().nullish(),
|
|
134
|
+
coupon: z
|
|
135
|
+
.object({
|
|
136
|
+
percent_off: z.number().nullish(),
|
|
137
|
+
amount_off: z.number().nullish(),
|
|
138
|
+
currency: z.string().nullish(),
|
|
139
|
+
})
|
|
140
|
+
.loose()
|
|
141
|
+
.nullish(),
|
|
142
|
+
})
|
|
143
|
+
.loose(),
|
|
144
|
+
),
|
|
145
|
+
})
|
|
146
|
+
.loose();
|
|
147
|
+
|
|
148
|
+
/** The promotion codes this account holds, newest first. */
|
|
149
|
+
export async function listStripeDiscounts(options: StripeDiscountOptions): Promise<readonly ListedDiscount[]> {
|
|
150
|
+
const found = await stripeJson(options.transport ?? stripeHttpFetch, "/promotion_codes", {
|
|
151
|
+
what: "the discount codes",
|
|
152
|
+
secretKey: options.credentials.secretKey,
|
|
153
|
+
query: { limit: DISCOUNT_PAGE },
|
|
154
|
+
});
|
|
155
|
+
const parsed = StripePromotionList.safeParse(found);
|
|
156
|
+
if (!parsed.success) return [];
|
|
157
|
+
return parsed.data.data.map((promotion) => ({
|
|
158
|
+
code: promotion.code,
|
|
159
|
+
providerDiscountId: promotion.id,
|
|
160
|
+
amount:
|
|
161
|
+
promotion.coupon?.percent_off != null
|
|
162
|
+
? `${promotion.coupon.percent_off}%`
|
|
163
|
+
: `${promotion.coupon?.amount_off ?? 0} ${promotion.coupon?.currency ?? ""}`.trim(),
|
|
164
|
+
redemptions: promotion.times_redeemed ?? null,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One page of codes. A discount list is small by nature; this bounds it without inventing pagination. */
|
|
169
|
+
const DISCOUNT_PAGE = 100;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_stripeChargeRefunded",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2025-04-30.basil",
|
|
5
|
+
"created": 1768003200,
|
|
6
|
+
"livemode": true,
|
|
7
|
+
"type": "charge.refunded",
|
|
8
|
+
"data": {
|
|
9
|
+
"object": {
|
|
10
|
+
"id": "ch_1PithyCoins",
|
|
11
|
+
"object": "charge",
|
|
12
|
+
"amount": 499,
|
|
13
|
+
"amount_refunded": 499,
|
|
14
|
+
"currency": "usd",
|
|
15
|
+
"customer": "cus_PithyAda",
|
|
16
|
+
"payment_intent": "pi_1PithyCoins",
|
|
17
|
+
"refunded": true,
|
|
18
|
+
"created": 1767225600,
|
|
19
|
+
"livemode": true,
|
|
20
|
+
"metadata": { "pithy_account_reference": "ada", "pithy_price_id": "price_1Coins" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_stripeInvoicePaid",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2025-04-30.basil",
|
|
5
|
+
"created": 1769904000,
|
|
6
|
+
"livemode": true,
|
|
7
|
+
"type": "invoice.paid",
|
|
8
|
+
"data": {
|
|
9
|
+
"object": {
|
|
10
|
+
"id": "in_1PithyAdaFeb",
|
|
11
|
+
"object": "invoice",
|
|
12
|
+
"customer": "cus_PithyAda",
|
|
13
|
+
"subscription": "sub_1PithyAdaPro",
|
|
14
|
+
"amount_paid": 999,
|
|
15
|
+
"currency": "usd",
|
|
16
|
+
"livemode": true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_stripeSessionPayment",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2025-04-30.basil",
|
|
5
|
+
"created": 1767225600,
|
|
6
|
+
"livemode": true,
|
|
7
|
+
"type": "checkout.session.completed",
|
|
8
|
+
"data": {
|
|
9
|
+
"object": {
|
|
10
|
+
"id": "cs_test_pithyCoins",
|
|
11
|
+
"object": "checkout.session",
|
|
12
|
+
"mode": "payment",
|
|
13
|
+
"status": "complete",
|
|
14
|
+
"payment_status": "paid",
|
|
15
|
+
"amount_total": 499,
|
|
16
|
+
"currency": "usd",
|
|
17
|
+
"customer": "cus_PithyAda",
|
|
18
|
+
"client_reference_id": "ada",
|
|
19
|
+
"payment_intent": "pi_1PithyCoins",
|
|
20
|
+
"subscription": null,
|
|
21
|
+
"created": 1767225600,
|
|
22
|
+
"expires_at": 1767312000,
|
|
23
|
+
"livemode": true,
|
|
24
|
+
"metadata": { "pithy_account_reference": "ada", "pithy_price_id": "price_1Coins" }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_stripeSessionSubscription",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2025-04-30.basil",
|
|
5
|
+
"created": 1767225600,
|
|
6
|
+
"livemode": true,
|
|
7
|
+
"type": "checkout.session.completed",
|
|
8
|
+
"data": {
|
|
9
|
+
"object": {
|
|
10
|
+
"id": "cs_test_pithyPro",
|
|
11
|
+
"object": "checkout.session",
|
|
12
|
+
"mode": "subscription",
|
|
13
|
+
"status": "complete",
|
|
14
|
+
"payment_status": "paid",
|
|
15
|
+
"amount_total": 999,
|
|
16
|
+
"currency": "usd",
|
|
17
|
+
"customer": "cus_PithyAda",
|
|
18
|
+
"client_reference_id": "ada",
|
|
19
|
+
"payment_intent": null,
|
|
20
|
+
"subscription": "sub_1PithyAdaPro",
|
|
21
|
+
"created": 1767225600,
|
|
22
|
+
"expires_at": 1767312000,
|
|
23
|
+
"livemode": true,
|
|
24
|
+
"metadata": { "pithy_account_reference": "ada", "pithy_price_id": "price_1Abc" }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_stripeSubscriptionCanceled",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2025-04-30.basil",
|
|
5
|
+
"created": 1768003200,
|
|
6
|
+
"livemode": true,
|
|
7
|
+
"type": "customer.subscription.updated",
|
|
8
|
+
"data": {
|
|
9
|
+
"object": {
|
|
10
|
+
"id": "sub_1PithyAdaPro",
|
|
11
|
+
"object": "subscription",
|
|
12
|
+
"customer": "cus_PithyAda",
|
|
13
|
+
"status": "active",
|
|
14
|
+
"cancel_at_period_end": true,
|
|
15
|
+
"canceled_at": 1768003200,
|
|
16
|
+
"created": 1767225600,
|
|
17
|
+
"start_date": 1767225600,
|
|
18
|
+
"currency": "usd",
|
|
19
|
+
"latest_invoice": "in_1PithyAdaJan",
|
|
20
|
+
"livemode": true,
|
|
21
|
+
"metadata": { "pithy_account_reference": "ada" },
|
|
22
|
+
"items": {
|
|
23
|
+
"object": "list",
|
|
24
|
+
"data": [
|
|
25
|
+
{
|
|
26
|
+
"id": "si_1PithyAdaPro",
|
|
27
|
+
"object": "subscription_item",
|
|
28
|
+
"quantity": 1,
|
|
29
|
+
"current_period_start": 1767225600,
|
|
30
|
+
"current_period_end": 1769904000,
|
|
31
|
+
"price": {
|
|
32
|
+
"id": "price_1Abc",
|
|
33
|
+
"object": "price",
|
|
34
|
+
"currency": "usd",
|
|
35
|
+
"unit_amount": 999,
|
|
36
|
+
"recurring": { "interval": "month", "interval_count": 1 }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_stripeSubscriptionCreated",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2025-04-30.basil",
|
|
5
|
+
"created": 1767225600,
|
|
6
|
+
"livemode": true,
|
|
7
|
+
"type": "customer.subscription.created",
|
|
8
|
+
"data": {
|
|
9
|
+
"object": {
|
|
10
|
+
"id": "sub_1PithyAdaPro",
|
|
11
|
+
"object": "subscription",
|
|
12
|
+
"customer": "cus_PithyAda",
|
|
13
|
+
"status": "active",
|
|
14
|
+
"cancel_at_period_end": false,
|
|
15
|
+
"created": 1767225600,
|
|
16
|
+
"start_date": 1767225600,
|
|
17
|
+
"currency": "usd",
|
|
18
|
+
"latest_invoice": "in_1PithyAdaJan",
|
|
19
|
+
"livemode": true,
|
|
20
|
+
"metadata": { "pithy_account_reference": "ada" },
|
|
21
|
+
"items": {
|
|
22
|
+
"object": "list",
|
|
23
|
+
"data": [
|
|
24
|
+
{
|
|
25
|
+
"id": "si_1PithyAdaPro",
|
|
26
|
+
"object": "subscription_item",
|
|
27
|
+
"quantity": 1,
|
|
28
|
+
"current_period_start": 1767225600,
|
|
29
|
+
"current_period_end": 1769904000,
|
|
30
|
+
"price": {
|
|
31
|
+
"id": "price_1Abc",
|
|
32
|
+
"object": "price",
|
|
33
|
+
"currency": "usd",
|
|
34
|
+
"unit_amount": 999,
|
|
35
|
+
"recurring": { "interval": "month", "interval_count": 1 }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "evt_stripeSubscriptionDeleted",
|
|
3
|
+
"object": "event",
|
|
4
|
+
"api_version": "2025-04-30.basil",
|
|
5
|
+
"created": 1769904000,
|
|
6
|
+
"livemode": true,
|
|
7
|
+
"type": "customer.subscription.deleted",
|
|
8
|
+
"data": {
|
|
9
|
+
"object": {
|
|
10
|
+
"id": "sub_1PithyAdaPro",
|
|
11
|
+
"object": "subscription",
|
|
12
|
+
"customer": "cus_PithyAda",
|
|
13
|
+
"status": "canceled",
|
|
14
|
+
"cancel_at_period_end": true,
|
|
15
|
+
"canceled_at": 1768003200,
|
|
16
|
+
"ended_at": 1769904000,
|
|
17
|
+
"created": 1767225600,
|
|
18
|
+
"start_date": 1767225600,
|
|
19
|
+
"currency": "usd",
|
|
20
|
+
"latest_invoice": "in_1PithyAdaJan",
|
|
21
|
+
"livemode": true,
|
|
22
|
+
"metadata": { "pithy_account_reference": "ada" },
|
|
23
|
+
"items": {
|
|
24
|
+
"object": "list",
|
|
25
|
+
"data": [
|
|
26
|
+
{
|
|
27
|
+
"id": "si_1PithyAdaPro",
|
|
28
|
+
"object": "subscription_item",
|
|
29
|
+
"quantity": 1,
|
|
30
|
+
"current_period_start": 1767225600,
|
|
31
|
+
"current_period_end": 1769904000,
|
|
32
|
+
"price": {
|
|
33
|
+
"id": "price_1Abc",
|
|
34
|
+
"object": "price",
|
|
35
|
+
"currency": "usd",
|
|
36
|
+
"unit_amount": 999,
|
|
37
|
+
"recurring": { "interval": "month", "interval_count": 1 }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { StripeHttpFetch, StripeHttpRequest } from "../api";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Test material for Stripe's rail: a known signing secret, a real HMAC over real bytes, and a transport that
|
|
8
|
+
* answers the way Stripe's API does.
|
|
9
|
+
*
|
|
10
|
+
* Stripe's authenticity proof is symmetric — the same secret signs and verifies — so a test can produce a
|
|
11
|
+
* genuinely valid `Stripe-Signature` header rather than stubbing the verifier. That is the whole point: the
|
|
12
|
+
* negative cases below are real failures of real cryptography. A tampered body produces a signature that does
|
|
13
|
+
* not match because the bytes changed, not because a mock was told to say no.
|
|
14
|
+
*
|
|
15
|
+
* The recorded event payloads live beside this module as JSON, unsigned, because a signature is over the exact
|
|
16
|
+
* bytes a test sends — so it must be computed at send time and cannot be recorded.
|
|
17
|
+
*
|
|
18
|
+
* This module signs; the shipped code only verifies. That is why it lives under `fixtures/`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The signing secret every Stripe suite uses. Shaped like Stripe's own (`whsec_` + base64-ish) so a test that
|
|
23
|
+
* accidentally logged it would be recognizable, and obviously fake so nobody mistakes it for a real one.
|
|
24
|
+
*/
|
|
25
|
+
export const STRIPE_TEST_WEBHOOK_SECRET = "whsec_pithyTestSigningSecretNotARealOne00";
|
|
26
|
+
|
|
27
|
+
/** The secret key the API fixtures pretend to call Stripe with. Test-mode shaped, and not a real key. */
|
|
28
|
+
export const STRIPE_TEST_SECRET_KEY = "sk_test_pithyTestKeyNotARealOne00";
|
|
29
|
+
|
|
30
|
+
/** What a test varies about the signature header. */
|
|
31
|
+
export interface StripeSignatureOptions {
|
|
32
|
+
/** The signing secret. The suite's own unless a case is proving a wrong one is refused. */
|
|
33
|
+
secret?: string;
|
|
34
|
+
/** The timestamp the header claims and the signature covers. Defaults to the clock the case passes in. */
|
|
35
|
+
timestamp?: Date;
|
|
36
|
+
/** The scheme label. `v1` unless a case is proving an unknown scheme is not accepted. */
|
|
37
|
+
scheme?: string;
|
|
38
|
+
/** Extra signature values to list before the real one — how Stripe presents a secret mid-rotation. */
|
|
39
|
+
extra?: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Hex, lower case — the encoding Stripe puts a `v1` signature in. */
|
|
43
|
+
function hex(bytes: Uint8Array): string {
|
|
44
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sign a body the way Stripe signs a webhook delivery: HMAC-SHA256 over `<timestamp>.<body>`, keyed with the
|
|
49
|
+
* endpoint's signing secret, presented as `t=<timestamp>,v1=<hex>`.
|
|
50
|
+
*
|
|
51
|
+
* The timestamp is inside the signed payload as well as beside it, which is what makes the replay window real:
|
|
52
|
+
* a delivery cannot be re-dated without invalidating its own signature.
|
|
53
|
+
*/
|
|
54
|
+
export async function stripeSignatureHeader(
|
|
55
|
+
body: string,
|
|
56
|
+
now: Date,
|
|
57
|
+
options: StripeSignatureOptions = {},
|
|
58
|
+
): Promise<string> {
|
|
59
|
+
const timestamp = Math.floor((options.timestamp ?? now).getTime() / 1000);
|
|
60
|
+
const key = await crypto.subtle.importKey(
|
|
61
|
+
"raw",
|
|
62
|
+
new TextEncoder().encode(options.secret ?? STRIPE_TEST_WEBHOOK_SECRET) as unknown as ArrayBuffer,
|
|
63
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
64
|
+
false,
|
|
65
|
+
["sign"],
|
|
66
|
+
);
|
|
67
|
+
const signature = new Uint8Array(
|
|
68
|
+
await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${body}`) as unknown as ArrayBuffer),
|
|
69
|
+
);
|
|
70
|
+
const values = [...(options.extra ?? []), hex(signature)];
|
|
71
|
+
return [`t=${timestamp}`, ...values.map((value) => `${options.scheme ?? "v1"}=${value}`)].join(",");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One recorded outbound call, so a suite can assert what left the Worker and not only what came back. */
|
|
75
|
+
export interface StripeRecordedCall {
|
|
76
|
+
/** The full URL, query string included. */
|
|
77
|
+
url: string;
|
|
78
|
+
/** The request as the transport received it. */
|
|
79
|
+
init?: StripeHttpRequest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What the stub answers with. A status alone is a failure; a body alone is a 200. */
|
|
83
|
+
export interface StripeStubAnswer {
|
|
84
|
+
/** The status to answer with. 200 unless a case is proving a failure path. */
|
|
85
|
+
status?: number;
|
|
86
|
+
/** The JSON body. Absent answers `{}`. */
|
|
87
|
+
body?: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A transport that answers Stripe's endpoints from a table keyed on a path fragment, recording every call.
|
|
92
|
+
*
|
|
93
|
+
* A fragment rather than a full URL because a retrieve carries an id and a query string, and a test that had to
|
|
94
|
+
* spell those out would be asserting its own string building. An unmatched call is a 404, which is the honest
|
|
95
|
+
* answer for an endpoint the case did not stub.
|
|
96
|
+
*/
|
|
97
|
+
export function stripeStubTransport(
|
|
98
|
+
answers: Record<string, StripeStubAnswer>,
|
|
99
|
+
calls: StripeRecordedCall[] = [],
|
|
100
|
+
): StripeHttpFetch {
|
|
101
|
+
return async (url, init) => {
|
|
102
|
+
calls.push({ url, init });
|
|
103
|
+
const matched = Object.entries(answers).find(([fragment]) => url.includes(fragment))?.[1];
|
|
104
|
+
const status = matched?.status ?? (matched === undefined ? 404 : 200);
|
|
105
|
+
return {
|
|
106
|
+
ok: status >= 200 && status < 300,
|
|
107
|
+
status,
|
|
108
|
+
text: async () => JSON.stringify(matched?.body ?? {}),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
}
|