kempo-payments 0.1.1

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 (44) hide show
  1. package/README.md +400 -0
  2. package/admin/components/PaymentDetail.js +304 -0
  3. package/admin/components/PaymentList.js +261 -0
  4. package/admin/components/ProviderSettings.js +310 -0
  5. package/admin/components/TestPayment.js +283 -0
  6. package/admin/index.page.html +81 -0
  7. package/admin/nav.global.html +3 -0
  8. package/install.js +48 -0
  9. package/kempo-config.json +73 -0
  10. package/package.json +56 -0
  11. package/public/api/config/GET.js +48 -0
  12. package/public/api/payments/GET.js +31 -0
  13. package/public/api/payments/POST.js +41 -0
  14. package/public/api/payments/[id]/GET.js +30 -0
  15. package/public/api/payments/[id]/cancel/POST.js +19 -0
  16. package/public/api/payments/[id]/capture/POST.js +22 -0
  17. package/public/api/payments/[id]/client-secret/GET.js +23 -0
  18. package/public/api/payments/[id]/refund/POST.js +28 -0
  19. package/public/api/payments/[id]/sync/POST.js +23 -0
  20. package/public/api/providers/GET.js +16 -0
  21. package/public/api/settings/GET.js +36 -0
  22. package/public/api/settings/PUT.js +123 -0
  23. package/public/api/summary/GET.js +18 -0
  24. package/public/api/webhooks/[provider]/POST.js +37 -0
  25. package/public/components/PaymentForm.js +522 -0
  26. package/public/sdk.js +72 -0
  27. package/public/utils/money.js +67 -0
  28. package/sdk.js +42 -0
  29. package/server/db/schema.js +141 -0
  30. package/server/providers/index.js +84 -0
  31. package/server/providers/stripe.js +435 -0
  32. package/server/utils/config/declared.js +25 -0
  33. package/server/utils/config/settings.js +72 -0
  34. package/server/utils/money/currencies.js +67 -0
  35. package/server/utils/payments/actions.js +149 -0
  36. package/server/utils/payments/create.js +116 -0
  37. package/server/utils/payments/statuses.js +55 -0
  38. package/server/utils/payments/store.js +250 -0
  39. package/server/utils/payments/sync.js +179 -0
  40. package/server/utils/payments/text.js +38 -0
  41. package/server/utils/permissions/gate.js +42 -0
  42. package/server/utils/webhooks/receive.js +153 -0
  43. package/uninstall.js +35 -0
  44. package/update.js +14 -0
