@pithy-sh/payments 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. package/src/workflows/wrangler.jsonc +55 -0
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuditSeverity } from "@pithy-sh/core/src/audit/auditEvent";
5
+ import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
6
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
7
+ import type { LogLevel } from "@pithy-sh/core/src/logger/record";
8
+ import type { ReconcileReport } from "./reconcile";
9
+
10
+ /**
11
+ * What a reconcile pass says about itself: its tally, and each repair it had to make.
12
+ *
13
+ * Both are pure functions of a logger and a value, and both used to live in `worker.ts` beside the
14
+ * Workflow class that calls them. That module imports `cloudflare:workers`, which resolves in workerd
15
+ * and nowhere else, so everything it exported was reachable only from inside the Workers runtime — a
16
+ * Node-side caller taking one of these would have taken the whole runtime module with it and failed
17
+ * with `Could not load pithy.config.ts`, naming the config rather than the import.
18
+ *
19
+ * That is #172 and #180, twice, and neither was noticed until somebody accepted the offer. The shape
20
+ * both were fixed into is this one: a sibling module with no runtime import, which the runtime module
21
+ * imports from. `configEntrypoints.test.ts` states the invariant and holds every runtime module to it.
22
+ *
23
+ * Nothing here needs a binding, a request, or a Workers global, which is why its tests are node tests.
24
+ */
25
+
26
+ /** An audit event ranks itself; this is that rank as a log level. */
27
+ const AUDIT_LEVEL: Record<AuditSeverity, LogLevel> = { info: "info", warning: "warn", critical: "error" };
28
+
29
+ /**
30
+ * The audit emitter a standalone host can honestly offer: a structured record per repaired purchase.
31
+ *
32
+ * A host worker composes no capabilities, so there is no `@pithy-sh/audit` recorder to reach and no
33
+ * `c.var.emit` to inherit — writing to `pithy_audit_events` from here would mean this package reaching into
34
+ * another capability's tables, which is exactly what the seam exists to prevent. A logged event is what the
35
+ * seam degrades to everywhere else it is uncomposed, and it keeps the drift visible where an operator reads
36
+ * this worker's output. The event shape is core's, so a recorder wired in later takes the identical input.
37
+ *
38
+ * It takes the run's logger rather than building its own, so every event carries the instance the repair
39
+ * happened in — without that, a trail read out of Workers Logs cannot be attributed to a pass.
40
+ */
41
+ export function auditLogEmit(log: Logger): AuditEmit {
42
+ return async (event) => {
43
+ // The event already ranks itself, so the record takes that rank rather than inventing one — a repaired
44
+ // drift is emitted at `warning` because a pattern of them is a broken webhook path, and a log that
45
+ // flattened it to `info` would hide exactly the thing the trail exists to surface. Absent severity is
46
+ // the schema's own `info` default.
47
+ log[AUDIT_LEVEL[event.severity ?? "info"]]("audit event, no recorder composed", { ...event });
48
+ };
49
+ }
50
+
51
+ /**
52
+ * The tally, as one record. The run's only visible output: a pass whose findings are invisible is a pass
53
+ * nobody can tell has stopped working.
54
+ *
55
+ * Three levels, and the distinction is the point of logging it at all. `failed` counts purchases a store
56
+ * refused to answer for — a failure that was observed, so an operator has something to chase. `drifted`
57
+ * and `truncated` are the degraded-but-continuing pair: the first says webhooks are being dropped and the
58
+ * repair is covering for them, the second says the page cap stopped the pass with catalog left unread. A
59
+ * clean pass is routine, and a nightly job that reports routine at `warn` teaches an operator to ignore it.
60
+ */
61
+ export function logReconcileReport(log: Logger, report: ReconcileReport): void {
62
+ const level: LogLevel = report.failed > 0 ? "error" : report.drifted > 0 || report.truncated ? "warn" : "info";
63
+ log[level]("reconcile pass complete", { ...report });
64
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
5
+
6
+ /**
7
+ * **What the reconciliation pass retries, and what it refuses to.**
8
+ *
9
+ * The pass already draws this line in prose — `reconcile.ts` §"What a failure means" — and the line was
10
+ * the right one: a store that cannot be *reached* fails the page so the step re-drives it, and every
11
+ * other refusal is about one purchase and is counted rather than thrown. What was missing is that the
12
+ * platform never heard it, so anything that did escape a page was retried on the same terms
13
+ * (pithy-sh/pithy#338). This is that paragraph, in the form the step reads.
14
+ *
15
+ * ## Retryable
16
+ *
17
+ * - **`payments/provider_unavailable`** — Apple, Google, Stripe, Paddle or Lemon Squeezy unreachable, out
18
+ * of time, rate-limiting, or answering 5xx; every rail's HTTP layer folds those into this one code. A
19
+ * page is idempotent — it re-asks and re-projects to the same place — so a re-drive costs a request and
20
+ * fixes an outage that has since ended.
21
+ * - **`core/upstream_failed` / `core/upstream_timeout`** — the same fault, raised by a shared client
22
+ * rather than by a rail.
23
+ * - **A transient D1 fault** — classified in core by `withD1Retry`, never restated here.
24
+ *
25
+ * ## Terminal
26
+ *
27
+ * Everything else, and the list is deliberately not enumerated: an unmapped store state, a SKU no longer
28
+ * in the catalog, a rail switched off since the row was written, a receipt that will not verify, a
29
+ * parameter that will not validate. None of them answer differently to the same question an hour later,
30
+ * and a pass that spends its retry budget on one of them is a pass that does not reach page forty-one.
31
+ */
32
+ export const paymentsWorkflowRetry: WorkflowRetryPolicy = {
33
+ capability: "payments",
34
+ retryable: {
35
+ "payments/provider_unavailable":
36
+ "The store could not be reached. A page is idempotent, so re-asking is free and the outage may be over.",
37
+ "core/upstream_failed": "A dependency this pass does not control was unreachable; the next attempt may reach it.",
38
+ "core/upstream_timeout": "A dependency ran out of time rather than refusing; the same page can be asked again.",
39
+ },
40
+ };
@@ -0,0 +1,143 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
5
+ import type { WorkflowRegistry, WorkflowSpecMap } from "@pithy-sh/core/src/workflow/spec";
6
+ import { z } from "zod";
7
+ import { PaymentsRail } from "../data/rail";
8
+ import { PaymentsSubject } from "../data/subject";
9
+
10
+ /**
11
+ * The one durable job payments owns: the reconciliation pass.
12
+ *
13
+ * Declared once, here. `capability.ts` derives its Workflow binding from this map, `provision/` derives the
14
+ * host worker's `workflows` array and its cron from it, and any caller triggers it by the `payments/reconcile`
15
+ * key. There is no second place where the binding name, the class name, or the schedule is written down, so
16
+ * none of them can drift.
17
+ *
18
+ * `optional: true` because the Workflow lives in the prebuilt reconcile worker, which exists only once
19
+ * `pithy payments provision` has run. Until then an app composing payments must still verify receipts, accept
20
+ * webhooks, and resolve entitlements — every one of which works with no Workflow at all. An absent binding
21
+ * degrades to a logged skip rather than a boot failure.
22
+ */
23
+
24
+ /** The capability name — the first segment of the dispatch key and of every deployed resource name. */
25
+ export const PAYMENTS_CAPABILITY = "payments";
26
+
27
+ /** How far ahead of expiry a subscription is re-verified, when the caller names no window. Two days. */
28
+ export const DEFAULT_EXPIRING_WITHIN_SECONDS = 172_800;
29
+
30
+ /** How long a subscription may go unverified before a pass looks at it anyway. One week. */
31
+ export const DEFAULT_STALE_AFTER_SECONDS = 604_800;
32
+
33
+ /**
34
+ * The pass's parameters. **Every field is optional, and that is a requirement rather than a convenience:** a
35
+ * cron supplies no input, and `createEntrypoint` dispatches a scheduled job with `{}`. A schema that demanded
36
+ * a field could never run on its own schedule.
37
+ *
38
+ * The two windows are the pass's whole selection policy, and they are parameters rather than config because
39
+ * the reason to change one is always a one-off: reproducing a repair in staging without waiting for a
40
+ * subscription to age, or sweeping wider after an outage that ate a day of deliveries.
41
+ *
42
+ * **A holder narrowing is the pair, both halves or neither** — the rule `data/subject.ts` states, applied to
43
+ * the one field a support pass narrows on. Neither half is the cron. Both is *"reconcile this account"*. One
44
+ * half is refused rather than ignored: `subjectId` alone would scan whichever user *or* organization happens
45
+ * to carry that id, and nothing keeps those two ids apart, so the pass would answer about somebody else and
46
+ * report it as the account that was asked about.
47
+ */
48
+ export const PaymentsReconcileParams = z
49
+ .object({
50
+ subjectType: PaymentsSubject.shape.subjectType
51
+ .optional()
52
+ .describe(
53
+ "Which kind of holder to narrow to. Send it with `subjectId` or send neither — half a subject names nobody.",
54
+ ),
55
+ subjectId: PaymentsSubject.shape.subjectId
56
+ .optional()
57
+ .describe(
58
+ "Reconcile one holder's purchases instead of the whole catalog. This is the support tool for \"my subscription isn't showing up\" — the same steps, narrowed, so the answer comes from the same code path the cron runs. Send it with `subjectType`: an id alone names whichever user or organization happens to carry it.",
59
+ ),
60
+ rail: PaymentsRail.optional().describe(
61
+ "Reconcile one rail's purchases only. The pass to run when a single store's webhooks were interrupted, rather than paying for the other two.",
62
+ ),
63
+ expiringWithinSeconds: z
64
+ .number()
65
+ .int()
66
+ .positive()
67
+ .optional()
68
+ .describe(
69
+ "How far ahead of its expiry a subscription is re-verified. Omitted uses two days, so a daily cron looks at every subscription at least twice before the period it paid for ends.",
70
+ ),
71
+ staleAfterSeconds: z
72
+ .number()
73
+ .int()
74
+ .positive()
75
+ .optional()
76
+ .describe(
77
+ "How long a subscription may go unverified before a pass looks at it regardless of its expiry. Omitted uses a week. This is what catches a subscription whose state changed nowhere near a renewal — a refund, a pause, a revocation whose notification never arrived.",
78
+ ),
79
+ pageSize: z
80
+ .number()
81
+ .int()
82
+ .positive()
83
+ .optional()
84
+ .describe(
85
+ "Purchases per durable step. Each page is one journalled step, so this is the unit of work a retry repeats. Omitted uses 100, which keeps a page inside the life of the one access token it mints.",
86
+ ),
87
+ maxPages: z
88
+ .number()
89
+ .int()
90
+ .positive()
91
+ .optional()
92
+ .describe(
93
+ "Cap how many pages one run scans. A bound on the work, so a first pass over a very large catalog finishes rather than running until it is killed. The next run resumes from the beginning and reaches what this one did not, because a reconciled row stops matching the selection.",
94
+ ),
95
+ dryRun: z
96
+ .boolean()
97
+ .optional()
98
+ .describe(
99
+ "Ask every store and write nothing. The safe way to answer 'how much drift is there' before letting a pass repair it, and the way to measure whether the webhook path is working at all.",
100
+ ),
101
+ })
102
+ .refine(
103
+ (params) => (params.subjectType === undefined) === (params.subjectId === undefined),
104
+ // The remedy is the other field, so the refusal names it. This is read off a CLI flag most of the time,
105
+ // where the two-line house error is all the operator sees.
106
+ { message: "Narrow on `subjectType` and `subjectId` together, or neither. Half a subject names no holder." },
107
+ )
108
+ .describe("What one reconciliation run should cover. Every field optional, because a cron passes none of them.");
109
+ export type PaymentsReconcileParams = z.infer<typeof PaymentsReconcileParams>;
110
+
111
+ /**
112
+ * Payments' durable jobs, keyed by job name.
113
+ *
114
+ * The schedule is daily at 04:00 UTC — offset from storage's 03:00 sweep and the secrets rotation, so three
115
+ * hosts in one account do not contend for the same minute. Daily is the right cadence because subscription
116
+ * expiry windows are hours wide rather than minutes wide, and because the client-submission path already gives
117
+ * a purchaser immediate feedback: reconciliation is the repair path, never the primary one.
118
+ *
119
+ * It is a cron **and** a dispatch target. A pass nobody can run on demand cannot be tested in staging, and it
120
+ * could not be the support tool the issue asks for — "my subscription isn't showing up" is answered by running
121
+ * these exact steps for one user.
122
+ */
123
+ export const paymentsWorkflows = {
124
+ reconcile: {
125
+ binding: "PAYMENTS_RECONCILE",
126
+ className: "PaymentsReconcileWorkflow",
127
+ params: PaymentsReconcileParams,
128
+ schedule: "0 4 * * *",
129
+ optional: true,
130
+ },
131
+ } as const satisfies WorkflowSpecMap;
132
+
133
+ /**
134
+ * Payments' jobs as a dispatch registry, keyed `payments/<job>`. Built here rather than through
135
+ * `composeWorkflows` because the reconcile worker dispatches its own job before any project-wide registry
136
+ * exists — and the key format comes from core's {@link workflowKey} either way, so the two cannot drift.
137
+ */
138
+ export const paymentsWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
139
+ Object.entries(paymentsWorkflows).map(([job, spec]) => {
140
+ const key = workflowKey(PAYMENTS_CAPABILITY, job);
141
+ return [key, { key, capability: PAYMENTS_CAPABILITY, job, spec }];
142
+ }),
143
+ );
@@ -0,0 +1,166 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
5
+ import { NonRetryableError } from "cloudflare:workflows";
6
+ import { bindWorkflowContext, createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
7
+ import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
8
+ import { configureSharedSecrets, sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
9
+ import type { PurchaseEnvironment } from "../data/purchase";
10
+ import { triggerPaymentsReconcile } from "../http/dispatch";
11
+ import { PAYMENTS_PROVIDER_SECRET, paymentsSecretsRegistry, railCredentials } from "../secret/registry";
12
+ import { sweepPaddle } from "./paddleSweep";
13
+ import { batchedRailAccess } from "./railAccess";
14
+ import { type ReconcileReport, reconcilePayments } from "./reconcile";
15
+ import { auditLogEmit, logReconcileReport } from "./report";
16
+ import { paymentsWorkflowRetry } from "./retryPolicy";
17
+ import { PaymentsReconcileParams } from "./specs";
18
+ import { type PaymentsWorkerEnv, reconcileWorkerConfig } from "./workerConfig";
19
+
20
+ /**
21
+ * The prebuilt payments reconcile worker. `pithy payments provision` deploys one per environment; the adopter
22
+ * authors none of it. It hosts a single Workflow — the reconciliation pass — and fires it on a daily cron.
23
+ *
24
+ * **Why its own worker rather than the app worker's `scheduled()`.** The pass makes hundreds of authenticated
25
+ * calls to three stores and can rewrite any purchase row. Keeping it out of the request-serving deployment
26
+ * means it cannot compete with a checkout for CPU, and the app worker never needs the App Store Connect key
27
+ * or the Play service account on a path that serves a request. It is a Workflow rather than a plain scheduled
28
+ * pass because a Worker invocation is wall-clock bounded while a Workflow step is not: a catalog of ten
29
+ * thousand subscriptions is a hundred pages against rate-limited third-party APIs, and that is work you want
30
+ * journalled rather than restarted.
31
+ *
32
+ * ## A Workflow here runs with **no subject seam**, and that is structural
33
+ *
34
+ * Read this before adding the next Workflow to this worker. Payments splits the "who holds a purchase"
35
+ * decision in two: `billingSubject` is a two-value enum in `PaymentsConfig`, and `resolveSubject` is a
36
+ * **function** on `PaymentsOptions`, the non-config argument to the `payments()` factory. The split follows
37
+ * the round trip this file sits at the end of — `provision/resolvePaymentsConfig.ts` `JSON.stringify`s the
38
+ * config into the `PAYMENTS_CONFIG` var and {@link reconcileWorkerConfig} parses it back, and a function
39
+ * does not survive that.
40
+ *
41
+ * So this worker **always** runs with no adopter resolver, on every project, in every environment. There is
42
+ * nothing to run one against either: `resolvePaymentsSubject` takes a Hono context, and a cron fire has no
43
+ * request, no session, and no caller.
44
+ *
45
+ * **Every subject anything in here touches therefore comes off a row it read** — the pair stored on a
46
+ * purchase, the pair on a provider-account link, or the reference a checkout stamped and the store echoed
47
+ * back. That is not a gap waiting to be closed; it is what a durable repair pass has to do regardless, since
48
+ * the holder of a subscription bought eleven months ago is not whoever happens to be signed in tonight.
49
+ * A Workflow added here that wants to know who holds something reads the row, and if the row cannot say,
50
+ * the honest outcome is an orphan: recorded, replayable, granting nothing.
51
+ *
52
+ * This module imports `cloudflare:workers`, so it runs only in the Workers runtime and is excluded from the
53
+ * node `.describe()` meta-test.
54
+ */
55
+
56
+ export type { PaymentsWorkerEnv } from "./workerConfig";
57
+
58
+ // A standalone worker, not assembled by `createBackend`, so wire the shared secrets accessor directly. Without
59
+ // this the `secrets` capability's `compose` hook never runs and every rail's credential read throws.
60
+ configureSharedSecrets({ registry: paymentsSecretsRegistry });
61
+
62
+ /**
63
+ * This deployment's store environment, from the `ENVIRONMENT` var — the same rule the app worker's routes use.
64
+ *
65
+ * Only a worker deployed to `prod` is production. The failure directions are not symmetric: treating
66
+ * production as sandbox refuses a repair that the next pass makes anyway, while treating sandbox as production
67
+ * would let a test transaction rewrite a real entitlement.
68
+ */
69
+ function deploymentEnvironment(env: PaymentsWorkerEnv): PurchaseEnvironment {
70
+ return env.ENVIRONMENT === "prod" ? "production" : "sandbox";
71
+ }
72
+
73
+ /**
74
+ * The reconciliation pass, as a cron-triggered Workflow. One journalled step per page of purchases.
75
+ *
76
+ * Every step runs under {@link paymentsWorkflowRetry}: a store that could not be reached re-drives the
77
+ * page, and anything else fails it at once rather than spending a run's retry budget on an answer that
78
+ * will not change. See `retryPolicy.ts`.
79
+ */
80
+ export class PaymentsReconcileWorkflow extends WorkflowEntrypoint<PaymentsWorkerEnv, PaymentsReconcileParams> {
81
+ override async run(event: WorkflowEvent<PaymentsReconcileParams>, step: WorkflowStep): Promise<ReconcileReport> {
82
+ // Parse rather than trust: an instance can be started by the cron, by `triggerPaymentsReconcile`, or by an
83
+ // operator through the Cloudflare dashboard, and only the first two have already been validated.
84
+ const params = PaymentsReconcileParams.parse(event.payload ?? {});
85
+ // Before anything else in the run: a config the build cannot read makes every step below meaningless,
86
+ // and failing on the first line is what puts the refusal at the top of the instance rather than six
87
+ // journalled steps in.
88
+ const config = reconcileWorkerConfig(this.env);
89
+
90
+ // A run has no request, so there is no `c.var.log` to inherit: the Workflow builds its own and binds the
91
+ // instance onto it. The instance id is what the dashboard and `wrangler workflows` key on, so binding it
92
+ // once here is what lets an operator read a whole pass — tally and every repair — as one correlated set.
93
+ const log = bindWorkflowContext(createWorkerLogger({ name: "payments:reconcile" }), {
94
+ workflow: event.workflowName,
95
+ instance: event.instanceId,
96
+ env: this.env.ENVIRONMENT ?? "unknown",
97
+ });
98
+
99
+ // Read at the point of need, through the one reader. The credentials are handed to the rail access builder
100
+ // and never cached beyond the run.
101
+ const secrets = await sharedSecretsStore(this.env, paymentsSecretsRegistry);
102
+ const credentials = secrets.get(PAYMENTS_PROVIDER_SECRET);
103
+
104
+ const report = await reconcilePayments(
105
+ {
106
+ d1: this.env.DB,
107
+ config,
108
+ environment: deploymentEnvironment(this.env),
109
+ // Built per page inside the step, so each page mints its own batch tokens rather than replaying an
110
+ // expired pair after a retry.
111
+ railAccess: (now) => batchedRailAccess({ config, credentials, now }),
112
+ now: () => new Date(),
113
+ emit: auditLogEmit(log),
114
+ /**
115
+ * The Paddle events sweep, supplied only when that rail is on.
116
+ *
117
+ * Undefined otherwise, and the difference is a statement rather than an optimization: a rail
118
+ * nobody sells through did not sweep, where a rail that swept and found nothing is a healthy
119
+ * integration. Collapsing the two makes "the webhooks are fine" indistinguishable from "we never
120
+ * looked", which is the exact ambiguity this pass exists to remove.
121
+ *
122
+ * Built here rather than inside `reconcilePayments`, because it needs Paddle's credentials and its
123
+ * own account, and that module deliberately knows nothing about any single rail.
124
+ */
125
+ ...(config.rails.paddle && config.paddle !== undefined
126
+ ? {
127
+ sweepPaddle: () =>
128
+ sweepPaddle({
129
+ d1: this.env.DB,
130
+ config,
131
+ environment: deploymentEnvironment(this.env),
132
+ credentials: railCredentials(credentials, "paddle"),
133
+ paddleEnvironment: config.paddle?.environment ?? "sandbox",
134
+ // The raw `ENVIRONMENT`, not the two-valued store environment: the shared-sandbox fence
135
+ // separates `dev` from `staging`, and both are `sandbox` on the other axis.
136
+ deployment: this.env.ENVIRONMENT,
137
+ now: () => new Date(),
138
+ }),
139
+ }
140
+ : {}),
141
+ },
142
+ classifiedSteps(step, paymentsWorkflowRetry, NonRetryableError),
143
+ params,
144
+ );
145
+
146
+ logReconcileReport(log, report);
147
+ return report;
148
+ }
149
+ }
150
+
151
+ export default {
152
+ /**
153
+ * Cron entry: start one pass per fire, with empty parameters — the defaults are the scheduled behavior.
154
+ *
155
+ * Through the same dispatcher an on-demand pass uses, rather than `env.PAYMENTS_RECONCILE.create()`: the
156
+ * parameters are validated against the job's own schema before the binding is touched, and a host deployed
157
+ * without its binding logs a skip instead of throwing inside a cron nobody is watching.
158
+ */
159
+ async scheduled(_controller: unknown, env: PaymentsWorkerEnv): Promise<void> {
160
+ // The logger is the point of the sentence above. Without one the dispatcher logs its skip to the no-op,
161
+ // so a host deployed with `PAYMENTS_RECONCILE` absent or renamed fires at 04:00 every night, does nothing,
162
+ // and says nothing — the one failure mode a nightly repair job cannot afford, because silence is also what
163
+ // success looks like.
164
+ await triggerPaymentsReconcile(env as unknown as Record<string, unknown>, {}, createWorkerLogger());
165
+ },
166
+ };
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { fromZodError, InternalError, messageOf } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
7
+ import { PaymentsConfig } from "../config/config";
8
+
9
+ /**
10
+ * The reconcile worker's env, and the one function that reads its config out of a var.
11
+ *
12
+ * **A sibling module rather than part of `worker.ts`, and the split is a rule rather than a preference.**
13
+ * `worker.ts` imports `cloudflare:workers`, so it resolves only inside the Workers runtime — and anything it
14
+ * exports is therefore unreachable from a node test, from the CLI, and from every meta-test that walks this
15
+ * repository's exports. `packages/cli/src/capabilities/configEntrypoints.test.ts` fails a runtime module that
16
+ * exports a pure value for exactly that reason (#172), and this function is a pure value: it takes a plain
17
+ * object and returns a parsed config, touching no binding and no runtime API.
18
+ *
19
+ * So it lives here, where anything may import it, and `worker.ts` imports it like any other caller.
20
+ */
21
+
22
+ /** The reconcile worker's env: the app database, the secrets database, the master key, and its config. */
23
+ export interface PaymentsWorkerEnv extends SecretsStoreEnv {
24
+ /** The app database the `pithy_payments_*` tables live in. */
25
+ DB: D1Database;
26
+ /** The resolved payments config as a JSON string, filled at provision. See {@link reconcileWorkerConfig}. */
27
+ PAYMENTS_CONFIG?: string;
28
+ /** This worker's own Workflow binding — how `scheduled()` starts an instance. */
29
+ PAYMENTS_RECONCILE?: { create(options?: { id?: string; params?: unknown }): Promise<unknown> };
30
+ }
31
+
32
+ /**
33
+ * The catalog this worker reconciles against, read back out of the `PAYMENTS_CONFIG` var.
34
+ *
35
+ * **A config this does not accept stops the run, and since `billingSubject` became required that includes
36
+ * an absent var.** It used to fall back to `{}` and every default — which was survivable while every field
37
+ * had one, and is not now. The holder kind is the one thing a pass cannot default: `user` is what a
38
+ * defaulted config would produce, and a pass that repaired an organization's subscriptions while believing
39
+ * it was reconciling users would resolve entitlements for the wrong holder and audit the repairs under it.
40
+ * Refusing is the loud version of the same fact, and it fires on the first cron rather than on the first
41
+ * support ticket.
42
+ *
43
+ * Named as a refusal rather than a stack trace, because the reader is an operator: the message says which
44
+ * var, the action says which command rewrites it. Both `PithyError`s, so the Workflow's failure reads the
45
+ * way every other failure in the kit does.
46
+ */
47
+ export function reconcileWorkerConfig(env: PaymentsWorkerEnv): PaymentsConfig {
48
+ let value: unknown;
49
+ try {
50
+ // An empty var is absent, not malformed — a wrangler config with the key and no value is the same
51
+ // missing config as one without the key, and it deserves the refusal that names the missing key rather
52
+ // than one about JSON syntax.
53
+ value = env.PAYMENTS_CONFIG ? JSON.parse(env.PAYMENTS_CONFIG) : undefined;
54
+ } catch (cause) {
55
+ throw new InternalError({
56
+ message: "The reconcile worker's PAYMENTS_CONFIG var is not valid JSON.",
57
+ action: "Re-run `pithy payments provision` for this environment to rewrite it.",
58
+ detail: messageOf(cause),
59
+ });
60
+ }
61
+
62
+ const parsed = PaymentsConfig.safeParse(value ?? {});
63
+ if (!parsed.success) {
64
+ throw fromZodError(parsed.error, {
65
+ message: "The reconcile worker's PAYMENTS_CONFIG var is not a payments config this build can run.",
66
+ // The two halves of the repair, in the order they have to happen: the value is the adopter's, and the
67
+ // var is a copy of it. Fixing only the second is a provision that writes the same refusal back.
68
+ action:
69
+ "Set the missing keys in this Worker's pithy.config.ts — `billingSubject` is required — then re-run `pithy payments provision` for this environment.",
70
+ });
71
+ }
72
+ return parsed.data;
73
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ // The prebuilt payments reconcile worker. Like the storage, email, and media workers, this is a TEMPLATE,
3
+ // not a wrangler env-stanza file: staging and prod are genuinely separate workers. `pithy payments
4
+ // provision` resolves it into one complete config per environment — filling the `<...>` placeholders,
5
+ // deriving the `workflows` array and the cron from the capability's specs — and deploys each with
6
+ // `wrangler deploy --config <resolved>`. The adopter authors none of it.
7
+ // Resolved per project and env → <project>-staging-payments / <project>-prod-payments. Worker
8
+ // script names are account-scoped, so the project segment is what stops a second Pithy project's
9
+ // deploy overwriting this one's running worker instead of colliding with it.
10
+ "name": "pithy-payments",
11
+ "main": "./worker.ts",
12
+ // The compatibility date every Worker in this repository runs on. Stated once in the repository
13
+ // root's `compatibility.ts` and copied here because JSONC cannot import it —
14
+ // `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
15
+ "compatibility_date": "2026-06-01",
16
+ "compatibility_flags": ["nodejs_compat"],
17
+
18
+ // No public URL. Every payments route lives in the app worker; this worker is reached only by its cron and
19
+ // by Workflow dispatch. It holds three stores' credentials and can rewrite any purchase row, so it stays
20
+ // off workers.dev.
21
+ "workers_dev": false,
22
+
23
+ // The app database (the pithy_payments_* tables) and the secrets database, read for the rails' credentials.
24
+ "d1_databases": [
25
+ { "binding": "DB", "database_name": "pithy-app", "database_id": "<filled-at-provision>" },
26
+ { "binding": "SECRETS", "database_name": "pithy-secrets", "database_id": "<filled-at-provision>" }
27
+ ],
28
+
29
+ // The master key for decrypting the provider credentials, read through the secretsStore accessor.
30
+ "secrets_store_secrets": [
31
+ {
32
+ "binding": "SECRETS_ENCRYPTION_KEYS",
33
+ "store_id": "<filled-at-provision>",
34
+ "secret_name": "<filled-at-provision>"
35
+ }
36
+ ],
37
+
38
+ // The one Workflow this worker hosts. Rewritten at provision from `workflows/specs.ts`, so the binding, the
39
+ // class name, and the per-environment deployed name come from the spec rather than from this block — it is
40
+ // here so the template reads as a complete config.
41
+ "workflows": [
42
+ { "binding": "PAYMENTS_RECONCILE", "name": "pithy-payments-reconcile", "class_name": "PaymentsReconcileWorkflow" }
43
+ ],
44
+
45
+ // The daily pass, at 04:00 UTC. Also rewritten at provision from the spec's `schedule`, for the same reason
46
+ // — and offset from storage's 03:00 sweep so two hosts in one account do not contend for the same minute.
47
+ "triggers": { "crons": ["0 4 * * *"] },
48
+
49
+ "vars": {
50
+ // The resolved PaymentsConfig as one JSON blob, filled at provision from the app's payments() config. The
51
+ // pass reads the catalog from it — a refreshed SKU has to map to a product before it can be projected.
52
+ "PAYMENTS_CONFIG": "<filled-at-provision>",
53
+ "ENVIRONMENT": "<filled-at-provision>"
54
+ }
55
+ }