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
@@ -0,0 +1,67 @@
1
+ /*
2
+ Formatting money in the browser, served at /payments/utils/money.js.
3
+
4
+ The same minor-unit rules as server/utils/money/currencies.js, kept as a separate small copy on
5
+ purpose: the server module imports kempo's server SDK, and a browser cannot load that. A static
6
+ test checks the two lists of zero- and three-decimal currencies still agree, because the day they
7
+ drift is the day a screen shows ¥1,200 as ¥12.00.
8
+ */
9
+
10
+ export const ZERO_DECIMAL = new Set([
11
+ 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
12
+ 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
13
+ ]);
14
+
15
+ export const THREE_DECIMAL = new Set(['bhd', 'jod', 'kwd', 'omr', 'tnd']);
16
+
17
+ export const decimalsFor = currency => {
18
+ const code = String(currency || '').toLowerCase();
19
+ if(ZERO_DECIMAL.has(code)) return 0;
20
+ if(THREE_DECIMAL.has(code)) return 3;
21
+ return 2;
22
+ };
23
+
24
+ export const fromMinor = (minor, currency) => Number(minor || 0) / 10 ** decimalsFor(currency);
25
+
26
+ export const toMinor = (major, currency) => {
27
+ const amount = Number(major);
28
+ if(!Number.isFinite(amount)) return null;
29
+ return Math.round(amount * 10 ** decimalsFor(currency));
30
+ };
31
+
32
+ export const formatAmount = (minor, currency) => {
33
+ const code = String(currency || 'usd').toLowerCase();
34
+ try {
35
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: code.toUpperCase() }).format(fromMinor(minor, code));
36
+ } catch {
37
+ return `${fromMinor(minor, code).toFixed(decimalsFor(code))} ${code.toUpperCase()}`;
38
+ }
39
+ };
40
+
41
+ /*
42
+ Statuses read as machine names — `requires_payment_method` — and a customer or a shop owner
43
+ should never see one. These are what a screen shows instead.
44
+ */
45
+ export const STATUS_LABELS = {
46
+ requires_payment_method: 'Awaiting payment',
47
+ requires_confirmation: 'Awaiting confirmation',
48
+ requires_action: 'Awaiting the customer’s bank',
49
+ processing: 'Processing',
50
+ requires_capture: 'Authorised, not captured',
51
+ succeeded: 'Paid',
52
+ canceled: 'Cancelled',
53
+ failed: 'Failed',
54
+ };
55
+
56
+ export const statusLabel = status => STATUS_LABELS[status] || status;
57
+
58
+ /*
59
+ Which colour a status badge gets. Only `succeeded` is good news, only `failed` is bad; everything
60
+ else is in progress and should not be coloured as though somebody needs to do something about it.
61
+ */
62
+ export const statusTone = status => {
63
+ if(status === 'succeeded') return 'success';
64
+ if(status === 'failed' || status === 'canceled') return 'danger';
65
+ if(status === 'requires_capture') return 'warning';
66
+ return 'muted';
67
+ };
package/sdk.js ADDED
@@ -0,0 +1,42 @@
1
+ /*
2
+ Server-side entry point — what another extension imports to take money.
3
+
4
+ This is the interface the board item calls for: a consumer (kempo-commerce's checkout today, a
5
+ subscriptions extension later) creates, captures and refunds payments through these functions and
6
+ never learns which processor is configured. Swapping Stripe for something else is a settings
7
+ change, not a change to anything that called in here.
8
+
9
+ ```js
10
+ import { createPayment } from 'kempo-payments/sdk';
11
+
12
+ const [error, { payment, clientSecret }] = await createPayment({
13
+ amount: 4999, // always minor units — 4999 is $49.99
14
+ currency: 'usd',
15
+ owner: 'kempo-commerce', // who is asking
16
+ reference: order.id, // their id for what is being paid for
17
+ userId: user?.id, // null for a guest
18
+ customerEmail: order.email,
19
+ description: `Order ${order.number}`,
20
+ });
21
+ ```
22
+
23
+ Then hand `clientSecret` to the browser and mount `<k-pay-form>` with it. Do not wait for the
24
+ browser to report back — listen for the `payment:succeeded` hook, which is fired from the
25
+ processor's webhook and arrives whether or not that browser is still open.
26
+
27
+ As in kempo's other extensions these are the *data* operations, with no permission checks of
28
+ their own. The routes under public/api enforce who may do what; anything calling in here is
29
+ server-side code that has already decided it is allowed.
30
+ */
31
+
32
+ export { createPayment } from './server/utils/payments/create.js';
33
+ export { capturePayment, cancelPayment, refundPayment, getClientSecret } from './server/utils/payments/actions.js';
34
+ export { getPayment, getPaymentByRef, paymentsForReference, listPayments, listRefunds, summary } from './server/utils/payments/store.js';
35
+ export { syncPayment, EVENTS } from './server/utils/payments/sync.js';
36
+ export { receiveWebhook, listEvents } from './server/utils/webhooks/receive.js';
37
+
38
+ export { STATUSES, FINAL, isStatus, isFinal, isAwaitingCustomer, canCapture, canCancel, refundableAmount } from './server/utils/payments/statuses.js';
39
+ export { decimalsFor, formatAmount, fromMinor, isValidAmount, isValidCurrency, normaliseCurrency, toMinor } from './server/utils/money/currencies.js';
40
+
41
+ export { readConfig, CAPTURE_METHODS, DEFAULTS, OWNER } from './server/utils/config/settings.js';
42
+ export { activeProvider, getProvider, listProviders, providerForPayment } from './server/providers/index.js';
@@ -0,0 +1,141 @@
1
+ import { pgTable, text, boolean, integer, jsonb, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core';
2
+
3
+ /*
4
+ Three tables, and none of them holds a card number.
5
+
6
+ Nothing sensitive ever reaches this server: the customer's details go straight from their browser
7
+ to the processor, and what comes back is a reference. Everything below is kempo's own record of
8
+ what that reference was for — who it belonged to, what it was meant to pay for, and what has
9
+ happened to it since. Rebuilding any of it means asking the processor, which is exactly the
10
+ position to be in when the processor is the one holding the money.
11
+
12
+ Amounts are integers in the currency's minor unit — cents, pence, yen. Never floats. `1.15` is
13
+ not representable in binary floating point, and a rounding error in a total is money that either
14
+ the customer or the seller is short.
15
+ */
16
+
17
+ /*
18
+ One row per attempt to take money, created the moment a charge is started and updated for the
19
+ rest of its life. It is the row a consumer extension (checkout, a subscription, a top-up) points
20
+ its own order at via `reference`.
21
+
22
+ `providerRef` is the processor's own id for the same thing — Stripe's PaymentIntent. That is the
23
+ join back to the money, and it is what a webhook arrives carrying, so it is indexed.
24
+
25
+ `status` is a normalised vocabulary shared by every provider, not Stripe's. A second processor
26
+ maps its own states onto these rather than the rest of kempo learning a new set of words. See
27
+ server/utils/payments/statuses.js for what each one means and which of them are final.
28
+ */
29
+ export const kempoPayment = pgTable('kempoPayment', {
30
+ id: text('id').primaryKey(),
31
+
32
+ provider: text('provider').notNull(),
33
+ providerRef: text('providerRef').notNull(),
34
+ livemode: boolean('livemode').notNull().default(false),
35
+
36
+ status: text('status').notNull(),
37
+ captureMethod: text('captureMethod').notNull().default('automatic'),
38
+
39
+ amount: integer('amount').notNull(),
40
+ amountCaptured: integer('amountCaptured').notNull().default(0),
41
+ amountRefunded: integer('amountRefunded').notNull().default(0),
42
+ currency: text('currency').notNull(),
43
+
44
+ /*
45
+ Who asked for the payment and what it was for, in their terms. `owner` is the extension that
46
+ called `createPayment` and `reference` is its own id for the thing being paid for — an order
47
+ number, a subscription id. Together they are how a consumer finds its payment again without
48
+ keeping a second table of its own mapping ids to ids.
49
+ */
50
+ owner: text('owner'),
51
+ reference: text('reference'),
52
+ description: text('description'),
53
+ metadata: jsonb('metadata'),
54
+
55
+ /*
56
+ Nullable on purpose. A guest checkout is a real payment by a person with no account, and
57
+ refusing to record it would mean either inventing an account or not taking the money.
58
+ */
59
+ userId: text('userId'),
60
+ customerEmail: text('customerEmail'),
61
+
62
+ /*
63
+ The last thing the processor said went wrong, kept so a declined card can be explained to
64
+ somebody looking at the payment later. Cleared on the next successful transition rather than
65
+ accumulating — a payment that eventually succeeded is not a failed one.
66
+ */
67
+ lastError: text('lastError'),
68
+
69
+ disputed: boolean('disputed').notNull().default(false),
70
+
71
+ createdAt: timestamp('createdAt').notNull(),
72
+ updatedAt: timestamp('updatedAt').notNull(),
73
+ }, table => ({
74
+ providerRefIndex: uniqueIndex('kempoPayment_providerRef_idx').on(table.provider, table.providerRef),
75
+ referenceIndex: index('kempoPayment_reference_idx').on(table.owner, table.reference),
76
+ }));
77
+
78
+ /*
79
+ One row per refund, because a payment can have several and "amountRefunded" alone cannot say who
80
+ issued them, when, or why.
81
+
82
+ Kept even when the refund fails: a refund that was attempted and rejected is something the person
83
+ who attempted it needs to see, and a missing row reads as though nobody ever tried.
84
+ */
85
+ export const kempoPaymentRefund = pgTable('kempoPaymentRefund', {
86
+ id: text('id').primaryKey(),
87
+ paymentId: text('paymentId').notNull(),
88
+
89
+ provider: text('provider').notNull(),
90
+ providerRef: text('providerRef').notNull(),
91
+
92
+ amount: integer('amount').notNull(),
93
+ currency: text('currency').notNull(),
94
+ status: text('status').notNull(),
95
+ reason: text('reason'),
96
+
97
+ // The kempo user who pressed the button, where there was one. Null for a refund the processor
98
+ // originated itself — a dispute lost, a card network reversal.
99
+ issuedBy: text('issuedBy'),
100
+
101
+ createdAt: timestamp('createdAt').notNull(),
102
+ updatedAt: timestamp('updatedAt').notNull(),
103
+ }, table => ({
104
+ paymentIndex: index('kempoPaymentRefund_payment_idx').on(table.paymentId),
105
+ }));
106
+
107
+ /*
108
+ Every webhook this extension has already acted on.
109
+
110
+ Processors retry a webhook until they get a 2xx, and they are explicitly allowed to deliver the
111
+ same event more than once even after one. Without this table a retried `payment_intent.succeeded`
112
+ fires `payment:succeeded` a second time, and whatever is listening — an order confirmation email,
113
+ a licence key, a shipment — happens twice. Claiming a row here is what makes the handler
114
+ idempotent, and it is the whole reason the table exists.
115
+
116
+ The primary key is `provider:eventId` rather than a generated one, deliberately. kempo's
117
+ installer creates columns and primary keys and nothing else — a `unique()` or a unique index in a
118
+ Drizzle schema is not carried across (see install.js, which adds this extension's indexes by
119
+ hand). Hanging deduplication off the one constraint the installer is guaranteed to create means
120
+ it cannot quietly stop working on a site whose indexes did not get made.
121
+
122
+ `payload` is kept because a webhook is the only account of what the processor believed at that
123
+ moment, and it is the first thing anybody asks for when a payment and an order disagree.
124
+ */
125
+ export const kempoPaymentEvent = pgTable('kempoPaymentEvent', {
126
+ id: text('id').primaryKey(),
127
+
128
+ provider: text('provider').notNull(),
129
+ providerEventId: text('providerEventId').notNull(),
130
+ type: text('type').notNull(),
131
+
132
+ paymentId: text('paymentId'),
133
+ payload: jsonb('payload'),
134
+
135
+ // Null until it has been processed; set even for an event that was recognised and deliberately
136
+ // ignored, so "seen but not actionable" is distinguishable from "still queued".
137
+ handledAt: timestamp('handledAt'),
138
+ error: text('error'),
139
+
140
+ createdAt: timestamp('createdAt').notNull(),
141
+ });
@@ -0,0 +1,84 @@
1
+ import * as stripe from './stripe.js';
2
+ import { getSetting } from 'kempo/server/sdk.js';
3
+ import { DEFAULTS } from '../utils/config/settings.js';
4
+
5
+ /*
6
+ The registry, and the contract a processor module implements.
7
+
8
+ Only Stripe ships today. The abstraction is here anyway, and from the first commit rather than
9
+ the day a second processor is wanted, because the alternative is that every call site grows a
10
+ direct dependency on one company's SDK and adding PayPal becomes a change to the whole extension
11
+ instead of a file in this directory.
12
+
13
+ A provider module exports:
14
+
15
+ | Export | Shape |
16
+ |--------------------|--------------------------------------------------------------------------|
17
+ | `name` | the id stored on every payment row, and the last URL segment of its webhook |
18
+ | `label` | what a human is shown |
19
+ | `webhookPath` | the site path its events should be sent to |
20
+ | `webhookEvents` | the events that endpoint needs subscribed, for the settings screen to list |
21
+ | `signatureHeader` | the request header its webhook signature arrives in |
22
+ | `credentialFields` | the settings holding its credentials, in the order a screen shows them |
23
+ | `status()` | `[err, { configured, mode, livemode, … }]` — is it usable, and in which mode |
24
+ | `clientConfig()` | `[err, { publishableKey }]` — the part of the config a browser may hold |
25
+ | `createIntent()` | `[err, { ref, status, clientSecret, … }]` |
26
+ | `retrieveIntent()` | `[err, { ref, status, … }]` |
27
+ | `capture()` | `[err, { ref, status, … }]` |
28
+ | `cancel()` | `[err, { ref, status, … }]` |
29
+ | `refund()` | `[err, { ref, amount, status, … }]` |
30
+ | `listRefunds()` | optional: `[err, [{ ref, amount, status, … }]]` — every refund on a payment |
31
+ | `verifyEvent()` | `[err, event]` — reject anything not signed by the provider |
32
+ | `interpretEvent()` | `{ kind: 'payment' \| 'refund' \| 'dispute' \| 'ignored', … }` |
33
+
34
+ Statuses coming out of any of these are kempo's, not the provider's — see
35
+ server/utils/payments/statuses.js. Mapping is the provider module's job precisely so that the
36
+ rest of the extension has one vocabulary rather than one per processor.
37
+
38
+ Roadmap processors — PayPal, Square, Mollie, Amazon Pay — each become one more file here. Wallet
39
+ and buy-now-pay-later methods (Apple Pay, Google Pay, Klarna, Afterpay, Affirm) are deliberately
40
+ *not* providers: Stripe's payment form offers them already, so modelling them separately would
41
+ duplicate an integration that is already done.
42
+ */
43
+
44
+ const PROVIDERS = new Map([[stripe.name, stripe]]);
45
+
46
+ export const listProviders = () => [...PROVIDERS.values()].map(provider => ({
47
+ name: provider.name,
48
+ label: provider.label,
49
+ webhookPath: provider.webhookPath,
50
+ webhookEvents: provider.webhookEvents || [],
51
+ credentialFields: provider.credentialFields,
52
+ }));
53
+
54
+ /*
55
+ Which request header a provider's webhook signature arrives in. The webhook route asks rather
56
+ than knowing, so a second processor's header comes with its module instead of being one more
57
+ fallback in an ever-growing `||` chain.
58
+ */
59
+ export const signatureHeaderFor = name => PROVIDERS.get(name)?.signatureHeader || null;
60
+
61
+ export const getProvider = name => {
62
+ const provider = PROVIDERS.get(name);
63
+ if(!provider) return [{ code: 400, msg: `No payment provider named "${name}" is installed` }, null];
64
+ return [null, provider];
65
+ };
66
+
67
+ /*
68
+ The provider a payment should be taken through right now.
69
+
70
+ Read fresh from settings on every call rather than cached, because the alternative is that
71
+ switching processors appears to work and then keeps sending money to the old one until somebody
72
+ restarts the server.
73
+ */
74
+ export const activeProvider = async () => {
75
+ const [error, configured] = await getSetting('kempo-payments', 'provider', DEFAULTS.provider);
76
+ return getProvider(error ? DEFAULTS.provider : String(configured || DEFAULTS.provider));
77
+ };
78
+
79
+ /*
80
+ The provider a payment already went through, which is not necessarily the active one. Refunding a
81
+ Stripe charge after the site has switched to PayPal has to go back to Stripe — the money is
82
+ there, and asking the new processor to return it is meaningless.
83
+ */
84
+ export const providerForPayment = payment => getProvider(payment?.provider);