package/README.md ADDED
@@ -0,0 +1,400 @@
1
+ # kempo-payments
2
+
3
+ A payment-processing extension for the [kempo](https://github.com/dustinpoissant/kempo) CMS. Take a
4
+ payment, capture an authorisation, issue a refund — without the code doing it knowing which processor
5
+ is behind it.
6
+
7
+ Stripe is the processor that ships. It is not the one the rest of the extension is written against:
8
+ everything goes through a small provider interface, and adding PayPal or Square later is a new file
9
+ in `server/providers/`, not a change to anything that ever called `createPayment`.
10
+
11
+ This kempo (CMS) extension depends on no other kempo extension. Taking money does not require a
12
+ product catalog, so there is no dependency on `kempo-commerce` — the arrow points the other way. That is also what makes it usable
13
+ for anything else that needs to charge a card: a subscription, a donation, a one-off invoice.
14
+
15
+ ## What it is for
16
+
17
+ Other kempo (CMS) extensions. `kempo-payments` has no checkout of its own and no idea what anybody is buying.
18
+ It knows how to move money and how to tell you it moved.
19
+
20
+ ```js
21
+ import { createPayment } from 'kempo-payments/sdk';
22
+
23
+ const [error, { payment, clientSecret }] = await createPayment({
24
+ amount: 4999, // minor units, always — 4999 is $49.99
25
+ currency: 'usd',
26
+ owner: 'kempo-commerce', // who is asking
27
+ reference: order.id, // their own id for what is being paid for
28
+ userId: user?.id, // null for a guest
29
+ customerEmail: order.email,
30
+ description: `Order ${order.number}`,
31
+ });
32
+ ```
33
+
34
+ Hand `clientSecret` to the browser, mount the payment form against it, and then **stop watching the
35
+ browser**. Fulfilment hangs off the `payment:succeeded` hook, which is fired from the processor's
36
+ webhook — see [Hooks](#hooks-it-fires) for why that distinction is the whole design.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ npm install kempo-payments
42
+ ```
43
+
44
+ Then install it from the admin panel, or:
45
+
46
+ ```js
47
+ import installExtension from 'kempo/server/utils/extensions/installExtension.js';
48
+ await installExtension({ name: 'kempo-payments' });
49
+ ```
50
+
51
+ Requires kempo **4.2.40 or later** — the first release with the encrypted `secret` setting type that
52
+ every credential here is stored as.
53
+
54
+ ### `SETTINGS_ENCRYPTION_KEY` is required
55
+
56
+ Every credential this extension stores is a kempo `secret` setting, encrypted at rest. kempo cannot
57
+ encrypt anything without a key, so without one **no credential can be saved at all** — the settings
58
+ screen refuses, and installing prints a warning rather than leaving you to find out later.
59
+
60
+ ```bash
61
+ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
62
+ ```
63
+
64
+ Put it in the site's environment as `SETTINGS_ENCRYPTION_KEY` and restart. Back it up in the same
65
+ place as `DATABASE_URL`: losing it makes every stored secret on the site unrecoverable, and rotating
66
+ it means re-entering every one of them.
67
+
68
+ ## Connecting Stripe
69
+
70
+ Everything below is on the extension's own admin screen at
71
+ `/admin/extension/kempo-payments/` → **Settings**.
72
+
73
+ 1. **Get the keys.** Stripe dashboard → Developers → API keys. In test mode these begin `pk_test_`
74
+ and `sk_test_`. The secret key is shown once.
75
+ 2. **Paste both in and save.** The screen says which mode the keys are for, read off the key
76
+ prefixes rather than a switch — a switch can disagree with the keys, and "the toggle said test"
77
+ is an expensive sentence.
78
+ 3. **Add the webhook endpoint.** The screen shows the URL and the exact list of events to subscribe
79
+ it to. Stripe gives you a signing secret (`whsec_…`) when you create it; paste that in too.
80
+ 4. **Take a test payment.** The **Take a payment** tab runs the whole path — an intent created with
81
+ the secret key, the real payment form built with the publishable key, a confirmation from the
82
+ browser, and then the webhook coming back. Card `4242 4242 4242 4242`, any future expiry, any
83
+ CVC.
84
+
85
+ If step 4 ends at "Paid, but nothing came back", the charge worked and the webhook did not. That is
86
+ the failure worth catching before launch, because in production it means orders that are paid for
87
+ and never fulfilled.
88
+
89
+ ### Webhooks on localhost
90
+
91
+ Stripe cannot reach a development machine, so forward events to it instead:
92
+
93
+ ```bash
94
+ stripe login
95
+ stripe listen --forward-to localhost:9876/payments/api/webhooks/stripe
96
+ ```
97
+
98
+ `stripe listen` prints its **own** signing secret, different from the dashboard endpoint's. That is
99
+ the one to paste into the settings screen while developing.
100
+
101
+ ## Wallets and buy-now-pay-later
102
+
103
+ Apple Pay, Google Pay, Klarna, Afterpay, Cash App Pay and Amazon Pay are not separate providers
104
+ here and never will be. `<k-pay-form>`'s Express Checkout button row offers whichever of them are
105
+ available to a given customer, drawn and driven entirely by Stripe — no extra integration, no
106
+ separate merchant account, and no code in this extension to add one. Modelling any of them as its
107
+ own provider module would duplicate an integration that is already done.
108
+
109
+ "Available" is a dashboard setting, not a code path: Apple Pay and Google Pay need the site's domain
110
+ registered with Stripe (Settings → Payment methods → Apple Pay, and HTTPS — neither works over
111
+ plain HTTP, which is why they are absent in local development); Klarna, Cash App Pay and Amazon Pay
112
+ need to be switched on the same way. Stripe shows the not-yet-activated ones anyway in test mode, as
113
+ a preview of what turning them on would look like — genuinely useful for building a checkout, worth
114
+ knowing it means nothing about what a real customer will see until the dashboard step is done.
115
+
116
+ Two of them are not iframes at all. Tapping the Apple Pay or Google Pay button opens the *native OS
117
+ payment sheet* — Face ID, the device's own card picker — not anything this extension or Stripe
118
+ draws; the wallet encrypts the card on-device and only ever hands Stripe an already-encrypted
119
+ token. Klarna, Afterpay and similar hand the customer to a screen that has to stay the provider's:
120
+ they are running a soft credit check or an underwriting decision that only they can make. There is
121
+ no "backend only" version of that step, which is also why this extension has no custom UI of its
122
+ own for any of them — Stripe's Express Checkout button, tapped, is the entire integration.
123
+
124
+ ## Roadmap processors
125
+
126
+ Real second and third integrations, each with its own SDK, settlement and dispute model. Not
127
+ sequenced yet:
128
+
129
+ - **PayPal** — its Checkout SDK now covers what Braintree used to, which is why Braintree is not on
130
+ this list rather than an oversight.
131
+ - **Square** — the obvious pairing for a site with a physical till.
132
+ - **Mollie** — European coverage Stripe prices differently.
133
+ - **Amazon Pay**.
134
+
135
+ **Adyen** was considered and excluded: its onboarding is enterprise, application-based and
136
+ account-manager-mediated, which does not fit a self-serve platform a small seller installs.
137
+
138
+ ## The payment form
139
+
140
+ A reusable custom element, served from this extension:
141
+
142
+ ```html
143
+ <script type="module" src="/payments/components/PaymentForm.js"></script>
144
+
145
+ <k-pay-form
146
+ client-secret="pi_3..._secret_..."
147
+ return-url="https://example.com/thank-you"
148
+ submit-label="Pay $49.99"
149
+ ></k-pay-form>
150
+ ```
151
+
152
+ It shows two things, either of which may be absent depending on what is available:
153
+
154
+ - **Express Checkout** — a row of wallet buttons (Apple Pay, Google Pay, Link, and PayPal or Amazon
155
+ Pay if enabled), drawn and managed entirely by Stripe. Automatically hidden when nothing is
156
+ available for that customer's browser/account, with no gap left behind.
157
+ - **Card fields** — number, expiry, CVC and a plain ZIP/postal input, laid out and labelled by this
158
+ extension's own markup. Each of the three card fields is a small iframe belonging to the
159
+ processor; typing sends the digits from the customer's browser straight to it, never through this
160
+ server, which is what keeps a kempo site out of PCI scope. There is no supported way to collect a
161
+ card number in a plain kempo-ui `<input>` without taking on PCI scope yourself; this is as close
162
+ as a card field gets to looking like the rest of the form.
163
+
164
+ Neither half of the modern `appearance` API reaches these three fields — confirmed by testing,
165
+ twice over, not assumed. Their box — background, border, radius, padding, focus ring — is real CSS
166
+ on the wrapper `<div>` (a `.k-pay-field` class this component injects once, copied property-for-
167
+ property from kempo-css's own `input` rule), because these particular Stripe fields render with a
168
+ transparent, unpadded iframe by design — the caller's page has always been expected to draw the
169
+ box — and `appearance.rules` (which does restyle Stripe's *newer* elements, Express Checkout
170
+ included) does not reach them at all. Their text colour uses the `style` option instead —
171
+ `style.base.color` / `style.base['::placeholder']`, Stripe's original, pre-`appearance` styling
172
+ mechanism for exactly this element family — because `appearance.variables.colorText` doesn't reach
173
+ them either, the same silent no-effect failure one layer deeper.
174
+
175
+ > **Do not put `<k-pay-form>` inside a shadow root.** Stripe's fields do not mount in one — they
176
+ > reserve their height and render nothing, with no error anywhere. If a checkout shows an empty box
177
+ > where a card field should be, this is why: something in the chain of custom elements above it is
178
+ > using shadow DOM. Everything hosting the form has to be light DOM.
179
+
180
+ Klarna, Afterpay, Cash App Pay and Amazon Pay are not excluded — see
181
+ [Wallets and buy-now-pay-later](#wallets-and-buy-now-pay-later) for how they appear through Express
182
+ Checkout the same as Apple Pay and Google Pay do, gated by dashboard activation rather than by
183
+ anything in this component. What this rewrite did trade away is Stripe's unified, all-methods,
184
+ single-tabbed-widget layout (the previous version of this component) in exchange for owning the
185
+ card fields' own look. Wanting that back is one component, not a rewrite: mount
186
+ `elements.create('payment')` instead of the split card fields and Express Checkout Element — the
187
+ server side of this extension does not care which client-side widget produced the confirmation.
188
+
189
+ | Event | When |
190
+ |---|---|
191
+ | `payment-succeeded` | the processor confirmed it in this browser |
192
+ | `payment-processing` | accepted, still settling — normal for bank debits |
193
+ | `payment-failed` | declined, or the details were rejected |
194
+
195
+ Use these to move the customer along. **Do not use them to fulfil an order.** A browser closed at
196
+ the wrong moment fires nothing at all, and a browser under someone's control fires whatever they
197
+ like.
198
+
199
+ ## Hooks it fires
200
+
201
+ Registered by consumers in their own `kempo-config.json`, the same as any other kempo hook:
202
+
203
+ ```json
204
+ "hooks": {
205
+ "payment:succeeded": "hooks/payment-succeeded.js"
206
+ }
207
+ ```
208
+
209
+ | Event | Data | When |
210
+ |---|---|---|
211
+ | `payment:created` | `{ payment }` | an attempt has been started; no money has moved |
212
+ | `payment:authorized` | `{ payment }` | held on the customer's card, waiting to be captured |
213
+ | `payment:succeeded` | `{ payment }` | **taken** — this is the one to fulfil against |
214
+ | `payment:failed` | `{ payment, error }` | the processor refused it |
215
+ | `payment:canceled` | `{ payment }` | abandoned, or the hold released |
216
+ | `payment:refunded` | `{ payment, refund }` | some or all of it given back |
217
+ | `payment:disputed` | `{ payment, disputed, reason }` | a chargeback opened, or closed |
218
+
219
+ `payment:succeeded` fires **once** per payment, on the transition into that state and nowhere else.
220
+ Redelivered webhooks, polling and admin screens all pass through the same place and produce no
221
+ transition, so nothing downstream sends a second confirmation email or ships a second box.
222
+
223
+ ## Capture: automatic or manual
224
+
225
+ **Automatic** charges the card the moment the customer confirms. Right for anything delivered
226
+ immediately.
227
+
228
+ **Manual** places a hold and takes nothing until somebody captures it. Right for anything that
229
+ ships, where the amount can change or the order can be cancelled before it goes out. The hold
230
+ expires on its own — typically after seven days — and an authorisation nobody captured is money the
231
+ customer had reserved and the merchant never received.
232
+
233
+ The site-wide default is a setting; any individual payment can override it.
234
+
235
+ A capture method is **never guessed at**. `Manual` and `MANUAL ` are read as `manual` — case and
236
+ stray spaces are forgiven — but anything else (`hold`, `authorize`, a typo) is refused with a 400
237
+ naming the two that exist, before any processor is contacted. The alternative was falling back to
238
+ `automatic`, which turns "reserve this card" into "charge this card" for every mistyped word. The
239
+ same rule applies to the setting on the settings screen.
240
+
241
+ ## Refunds and cancellations
242
+
243
+ They are not the same thing and the extension will not let them be confused:
244
+
245
+ - A payment that is **authorised but not captured** has had nothing taken. Releasing it is a
246
+ **cancel**. Asking to refund it is refused, with that sentence rather than the processor's.
247
+ - A payment that **succeeded** can be refunded, in full or in part, repeatedly, until nothing is
248
+ left outstanding.
249
+
250
+ The refunded total is recomputed from the individual refund rows rather than incremented, and every
251
+ refund is keyed by the processor's own reference. An admin refund and the webhook reporting the same
252
+ refund a second later are one row and one total — which is what stops a payment claiming twice the
253
+ money went back.
254
+
255
+ Refunds made directly in the processor's dashboard arrive by webhook and are recorded here too.
256
+ Stripe's `charge.refunded` event reports only the new running total, not which refunds make it up,
257
+ so the extension asks Stripe for the refunds on that payment before recording them. **Check with
258
+ Stripe** on a payment's screen reconciles the same way, and will pick up a refund whose webhook was
259
+ missed entirely.
260
+
261
+ A refund amount larger than what is left is refused with the remaining amount in the payment's own
262
+ currency (`Only $5.00 is left to refund on this payment`).
263
+
264
+ ## When an event cannot be applied
265
+
266
+ The webhook marks an event handled only **after** it has been applied. If applying it fails — the
267
+ database would not write — the endpoint answers with an error, the claim on that event is released,
268
+ and Stripe's retry is processed rather than being answered "duplicate" and dropped. Text that
269
+ contains a NUL byte (which Postgres cannot store) is refused with a 400 when a caller supplies it
270
+ and stripped when the processor does, since by then the money has already moved.
271
+
272
+ ## Permissions
273
+
274
+ | Permission | Allows |
275
+ |---|---|
276
+ | `payments:view` | See payments, what they were for, and what happened to them |
277
+ | `payments:create` | Take a payment — start a new charge over the API |
278
+ | `payments:capture` | Capture or cancel a payment that was only authorised |
279
+ | `payments:refund` | Refund a payment, in full or in part |
280
+ | `payments:settings` | Change the payment provider and its API credentials |
281
+
282
+ Two groups ship with them: **`kempo-payments:operator`** handles payments day to day — looking them
283
+ up, capturing, refunding — without being able to see or change credentials, and
284
+ **`kempo-payments:administrator`** adds connecting the site to a processor.
285
+
286
+ `payments:create` is deliberately not held by an ordinary visitor. The HTTP route that starts a
287
+ payment takes the amount from the request body, so an open version of it would let anybody decide
288
+ what they owe. A customer-facing checkout computes the amount on its own server and calls the SDK.
289
+
290
+ ## Settings
291
+
292
+ | Setting | Default | Meaning |
293
+ |---|---|---|
294
+ | `provider` | `stripe` | Which processor money goes through |
295
+ | `currency` | `usd` | Used when the caller does not name one |
296
+ | `capture_method` | `automatic` | See [Capture](#capture-automatic-or-manual) |
297
+ | `stripe_publishable_key` | — | `pk_test_…` / `pk_live_…`; public by design |
298
+ | `stripe_secret_key` | — | `sk_test_…` / `sk_live_…`; encrypted, never leaves the server |
299
+ | `stripe_webhook_secret` | — | `whsec_…`; without it, no payment is ever confirmed |
300
+
301
+ ## Server SDK
302
+
303
+ ```js
304
+ import {
305
+ createPayment, capturePayment, cancelPayment, refundPayment,
306
+ getPayment, getPaymentByRef, paymentsForReference, listPayments,
307
+ syncPayment, getClientSecret, refundableAmount, formatAmount,
308
+ } from 'kempo-payments/sdk';
309
+ ```
310
+
311
+ Every one returns kempo's `[error, data]` tuple. None of them checks a permission — the HTTP routes
312
+ do that, and anything importing this is server-side code that has already decided it is allowed.
313
+
314
+ `paymentsForReference(owner, reference)` is the lookup a consumer wants: it knows its order number,
315
+ not a payment id.
316
+
317
+ ## HTTP API
318
+
319
+ Served under the extension's public scope, `/payments/`.
320
+
321
+ | Route | Permission | |
322
+ |---|---|---|
323
+ | `GET /payments/api/config` | none | Provider and publishable key, for the payment form |
324
+ | `GET /payments/api/payments` | `payments:view` | Filtered, paged list |
325
+ | `POST /payments/api/payments` | `payments:create` | Start a payment |
326
+ | `GET /payments/api/payments/:id` | `payments:view` | One payment, its refunds and its event log |
327
+ | `GET /payments/api/payments/:id/client-secret` | `payments:create` | Re-mount a form against a payment in flight |
328
+ | `POST /payments/api/payments/:id/capture` | `payments:capture` | |
329
+ | `POST /payments/api/payments/:id/cancel` | `payments:capture` | |
330
+ | `POST /payments/api/payments/:id/refund` | `payments:refund` | |
331
+ | `POST /payments/api/payments/:id/sync` | `payments:view` | Re-read from the processor |
332
+ | `GET /payments/api/summary` | `payments:view` | Counts and totals per currency |
333
+ | `GET`/`PUT /payments/api/settings` | `payments:settings` | Never returns a credential, only whether one is set |
334
+ | `POST /payments/api/webhooks/:provider` | signature | The processor's own endpoint |
335
+
336
+ ## Amounts are integers
337
+
338
+ Always the currency's smallest unit. `4999` is $49.99. `1200` is ¥1,200, because yen have no minor
339
+ unit at all — and a Kuwaiti dinar has three decimal places, not two.
340
+
341
+ Floats are refused rather than converted. A caller passing `49.99` meant $49.99 and would be
342
+ charging five cents; the processor would accept that instruction without complaint. `toMinor()` is
343
+ exported for the places where a human genuinely typed a decimal into a box.
344
+
345
+ ## Adding a provider
346
+
347
+ One file in `server/providers/`, exporting `name`, `label`, `webhookPath`, `webhookEvents`,
348
+ `signatureHeader`, `credentialFields`, and the functions `status`, `clientConfig`, `createIntent`,
349
+ `retrieveIntent`, `capture`, `cancel`, `refund`, `verifyEvent` and `interpretEvent`. Add its
350
+ credential settings to `kempo-config.json` and register it in `server/providers/index.js`.
351
+
352
+ The contract is documented in full at the top of `server/providers/index.js`. Statuses coming out of
353
+ it must be kempo's, not the processor's — mapping is the provider module's job, precisely so the
354
+ rest of the extension has one vocabulary rather than one per processor.
355
+
356
+ ## What this does not do
357
+
358
+ - **Subscriptions and recurring billing.** A separate concern with its own state machine, dunning
359
+ and proration. This extension is the thing one would be built on.
360
+ - **Saved cards and stored customers.** Nothing is kept on file; every payment stands alone.
361
+ - **Payouts, balances and reconciliation.** The processor's dashboard is the ledger.
362
+ - **Answering disputes.** They are flagged here and answered there — evidence submission is a
363
+ processor-specific workflow with deadlines attached.
364
+ - **Tax.** That is `kempo-tax`, deliberately separate: "compute what is owed" and "move money" are
365
+ orthogonal, and merging them would stop anybody swapping in their own tax calculator.
366
+ - **A checkout.** No cart, no order, no idea what is being bought. That is `kempo-commerce`.
367
+
368
+ ## Uninstalling
369
+
370
+ The tables are dropped, which loses this site's record of every payment it has taken. That record is
371
+ a copy — the charges themselves live at the processor and are entirely unaffected, and any of them
372
+ can still be refunded from its dashboard.
373
+
374
+ Uninstalling names any payment still authorised and uncaptured on the way out. Those are holds on
375
+ real cards that nothing will capture or release once the extension is gone.
376
+
377
+ ## Development
378
+
379
+ ```bash
380
+ npm install
381
+ npm run link:local # symlinks sibling kempo checkouts
382
+ docker compose up -d # test database on port 5439
383
+ DATABASE_URL=postgresql://kempo:kempo@localhost:5439/kempo_payments_test npx drizzle-kit push --force
384
+ npm test
385
+ ```
386
+
387
+ The database suites (data layer, lifecycle, webhooks) **skip themselves** when no database is
388
+ reachable, and a skip reads as a pass — check for `(SKIPPED)` before believing a green run covered
389
+ them. They also skip themselves when `DATABASE_URL` names a database that does not end in `_test`,
390
+ because they **empty the payment tables** first: pointing one at a real site's database would
391
+ delete its payments. The webhook suite additionally needs `SETTINGS_ENCRYPTION_KEY` (any 64 hex
392
+ characters will do) to store the signing secret it verifies against.
393
+
394
+ No Stripe account is needed for any test: every provider response in the suite is a literal, and
395
+ webhook events are signed with Stripe's own test-signature helper, because a suite that needs an
396
+ account is a suite nobody runs.
397
+
398
+ ## License
399
+
400
+ MIT