@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,148 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * Who holds a purchase and the entitlements it grants — a **subject**, which is not always a person.
8
+ *
9
+ * A consumer app sells to people: one buyer, one holder, and a user id is the whole answer. A business
10
+ * selling to businesses has a different fact to record — the organization signs, the organization is
11
+ * invoiced, and everybody in it holds what it bought. Modeling that on a user-keyed table means either
12
+ * fanning a plan out across members (which drifts the moment somebody joins or leaves) or keying it to
13
+ * the owner (which makes the plan theirs, transferable with ownership, and invisible to the colleague
14
+ * their employer is paying for).
15
+ *
16
+ * So the holder is a **pair**: {@link PaymentsSubjectType} and an id. Both halves travel together
17
+ * everywhere — in a row, in a comparison, in a provider reference — because either half alone is
18
+ * ambiguous. Nothing in the kit keeps an organization id from equalling some user's id, so a comparison
19
+ * that read only the id would let one hold the other's subscription.
20
+ *
21
+ * **Which kind a project uses is decided once, in config** (`PaymentsConfig.billingSubject`), not per
22
+ * call. A codebase that could grant to a user on one route and an organization on the next is one where
23
+ * the two eventually disagree about who is entitled, and the disagreement surfaces as somebody being
24
+ * refused something they paid for.
25
+ *
26
+ * **The capability never learns what an organization is.** It has no members table, no roles, and no
27
+ * business acquiring either — memberships belong to the adopter. Under organization billing the
28
+ * capability asks *which subject is this caller acting for* and the adopter answers from its own
29
+ * session. Unanswered is unentitled, which is the direction every gate in the kit already fails.
30
+ */
31
+
32
+ /**
33
+ * The kinds of thing that can hold a purchase.
34
+ *
35
+ * **An enum rather than a free string**, because a free string is a column that ends up holding `org`,
36
+ * `organization` and `Organization` in three adopters, and every gate comparing it is then comparing
37
+ * spellings rather than facts.
38
+ *
39
+ * **Spelled with a `z`, deliberately.** The only realistic way an adopter has organizations at all is
40
+ * Better Auth's `organization()` plugin, which `@pithy-sh/auth` composes verbatim: it ships the
41
+ * `organization`, `member` and `invitation` tables and writes `active_organization_id` onto the session.
42
+ * An adopter wiring the subject seam reads `session.activeOrganizationId` and writes the value below on
43
+ * the next line, so the two must match. This is a stored token in a column and a UNIQUE index — it
44
+ * cannot be respelled later — and it is the one place in this repository where the American spelling is
45
+ * correct: it is an identifier inherited from a dependency, not prose.
46
+ */
47
+ export const PaymentsSubjectType = z
48
+ .enum(["user", "organization"])
49
+ .describe(
50
+ "What kind of thing holds a purchase — one person, or one organization. Spelled to match Better Auth's `organization()` plugin, whose session column an adopter reads to answer the subject seam.",
51
+ );
52
+ export type PaymentsSubjectType = z.infer<typeof PaymentsSubjectType>;
53
+
54
+ /**
55
+ * The longest a subject id may be.
56
+ *
57
+ * **Derived from the narrowest provider field a reference is written into, not chosen.** A subject
58
+ * crosses to a store as {@link encodeSubjectReference}'s output, and Stripe's `client_reference_id` caps
59
+ * at 200 characters. A provider that silently truncates a longer value would hand back a string that
60
+ * decodes to a *different* id, attributing the purchase to whoever holds it — so the cap is enforced
61
+ * here, at the schema, where it fails on the way out instead of on the way back.
62
+ *
63
+ * 200 less `organization:` (13) leaves 187; 180 is that with room for a longer type member than either
64
+ * of today's, and it is far past any id the kit or Better Auth mints (both are UUIDs).
65
+ */
66
+ export const MAX_SUBJECT_ID_LENGTH = 180;
67
+
68
+ /** How a subject's two halves are joined in a single string. See {@link encodeSubjectReference}. */
69
+ const SUBJECT_REFERENCE_SEPARATOR = ":";
70
+
71
+ /**
72
+ * The id half of a subject — opaque to this package. It is a Pithy user id under `user`, and whatever
73
+ * the adopter's own membership model calls an organization under `organization`.
74
+ */
75
+ const SubjectId = z
76
+ .string()
77
+ .min(1)
78
+ .max(MAX_SUBJECT_ID_LENGTH)
79
+ .describe(
80
+ "The subject's own id — a Pithy user id, or the adopter's organization id. Opaque here: payments never resolves it to anything.",
81
+ );
82
+
83
+ /**
84
+ * A subject: the pair, as every row carries it and every function takes it.
85
+ *
86
+ * **Passed as this object rather than two positional strings**, deliberately. Two adjacent `string`
87
+ * parameters are two parameters a call site can transpose, and transposing them typechecks. The object
88
+ * makes the halves nameable and inseparable, which is what keeps the invariant — a subject is read from
89
+ * and written to one row atomically, and no code path pairs a type from config with an id from a row.
90
+ */
91
+ export const PaymentsSubject = z
92
+ .object({
93
+ subjectType: PaymentsSubjectType.describe("Whether `subjectId` names a user or an organization."),
94
+ subjectId: SubjectId,
95
+ })
96
+ .describe("Who holds a purchase and the entitlements it grants — a user or an organization, and its id.");
97
+ export type PaymentsSubject = z.infer<typeof PaymentsSubject>;
98
+
99
+ /**
100
+ * Whether two subjects are the same holder — **both halves, always**.
101
+ *
102
+ * `undefined` is never equal to anything, including another `undefined`. An unresolved owner compared
103
+ * equal to an unresolved owner is how a webhook nobody could attribute would pass an ownership check,
104
+ * and that check is the only thing standing between one customer's renewal and another's account.
105
+ */
106
+ export function sameSubject(a: PaymentsSubject | undefined, b: PaymentsSubject | undefined): boolean {
107
+ if (a === undefined || b === undefined) return false;
108
+ return a.subjectType === b.subjectType && a.subjectId === b.subjectId;
109
+ }
110
+
111
+ /**
112
+ * A subject as one string, for the single-field slots a store gives us — Apple's `appAccountToken`,
113
+ * Google's `obfuscatedAccountId`, Stripe's `client_reference_id`, Paddle's and Lemon Squeezy's custom
114
+ * data.
115
+ *
116
+ * **One encoding, in one function, with one decoder.** These values leave our control and come back
117
+ * through a webhook, so the format is a wire contract with five providers at once. A second encoding
118
+ * anywhere means a purchase stamped by one code path and read by another resolves to nobody — or, worse,
119
+ * to somebody.
120
+ *
121
+ * The type half leads so the string sorts and greps by kind, and so a value that is *not* this encoding
122
+ * is recognizable at a glance in a provider's dashboard.
123
+ */
124
+ export function encodeSubjectReference(subject: PaymentsSubject): string {
125
+ return `${subject.subjectType}${SUBJECT_REFERENCE_SEPARATOR}${subject.subjectId}`;
126
+ }
127
+
128
+ /**
129
+ * The inverse, and **strict**: anything that is not exactly this encoding is `undefined`, never a guess.
130
+ *
131
+ * The input is a value a client put in a purchase and a store handed back, so it may be anything at all.
132
+ * The dangerous shape is a bare id — the format every pre-subject client sent — which a lenient decoder
133
+ * would read as a user, attributing a stranger's purchase to whoever holds that id. So a reference with
134
+ * no separator does not decode, an unknown type half does not decode, and an empty half does not decode.
135
+ * Every caller treats `undefined` as *orphaned*: the event is recorded and replayable, and nothing is
136
+ * granted.
137
+ *
138
+ * Splits on the **first** separator only, so an adopter whose organization ids contain colons round-trips.
139
+ */
140
+ export function decodeSubjectReference(reference: string): PaymentsSubject | undefined {
141
+ const at = reference.indexOf(SUBJECT_REFERENCE_SEPARATOR);
142
+ if (at <= 0) return undefined;
143
+ const parsed = PaymentsSubject.safeParse({
144
+ subjectType: reference.slice(0, at),
145
+ subjectId: reference.slice(at + SUBJECT_REFERENCE_SEPARATOR.length),
146
+ });
147
+ return parsed.success ? parsed.data : undefined;
148
+ }