@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,130 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PaymentsProviderUnavailableError } from "../../error/errors";
|
|
6
|
+
import type { PaymentsPaddleCredentials } from "../../secret/registry";
|
|
7
|
+
import type { PortalHandoff, PortalSessionInput, PortalSubscriptionLinks } from "../contract";
|
|
8
|
+
import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch, paddleJson } from "./api";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The customer portal — where a Paddle subscriber changes a card or cancels, under Paddle's own rules.
|
|
12
|
+
*
|
|
13
|
+
* ## The links are 24-hour bearer credentials, not the single-use tokens the issue describes
|
|
14
|
+
*
|
|
15
|
+
* `POST /customers/{ctm_…}/portal-sessions` returns `urls.general.overview` plus, per subscription,
|
|
16
|
+
* `cancel_subscription` and `update_subscription_payment_method`. The issue calls those "single-use,
|
|
17
|
+
* short-lived". Live, the overview URL carries `token=pga_<JWT>` whose `iat` and `exp` are 86400 seconds
|
|
18
|
+
* apart, with scopes including `customer.subscription.update`, `customer.customer.update` and
|
|
19
|
+
* `customer.transaction.create`. It is a day of control over that customer's billing.
|
|
20
|
+
*
|
|
21
|
+
* So "never cached or persisted" has to mean more than not writing them to a table: never logged, never
|
|
22
|
+
* in an audit payload, and never a redirect target something downstream could read out of a `Referer`.
|
|
23
|
+
* Nothing in this module returns them anywhere but to the caller, and the route emits an audit row naming
|
|
24
|
+
* the customer and not one URL.
|
|
25
|
+
*
|
|
26
|
+
* ## The caller never names the customer, and never names a subscription
|
|
27
|
+
*
|
|
28
|
+
* `providerAccountId` comes from the provider-account map, keyed on the authenticated caller.
|
|
29
|
+
* `subscriptionIds` comes from that caller's own purchase rows. The `/portal` route takes no body at all,
|
|
30
|
+
* which is the request contract that makes this safe: either field there would let anyone mint
|
|
31
|
+
* authenticated cancel links against somebody else's subscription.
|
|
32
|
+
*
|
|
33
|
+
* ## There is no return URL, and config refuses one
|
|
34
|
+
*
|
|
35
|
+
* Paddle's portal takes no return parameter. {@link PortalSessionInput.returnUrl} is optional precisely so
|
|
36
|
+
* a rail can decline it in the type rather than accept one and silently drop it, and `paddle.portalReturnUrl`
|
|
37
|
+
* is refused by config rather than accepted and ignored — a URL an adopter wrote that nothing reads is a
|
|
38
|
+
* lie in a file they trust.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** What creating a portal session needs: the credentials, the account, and the transport. */
|
|
42
|
+
export interface PaddlePortalOptions {
|
|
43
|
+
/** The rail's credentials. Only `apiKey` is used here, and it needs `customer_portal_session.write`. */
|
|
44
|
+
credentials: PaymentsPaddleCredentials;
|
|
45
|
+
/** Which Paddle account to reach. */
|
|
46
|
+
environment: PaddleEnvironment;
|
|
47
|
+
/** The HTTP seam. Defaults to the runtime's `fetch`. */
|
|
48
|
+
transport?: PaddleHttpFetch;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Paddle asks for at most 25 subscription ids. More than that is not a shape this rail produces — one
|
|
53
|
+
* caller with 26 live subscriptions is not a case worth failing a portal open over — so the list is
|
|
54
|
+
* truncated with the newest kept rather than the request being refused.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_PORTAL_SUBSCRIPTIONS = 25;
|
|
57
|
+
|
|
58
|
+
/** A created portal session, narrowed to the URLs it carries. */
|
|
59
|
+
const PaddlePortalSession = z
|
|
60
|
+
.object({
|
|
61
|
+
urls: z
|
|
62
|
+
.object({
|
|
63
|
+
general: z
|
|
64
|
+
.object({ overview: z.string().min(1).describe("The portal's overview page for this customer.") })
|
|
65
|
+
.loose()
|
|
66
|
+
.describe("The account-wide links."),
|
|
67
|
+
subscriptions: z
|
|
68
|
+
.array(
|
|
69
|
+
z
|
|
70
|
+
.object({
|
|
71
|
+
id: z.string().min(1).describe("The subscription these links act on."),
|
|
72
|
+
cancel_subscription: z.string().min(1).describe("Where this subscription is canceled."),
|
|
73
|
+
update_subscription_payment_method: z
|
|
74
|
+
.string()
|
|
75
|
+
.min(1)
|
|
76
|
+
.describe("Where this subscription's payment method is changed."),
|
|
77
|
+
})
|
|
78
|
+
.loose(),
|
|
79
|
+
)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Per-subscription deep links, one entry per id asked for."),
|
|
82
|
+
})
|
|
83
|
+
.loose(),
|
|
84
|
+
})
|
|
85
|
+
.loose()
|
|
86
|
+
.describe("A Paddle customer portal session — an overview page, and the deep links asked for.");
|
|
87
|
+
|
|
88
|
+
/** Create a portal session for one customer, and return where they manage their billing. */
|
|
89
|
+
export async function createPaddlePortalSession(
|
|
90
|
+
input: PortalSessionInput,
|
|
91
|
+
options: PaddlePortalOptions,
|
|
92
|
+
): Promise<PortalHandoff> {
|
|
93
|
+
const ids = (input.subscriptionIds ?? []).slice(0, MAX_PORTAL_SUBSCRIPTIONS);
|
|
94
|
+
|
|
95
|
+
const answer = await paddleJson(
|
|
96
|
+
options.transport ?? paddleHttpFetch,
|
|
97
|
+
`/customers/${encodeURIComponent(input.providerAccountId)}/portal-sessions`,
|
|
98
|
+
{
|
|
99
|
+
what: "a customer portal session",
|
|
100
|
+
apiKey: options.credentials.apiKey,
|
|
101
|
+
environment: options.environment,
|
|
102
|
+
// An empty body rather than no body: this is a POST that creates a session, and Paddle answers the
|
|
103
|
+
// account-wide overview with no `subscription_ids` at all.
|
|
104
|
+
body: ids.length === 0 ? {} : { subscription_ids: ids },
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const parsed = PaddlePortalSession.safeParse(answer?.data);
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
// Paddle created a session and did not say where to send the browser. A redirect to an absent URL is
|
|
111
|
+
// worse than a refusal the caller can retry. This is also what an API key missing
|
|
112
|
+
// `customer_portal_session.write` looks like from here — `pithy doctor` names that permission.
|
|
113
|
+
throw new PaymentsProviderUnavailableError({
|
|
114
|
+
detail: `Paddle returned a portal session for customer ${input.providerAccountId} with no authenticated URLs. Check that the API key carries \`customer_portal_session.write\`.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const subscriptions: PortalSubscriptionLinks[] = (parsed.data.urls.subscriptions ?? []).map((entry) => ({
|
|
119
|
+
subscriptionId: entry.id,
|
|
120
|
+
cancel: entry.cancel_subscription,
|
|
121
|
+
updatePaymentMethod: entry.update_subscription_payment_method,
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
url: parsed.data.urls.general.overview,
|
|
126
|
+
// Omitted rather than empty, so a screen's `subscriptions === undefined` reads as "this rail does not
|
|
127
|
+
// do deep links" and an empty array reads as "it does, and this caller has none".
|
|
128
|
+
...(ids.length === 0 ? {} : { subscriptions }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
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 { RefundRequest, SubscriptionChangeQuote, SubscriptionStanding } from "../../data/subscription";
|
|
7
|
+
import type { PaymentsPaddleCredentials } from "../../secret/registry";
|
|
8
|
+
import type {
|
|
9
|
+
CheckoutHandoff,
|
|
10
|
+
CheckoutRail,
|
|
11
|
+
CheckoutSessionInput,
|
|
12
|
+
DiscountRail,
|
|
13
|
+
ListedDiscount,
|
|
14
|
+
PaymentsRailProvider,
|
|
15
|
+
PortalHandoff,
|
|
16
|
+
PortalSessionInput,
|
|
17
|
+
PricingRail,
|
|
18
|
+
RailRequestContext,
|
|
19
|
+
RefundRail,
|
|
20
|
+
RefundRequestInput,
|
|
21
|
+
SubscriptionCancelInput,
|
|
22
|
+
SubscriptionChangeInput,
|
|
23
|
+
SubscriptionRail,
|
|
24
|
+
UnboundProviderEvent,
|
|
25
|
+
VerifiedNotification,
|
|
26
|
+
VerifiedPurchase,
|
|
27
|
+
WebhookDelivery,
|
|
28
|
+
} from "../contract";
|
|
29
|
+
import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch } from "./api";
|
|
30
|
+
import { createPaddleCheckoutSession } from "./checkout";
|
|
31
|
+
import { createPaddleDiscount, listPaddleDiscounts } from "./discounts";
|
|
32
|
+
import { PaddleEvent } from "./objects";
|
|
33
|
+
import { createPaddlePortalSession } from "./portal";
|
|
34
|
+
import { PADDLE_ADJUSTMENTS_INCLUDE, readTransaction } from "./read";
|
|
35
|
+
import { readPaddlePricing, refreshPaddlePurchase } from "./refresh";
|
|
36
|
+
import { requestPaddleRefunds } from "./refund";
|
|
37
|
+
import {
|
|
38
|
+
cancelPaddleSubscription,
|
|
39
|
+
changePaddlePlan,
|
|
40
|
+
keepPaddleSubscription,
|
|
41
|
+
previewPaddleChange,
|
|
42
|
+
readPaddleStanding,
|
|
43
|
+
} from "./subscription";
|
|
44
|
+
import { verifyPaddleTransaction } from "./verify";
|
|
45
|
+
import { parsePaddleNotification, readPaddleEvent } from "./webhook";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Paddle rail as one provider object — the fifth, and the second merchant of record.
|
|
49
|
+
*
|
|
50
|
+
* Like Stripe and Lemon Squeezy this rail *initiates* purchases, so it implements {@link CheckoutRail}.
|
|
51
|
+
* Unlike Lemon Squeezy it also implements `verify`, and that is not a nicety: `dev` is not publicly
|
|
52
|
+
* routable, so a dev checkout's webhooks land at `staging`, and a submitted `txn_…` checked against a
|
|
53
|
+
* proven ownership stamp is what makes local development work at all. See `verify.ts`.
|
|
54
|
+
*
|
|
55
|
+
* It is also the first rail to implement {@link SubscriptionRail} — read a subscription's standing, quote a
|
|
56
|
+
* plan change, make one, cancel, and withdraw a cancellation. That is a third interface rather than three
|
|
57
|
+
* more methods on the shared contract for {@link CheckoutRail}'s reason: an Apple or Google subscription is
|
|
58
|
+
* changed inside the store's own UI, on the device, and a rail forced to declare `changePlan` would declare a
|
|
59
|
+
* method that cannot be written. `subscription.ts` holds the five, and every rule they enforce came out of
|
|
60
|
+
* sandbox recordings rather than the documentation (#465).
|
|
61
|
+
*
|
|
62
|
+
* It implements {@link RefundRail} too, and that is a *sixth* interface rather than a sixth subscription verb.
|
|
63
|
+
* The two abilities are independent in both directions — Play refunds from the server and changes no plan from
|
|
64
|
+
* it, Apple's only refund endpoint is a lookup — and `isSubscriptionRail` ANDs its methods, so widening it
|
|
65
|
+
* would have de-narrowed every rail already shipping the five. `refund.ts` holds it.
|
|
66
|
+
*
|
|
67
|
+
* Built per request from credentials the caller resolved through the secrets store, rather than reading
|
|
68
|
+
* them itself. That keeps the rail a pure function of its inputs and keeps the secret read at the point of
|
|
69
|
+
* need.
|
|
70
|
+
*
|
|
71
|
+
* **Merchant of record.** Paddle is the seller on the customer's statement. It calculates and remits sales
|
|
72
|
+
* tax and VAT worldwide, issues the invoices, runs dunning, and absorbs the chargebacks. The one place
|
|
73
|
+
* that shows in this package is that a refund can arrive with no local write preceding it — Paddle issues
|
|
74
|
+
* them on its own account.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/** How the Paddle rail is varied. */
|
|
78
|
+
export interface PaddleRailOptions {
|
|
79
|
+
/** Which Paddle account this deployment sells through — sandbox or production. */
|
|
80
|
+
environment: PaddleEnvironment;
|
|
81
|
+
/** The publishable client token a browser initializes Paddle.js with. */
|
|
82
|
+
clientToken: string;
|
|
83
|
+
/** Whether checkout opens as an overlay, inline, or on Paddle's own hosted page. */
|
|
84
|
+
checkout: "overlay" | "inline" | "hosted";
|
|
85
|
+
/** How many seconds either side of now a delivery may be dated. Undefined uses the rail's 300. */
|
|
86
|
+
freshnessSeconds?: number;
|
|
87
|
+
/**
|
|
88
|
+
* The currency this project's Paddle catalog prices in, when the project declared one.
|
|
89
|
+
*
|
|
90
|
+
* Only used to refuse a fixed discount in another currency before it reaches Paddle — see
|
|
91
|
+
* `discounts.ts` for why that refusal has to happen at creation rather than at redemption.
|
|
92
|
+
*/
|
|
93
|
+
storeCurrency?: string;
|
|
94
|
+
/** The HTTP seam Paddle's API is reached through. Defaults to the runtime's `fetch`. */
|
|
95
|
+
transport?: PaddleHttpFetch;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The Paddle rail. */
|
|
99
|
+
export function paddleRail(
|
|
100
|
+
credentials: PaymentsPaddleCredentials,
|
|
101
|
+
options: PaddleRailOptions,
|
|
102
|
+
): PaymentsRailProvider & CheckoutRail & DiscountRail & PricingRail & SubscriptionRail & RefundRail {
|
|
103
|
+
const transport = options.transport ?? paddleHttpFetch;
|
|
104
|
+
const base = { credentials, environment: options.environment, transport };
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
rail: "paddle",
|
|
108
|
+
|
|
109
|
+
async verify(receipt: string, context: RailRequestContext): Promise<VerifiedPurchase> {
|
|
110
|
+
// `return await`, not `return`. Returning a promise from an async function makes this frame *adopt*
|
|
111
|
+
// the rejection rather than raising it, and workerd then reports the adopted promise as an unhandled
|
|
112
|
+
// rejection even though Hono's `onError` answers the request correctly.
|
|
113
|
+
return await verifyPaddleTransaction(receipt, { ...base, deployment: context.deployment, now: context.now });
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async parseNotification(delivery: WebhookDelivery, context: RailRequestContext): Promise<VerifiedNotification> {
|
|
117
|
+
return await parsePaddleNotification(delivery, {
|
|
118
|
+
credentials,
|
|
119
|
+
environment: options.environment,
|
|
120
|
+
now: context.now,
|
|
121
|
+
deployment: context.deployment,
|
|
122
|
+
freshnessSeconds: options.freshnessSeconds,
|
|
123
|
+
// An adjustment says how much came off and never what the original was, so "full refund" is a
|
|
124
|
+
// comparison the parser cannot make without this read. The rail owns the transport, so the rail
|
|
125
|
+
// supplies it — and it is the one read that asks for `include=adjustments`, which is the one read
|
|
126
|
+
// that needs the key to carry `adjustment.read`.
|
|
127
|
+
readTransaction: (id) => readTransaction(id, base, PADDLE_ADJUSTMENTS_INCLUDE),
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async replay(
|
|
132
|
+
payload: Record<string, unknown>,
|
|
133
|
+
context: RailRequestContext,
|
|
134
|
+
): Promise<VerifiedNotification | undefined> {
|
|
135
|
+
// The recorded body is a Paddle event as the stream and the webhook both deliver it, so the map is the
|
|
136
|
+
// same one both paths already use. The signature is not re-checked and could not be: it covers a header
|
|
137
|
+
// this table never stored, and the row's authenticity was established when it was written.
|
|
138
|
+
const parsed = PaddleEvent.safeParse(payload);
|
|
139
|
+
if (!parsed.success) return undefined;
|
|
140
|
+
return await readPaddleEvent(parsed.data, {
|
|
141
|
+
credentials,
|
|
142
|
+
environment: options.environment,
|
|
143
|
+
now: context.now,
|
|
144
|
+
deployment: context.deployment,
|
|
145
|
+
readTransaction: (id) => readTransaction(id, base, PADDLE_ADJUSTMENTS_INCLUDE),
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async refresh(purchase: PaymentsPurchase, context: RailRequestContext): Promise<UnboundProviderEvent | undefined> {
|
|
150
|
+
return await refreshPaddlePurchase(purchase, { ...base, now: context.now });
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async createCheckoutSession(input: CheckoutSessionInput, context: RailRequestContext): Promise<CheckoutHandoff> {
|
|
154
|
+
return await createPaddleCheckoutSession(input, {
|
|
155
|
+
credentials,
|
|
156
|
+
environment: options.environment,
|
|
157
|
+
clientToken: options.clientToken,
|
|
158
|
+
checkout: options.checkout,
|
|
159
|
+
deployment: context.deployment,
|
|
160
|
+
transport,
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async createPortalSession(input: PortalSessionInput): Promise<PortalHandoff> {
|
|
165
|
+
return await createPaddlePortalSession(input, { credentials, environment: options.environment, transport });
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async readPricing(
|
|
169
|
+
purchase: PaymentsPurchase,
|
|
170
|
+
context: RailRequestContext,
|
|
171
|
+
): Promise<SubscriptionPricing | undefined> {
|
|
172
|
+
return await readPaddlePricing(purchase, { ...base, now: context.now });
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async listDiscounts(): Promise<readonly ListedDiscount[]> {
|
|
176
|
+
return await listPaddleDiscounts({
|
|
177
|
+
credentials,
|
|
178
|
+
environment: options.environment,
|
|
179
|
+
storeCurrency: options.storeCurrency,
|
|
180
|
+
transport,
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async createDiscount(terms: DiscountTerms): Promise<CreatedDiscount> {
|
|
185
|
+
return await createPaddleDiscount(terms, {
|
|
186
|
+
credentials,
|
|
187
|
+
environment: options.environment,
|
|
188
|
+
storeCurrency: options.storeCurrency,
|
|
189
|
+
transport,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async readStanding(purchase: PaymentsPurchase): Promise<SubscriptionStanding | undefined> {
|
|
194
|
+
return await readPaddleStanding(purchase, base);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async previewChange(input: SubscriptionChangeInput, context: RailRequestContext): Promise<SubscriptionChangeQuote> {
|
|
198
|
+
// The one method here that reads the context's locale: its answer is the only one carrying money a
|
|
199
|
+
// person reads. See `RailRequestContext.locale`.
|
|
200
|
+
return await previewPaddleChange(input, base, context.locale);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async changePlan(input: SubscriptionChangeInput): Promise<SubscriptionStanding> {
|
|
204
|
+
return await changePaddlePlan(input, base);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async cancelSubscription(input: SubscriptionCancelInput): Promise<SubscriptionStanding> {
|
|
208
|
+
return await cancelPaddleSubscription(input, base);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async keepSubscription(purchase: PaymentsPurchase): Promise<SubscriptionStanding> {
|
|
212
|
+
return await keepPaddleSubscription(purchase, base);
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async requestRefunds(input: RefundRequestInput): Promise<RefundRequest> {
|
|
216
|
+
return await requestPaddleRefunds(input, base);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PaymentsPaddleCredentials } from "../../secret/registry";
|
|
5
|
+
import { type PaddleEnvironment, type PaddleHttpFetch, paddleJson } from "./api";
|
|
6
|
+
import { PaddleSubscription, PaddleTransaction } from "./objects";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The reads this rail makes of Paddle: a transaction, and a subscription.
|
|
10
|
+
*
|
|
11
|
+
* Both return `undefined` rather than throwing on an absent object, because two callers with different
|
|
12
|
+
* needs share them. The webhook parser reads a transaction to learn whether a refund covers its whole
|
|
13
|
+
* total; the reconciliation pass reads one to find out what a purchase looks like now. For the first, an
|
|
14
|
+
* absent transaction is a delivery this deployment cannot project; for the second it is the contract's
|
|
15
|
+
* documented "the store no longer knows this purchase". Neither is a failure of the rail.
|
|
16
|
+
*
|
|
17
|
+
* A store that cannot be *reached* throws `payments/provider_unavailable`, which is what tells the
|
|
18
|
+
* reconciliation Workflow to fail the step and retry, and what makes the webhook guard answer non-2xx so
|
|
19
|
+
* Paddle redelivers.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What a read needs: the credentials, which account, and the transport. */
|
|
23
|
+
export interface PaddleReadOptions {
|
|
24
|
+
/** The rail's credentials. The API key is read at the point of need and never cached. */
|
|
25
|
+
credentials: PaymentsPaddleCredentials;
|
|
26
|
+
/** Which Paddle account to reach. */
|
|
27
|
+
environment: PaddleEnvironment;
|
|
28
|
+
/** The HTTP seam. */
|
|
29
|
+
transport: PaddleHttpFetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The include the adjustment map needs, named once so a call site cannot spell it differently. */
|
|
33
|
+
export const PADDLE_ADJUSTMENTS_INCLUDE: readonly string[] = ["adjustments"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The permission each include this rail asks for demands of the key, so a refusal names what to grant.
|
|
37
|
+
*
|
|
38
|
+
* Paddle's permissions reference: *"Your key needs read permission for any entity added via the `include`
|
|
39
|
+
* parameter"*, and a key without it gets `forbidden` (403). The OpenAPI spec says the same thing in
|
|
40
|
+
* machine-readable form — `x-enum-permissions: {adjustments: ['adjustment.read']}` on this very query
|
|
41
|
+
* parameter. A 403 whose message says only "Paddle refused" sends an operator to check the key itself,
|
|
42
|
+
* which is the one thing that is not wrong.
|
|
43
|
+
*/
|
|
44
|
+
const INCLUDE_PERMISSIONS: Readonly<Record<string, string>> = { adjustments: "adjustment.read" };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The transaction with this id, or `undefined` when Paddle has none.
|
|
48
|
+
*
|
|
49
|
+
* **An `include` is a permission demand, so it is asked for only by the caller that needs it.** Three of
|
|
50
|
+
* this rail's four transaction reads — receipt verification, reconciliation, and the sweep's own
|
|
51
|
+
* projection — want a status, a total and a `custom_data` stamp, all of which are on the transaction
|
|
52
|
+
* itself. Only the adjustment map needs the array, because an adjustment says how much came off and never
|
|
53
|
+
* what the original was: Paddle raises one adjustment per refund, so a transaction refunded in two goes
|
|
54
|
+
* carries two, and only their sum tells a full refund from a partial one.
|
|
55
|
+
*
|
|
56
|
+
* An earlier build sent `include=adjustments` on every read and its docstring claimed *"a key without
|
|
57
|
+
* adjustment-read permission simply gets no array back rather than a refusal"*. Paddle's own reference
|
|
58
|
+
* says the opposite — see {@link INCLUDE_PERMISSIONS} — so that build made `adjustment.read` a
|
|
59
|
+
* requirement of checking out, on a rail whose documentation tells adopters to scope keys narrowly. The
|
|
60
|
+
* adopter documentation now states the requirement where it is real, and the refusal names it.
|
|
61
|
+
*/
|
|
62
|
+
export async function readTransaction(
|
|
63
|
+
id: string,
|
|
64
|
+
options: PaddleReadOptions,
|
|
65
|
+
include?: readonly string[],
|
|
66
|
+
): Promise<PaddleTransaction | undefined> {
|
|
67
|
+
const asked = include === undefined || include.length === 0 ? undefined : include.join(",");
|
|
68
|
+
const needs = (include ?? [])
|
|
69
|
+
.map((entity) => INCLUDE_PERMISSIONS[entity])
|
|
70
|
+
.filter((permission): permission is string => permission !== undefined);
|
|
71
|
+
const answer = await paddleJson(options.transport, `/transactions/${encodeURIComponent(id)}`, {
|
|
72
|
+
what:
|
|
73
|
+
asked === undefined
|
|
74
|
+
? `transaction ${id}`
|
|
75
|
+
: `transaction ${id} with include=${asked}${needs.length === 0 ? "" : ` (this key needs the ${needs.join(" and ")} permission)`}`,
|
|
76
|
+
apiKey: options.credentials.apiKey,
|
|
77
|
+
environment: options.environment,
|
|
78
|
+
query: asked === undefined ? undefined : [["include", asked]],
|
|
79
|
+
absentOn404: true,
|
|
80
|
+
});
|
|
81
|
+
if (answer === undefined) return undefined;
|
|
82
|
+
const parsed = PaddleTransaction.safeParse(answer.data);
|
|
83
|
+
return parsed.success ? parsed.data : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The subscription with this id, or `undefined` when Paddle has none.
|
|
88
|
+
*
|
|
89
|
+
* `include=next_transaction,recurring_transaction_details` is asked for by the pricing read, which needs
|
|
90
|
+
* what the next invoice comes to and what it becomes once a discount lapses. It costs nothing on the
|
|
91
|
+
* paths that ignore it, and asking twice would be two round trips for one question.
|
|
92
|
+
*/
|
|
93
|
+
export async function readSubscription(
|
|
94
|
+
id: string,
|
|
95
|
+
options: PaddleReadOptions,
|
|
96
|
+
include?: readonly string[],
|
|
97
|
+
): Promise<PaddleSubscription | undefined> {
|
|
98
|
+
const answer = await paddleJson(options.transport, `/subscriptions/${encodeURIComponent(id)}`, {
|
|
99
|
+
what: `subscription ${id}`,
|
|
100
|
+
apiKey: options.credentials.apiKey,
|
|
101
|
+
environment: options.environment,
|
|
102
|
+
query: include === undefined || include.length === 0 ? undefined : [["include", include.join(",")]],
|
|
103
|
+
absentOn404: true,
|
|
104
|
+
});
|
|
105
|
+
if (answer === undefined) return undefined;
|
|
106
|
+
const parsed = PaddleSubscription.safeParse(answer.data);
|
|
107
|
+
return parsed.success ? parsed.data : undefined;
|
|
108
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PaddleEvent } from "./objects";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What of a Paddle event this build writes down — the control that keeps a live client token out of D1.
|
|
8
|
+
*
|
|
9
|
+
* ## The hazard
|
|
10
|
+
*
|
|
11
|
+
* Paddle's notification destinations and its `/events` stream are **account-wide**. A destination
|
|
12
|
+
* subscribed to `*`, or a sweep whose query filter is not honored, carries `api_key.*`,
|
|
13
|
+
* `client_token.*`, `product.*`, `price.*` and `discount.*` alongside the events this package acts on.
|
|
14
|
+
* Verified against the assigned sandbox's own stream: it carries `api_key.created` and
|
|
15
|
+
* `client_token.created`, and while Paddle redacts the api key's `key`, **it does not redact the client
|
|
16
|
+
* token's `token`.**
|
|
17
|
+
*
|
|
18
|
+
* `PaddleEvent` is `.loose()` and its `data` is `z.record(z.string(), z.unknown())`, so nothing in the
|
|
19
|
+
* schema narrows an unknown event's body. Storing the delivery whole therefore persists a live credential
|
|
20
|
+
* into `pithy_payments_webhook_events` — a table an operator greps, an export copies, and a backup keeps.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the control is here rather than in the query
|
|
23
|
+
*
|
|
24
|
+
* The sweep does ask Paddle for only the types below, and that filter is worth having. But a query
|
|
25
|
+
* parameter is a *request*: it is honored by someone else's service, on someone else's release cadence,
|
|
26
|
+
* and it does not exist at all on the webhook path, where the subscribed-event list is set in Paddle's
|
|
27
|
+
* dashboard by a human. An allowlist on what is **recorded** is a control this package owns and can prove.
|
|
28
|
+
*
|
|
29
|
+
* So: a type on this list is recorded whole, because its body is a transaction, a subscription or an
|
|
30
|
+
* adjustment and that body is the replay source. A type not on this list is recorded as its **envelope
|
|
31
|
+
* only** — id, type, and when — which keeps the row that makes the event id idempotent and drops the body
|
|
32
|
+
* this build was never going to read.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every event type recorded in full. Exactly the types the event map in `webhook.ts` acts on.
|
|
37
|
+
*
|
|
38
|
+
* Written out rather than derived from that module's sets on purpose: a list computed from the thing it is
|
|
39
|
+
* meant to constrain cannot constrain it. `recorded.test.ts` cross-checks the two declarations against
|
|
40
|
+
* each other in both directions, so adding a case to the map without adding its type here fails, and so
|
|
41
|
+
* does the reverse.
|
|
42
|
+
*/
|
|
43
|
+
export const PADDLE_RECORDED_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
44
|
+
"transaction.created",
|
|
45
|
+
"transaction.ready",
|
|
46
|
+
"transaction.paid",
|
|
47
|
+
"transaction.completed",
|
|
48
|
+
"transaction.payment_failed",
|
|
49
|
+
"transaction.past_due",
|
|
50
|
+
"transaction.canceled",
|
|
51
|
+
"transaction.updated",
|
|
52
|
+
"transaction.revised",
|
|
53
|
+
"transaction.billed",
|
|
54
|
+
"subscription.created",
|
|
55
|
+
"subscription.activated",
|
|
56
|
+
"subscription.resumed",
|
|
57
|
+
"subscription.trialing",
|
|
58
|
+
"subscription.past_due",
|
|
59
|
+
"subscription.paused",
|
|
60
|
+
"subscription.canceled",
|
|
61
|
+
"subscription.updated",
|
|
62
|
+
"subscription.imported",
|
|
63
|
+
"adjustment.created",
|
|
64
|
+
"adjustment.updated",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What to store for this event: the whole delivery, or the envelope alone.
|
|
69
|
+
*
|
|
70
|
+
* The envelope keeps `event_id` — which is the whole point of the row, since `UNIQUE (rail,
|
|
71
|
+
* providerEventId)` is what makes a redelivery recognized rather than reprocessed — plus the type and the
|
|
72
|
+
* time, so an operator can still see that an event arrived and what it was. Everything else is dropped,
|
|
73
|
+
* including any top-level key `.loose()` let through, because a key this build cannot name is a key it
|
|
74
|
+
* cannot vouch for.
|
|
75
|
+
*/
|
|
76
|
+
export function recordedPayload(event: PaddleEvent): Record<string, unknown> {
|
|
77
|
+
if (PADDLE_RECORDED_EVENT_TYPES.has(event.event_type)) return { ...event };
|
|
78
|
+
return {
|
|
79
|
+
event_id: event.event_id,
|
|
80
|
+
event_type: event.event_type,
|
|
81
|
+
occurred_at: event.occurred_at,
|
|
82
|
+
};
|
|
83
|
+
}
|