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.
- package/README.md +400 -0
- package/admin/components/PaymentDetail.js +304 -0
- package/admin/components/PaymentList.js +261 -0
- package/admin/components/ProviderSettings.js +310 -0
- package/admin/components/TestPayment.js +283 -0
- package/admin/index.page.html +81 -0
- package/admin/nav.global.html +3 -0
- package/install.js +48 -0
- package/kempo-config.json +73 -0
- package/package.json +56 -0
- package/public/api/config/GET.js +48 -0
- package/public/api/payments/GET.js +31 -0
- package/public/api/payments/POST.js +41 -0
- package/public/api/payments/[id]/GET.js +30 -0
- package/public/api/payments/[id]/cancel/POST.js +19 -0
- package/public/api/payments/[id]/capture/POST.js +22 -0
- package/public/api/payments/[id]/client-secret/GET.js +23 -0
- package/public/api/payments/[id]/refund/POST.js +28 -0
- package/public/api/payments/[id]/sync/POST.js +23 -0
- package/public/api/providers/GET.js +16 -0
- package/public/api/settings/GET.js +36 -0
- package/public/api/settings/PUT.js +123 -0
- package/public/api/summary/GET.js +18 -0
- package/public/api/webhooks/[provider]/POST.js +37 -0
- package/public/components/PaymentForm.js +522 -0
- package/public/sdk.js +72 -0
- package/public/utils/money.js +67 -0
- package/sdk.js +42 -0
- package/server/db/schema.js +141 -0
- package/server/providers/index.js +84 -0
- package/server/providers/stripe.js +435 -0
- package/server/utils/config/declared.js +25 -0
- package/server/utils/config/settings.js +72 -0
- package/server/utils/money/currencies.js +67 -0
- package/server/utils/payments/actions.js +149 -0
- package/server/utils/payments/create.js +116 -0
- package/server/utils/payments/statuses.js +55 -0
- package/server/utils/payments/store.js +250 -0
- package/server/utils/payments/sync.js +179 -0
- package/server/utils/payments/text.js +38 -0
- package/server/utils/permissions/gate.js +42 -0
- package/server/utils/webhooks/receive.js +153 -0
- package/uninstall.js +35 -0
- 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);
|