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,37 @@
|
|
|
1
|
+
import { receiveWebhook } from '../../../../server/utils/webhooks/receive.js';
|
|
2
|
+
import { signatureHeaderFor } from '../../../../server/providers/index.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Where the processor reports what happened. The one route in this extension with no session behind
|
|
6
|
+
it — the caller is Stripe, and it authenticates by signing the request rather than by logging in.
|
|
7
|
+
|
|
8
|
+
Two details that are the whole reason this file is not three lines:
|
|
9
|
+
|
|
10
|
+
**The raw body.** The signature covers the exact bytes that were sent, so verification has to run
|
|
11
|
+
against them. `request.body` has already been through JSON.parse by the time this handler is
|
|
12
|
+
called, and re-serialising it produces the same data with different whitespace and key order —
|
|
13
|
+
which fails the check every time, for a payload that was never tampered with. `request.text()`
|
|
14
|
+
returns what actually arrived.
|
|
15
|
+
|
|
16
|
+
**What counts as a failure.** Only a request this endpoint could not honestly answer — a bad
|
|
17
|
+
signature, a database that would not write. Anything else, including events about payments this
|
|
18
|
+
site has never heard of, is a 200: a non-2xx tells the processor to redeliver, and redelivering
|
|
19
|
+
an event nothing will ever act on just means the same failure again tomorrow, in a dashboard that
|
|
20
|
+
eventually disables the endpoint for being broken.
|
|
21
|
+
*/
|
|
22
|
+
export default async (request, response) => {
|
|
23
|
+
const provider = request.params?.provider;
|
|
24
|
+
const header = signatureHeaderFor(provider);
|
|
25
|
+
|
|
26
|
+
if(!header) return response.status(404).json({ error: 'No such payment provider' });
|
|
27
|
+
|
|
28
|
+
const [error, result] = await receiveWebhook({
|
|
29
|
+
provider,
|
|
30
|
+
payload: await request.text(),
|
|
31
|
+
signature: request.headers[header] || null,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
35
|
+
|
|
36
|
+
response.json({ received: true, ...result });
|
|
37
|
+
};
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import LightComponent from '/kempo-ui/components/LightComponent.js';
|
|
2
|
+
import '/kempo-ui/components/Spinner.js';
|
|
3
|
+
import { html } from '/kempo-ui/lit-all.min.js';
|
|
4
|
+
import { getConfig } from '/payments/sdk.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
The payment form a customer actually types into, served at /payments/components/PaymentForm.js.
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<script type="module" src="/payments/components/PaymentForm.js"></script>
|
|
11
|
+
<k-pay-form client-secret="pi_3..._secret_..." return-url="https://example.com/thanks"></k-pay-form>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Card details never touch this server. The number/expiry/CVC fields are three small iframes
|
|
15
|
+
belonging to the processor, and confirming sends them from the customer's browser straight to
|
|
16
|
+
it — which is what keeps a kempo site out of PCI scope, and is not an implementation detail
|
|
17
|
+
anybody should optimise away. There is no way to collect a card number in a plain kempo-ui
|
|
18
|
+
`<input>` without taking on that scope yourself; this is as close to "our own component" as a
|
|
19
|
+
card field gets. See CONTRIBUTING.md's note on this if the question comes up again.
|
|
20
|
+
|
|
21
|
+
Two kinds of field, deliberately not Stripe's all-in-one "Payment Element":
|
|
22
|
+
|
|
23
|
+
- **Card fields** (`cardNumber` / `cardExpiry` / `cardCvc`) are three separate split fields
|
|
24
|
+
rather than one combined widget, so this markup owns the labels and layout and only the digits
|
|
25
|
+
themselves live in Stripe's iframe. Confirmed with `stripe.confirmCardPayment()`, the API this
|
|
26
|
+
split-field shape has always used; it takes the card element directly rather than going through
|
|
27
|
+
an `elements` group.
|
|
28
|
+
|
|
29
|
+
These are the *legacy* Stripe elements, and neither half of the modern `appearance` API reaches
|
|
30
|
+
them — confirmed by testing both, not assumed. Their iframe is **transparent and unpadded by
|
|
31
|
+
design**: setting a background directly on the wrapper `<div>` made a field visible for the
|
|
32
|
+
first time; `appearance.rules['.Input']` changes nothing. So `.k-pay-field`, injected once by
|
|
33
|
+
this module, *is* the input box — kempo-css's own real `input` rule, copied property-for-
|
|
34
|
+
property onto a class, because there is no such utility class already and no other way to make
|
|
35
|
+
a wrapper around a cross-origin iframe look like a kempo-css input. See `injectFieldStyles`.
|
|
36
|
+
|
|
37
|
+
The *text* colour has the same problem a layer deeper: `appearance.variables.colorText` also
|
|
38
|
+
does not reach these elements — proved by hardcoding it to an unmistakable red and watching
|
|
39
|
+
nothing change. These three element types predate the `appearance` API entirely and have always
|
|
40
|
+
taken their own `style` option instead (`style.base.color`, `style.base['::placeholder']`) —
|
|
41
|
+
that is the one mechanism confirmed, by testing with real typed digits and pixel-sampling the
|
|
42
|
+
result, to actually work. See `buildFieldStyle`.
|
|
43
|
+
- **Express Checkout** (Apple Pay / Google Pay / Link, and PayPal, Klarna, Cash App Pay or Amazon
|
|
44
|
+
Pay if activated in the Stripe dashboard) is a single button row Stripe draws and manages
|
|
45
|
+
entirely — tapping a wallet button opens the *native* OS payment sheet, not an iframe, which is
|
|
46
|
+
why it is not reskinned here; tapping Klarna or a similar method hands the customer to that
|
|
47
|
+
provider's own redirect for a credit check nothing here can shortcut, same as it always did
|
|
48
|
+
through Stripe's unified Payment Element. Hidden automatically when nothing is available;
|
|
49
|
+
confirmed with `stripe.confirmPayment()`. This is a newer, unified element type, and unlike the
|
|
50
|
+
split card fields above, it *does* take its styling from `appearance` normally.
|
|
51
|
+
|
|
52
|
+
**This element must not be placed inside a shadow root**, and it renders into the light DOM
|
|
53
|
+
itself for the same reason. Stripe's card fields do not mount inside one: they reserve the height
|
|
54
|
+
and then render nothing at all, with no error, in the browser or anywhere else. Measured while
|
|
55
|
+
building the previous version of this component — in the light DOM a Stripe field brings up a
|
|
56
|
+
real iframe; inside a shadow root, a stub with nothing in it.
|
|
57
|
+
|
|
58
|
+
So a page or component hosting `<k-pay-form>` has to be light DOM all the way up. That is why
|
|
59
|
+
this extension's own test screen is the one LightComponent among its admin components, and it is
|
|
60
|
+
the first thing to check if a checkout somewhere shows an empty box where a card field should be.
|
|
61
|
+
|
|
62
|
+
What it emits, and nothing it emits should be trusted as proof of payment:
|
|
63
|
+
|
|
64
|
+
| Event | When |
|
|
65
|
+
|----------------------|------------------------------------------------------------|
|
|
66
|
+
| `payment-succeeded` | the processor confirmed it in this browser |
|
|
67
|
+
| `payment-processing` | accepted, still settling — common for bank debits |
|
|
68
|
+
| `payment-failed` | declined, or the customer's details were rejected |
|
|
69
|
+
|
|
70
|
+
Use them to move the customer along — a thank-you page, a spinner. Do *not* use them to fulfil an
|
|
71
|
+
order. A browser closed at the wrong moment never fires anything, and one under someone's control
|
|
72
|
+
can fire whatever it likes. The `payment:succeeded` server hook is what fulfilment hangs off.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
let stripeJs = null;
|
|
76
|
+
|
|
77
|
+
const loadStripeJs = () => {
|
|
78
|
+
if(window.Stripe) return Promise.resolve(window.Stripe);
|
|
79
|
+
|
|
80
|
+
if(!stripeJs){
|
|
81
|
+
stripeJs = new Promise((resolve, reject) => {
|
|
82
|
+
const script = document.createElement('script');
|
|
83
|
+
script.src = 'https://js.stripe.com/v3/';
|
|
84
|
+
script.async = true;
|
|
85
|
+
script.onload = () => resolve(window.Stripe);
|
|
86
|
+
/*
|
|
87
|
+
Loaded from the processor rather than bundled, and it has to be: Stripe requires it so the
|
|
88
|
+
fraud signals and 3-D Secure handling can be updated without every site that takes payments
|
|
89
|
+
having to redeploy. A self-hosted copy is not supported and stops working.
|
|
90
|
+
*/
|
|
91
|
+
script.onerror = () => reject(new Error('The payment library could not be loaded'));
|
|
92
|
+
document.head.appendChild(script);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return stripeJs;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
The box every card field sits in — real CSS, in this document, because it never has to cross
|
|
101
|
+
into Stripe's iframe: kempo-css's own rule for a real `<input>` (background, border, radius,
|
|
102
|
+
padding, and the same focus ring, standing in for `:focus` with `:focus-within` since focus
|
|
103
|
+
itself lives one document down, inside the iframe), copied onto a class name instead of an
|
|
104
|
+
element selector. Injected once into `document.head` — a light DOM component has no shadow root
|
|
105
|
+
to scope a `static styles` block into, and this only needs to exist globally once regardless of
|
|
106
|
+
how many `<k-pay-form>` elements are on the page.
|
|
107
|
+
*/
|
|
108
|
+
let fieldStylesInjected = false;
|
|
109
|
+
|
|
110
|
+
const injectFieldStyles = () => {
|
|
111
|
+
if(fieldStylesInjected) return;
|
|
112
|
+
fieldStylesInjected = true;
|
|
113
|
+
|
|
114
|
+
const style = document.createElement('style');
|
|
115
|
+
style.textContent = `
|
|
116
|
+
.k-pay-field {
|
|
117
|
+
display: block;
|
|
118
|
+
width: 100%;
|
|
119
|
+
background-color: var(--input_bg);
|
|
120
|
+
border: var(--input_border_width) solid var(--c_input_border);
|
|
121
|
+
border-radius: var(--radius);
|
|
122
|
+
padding: var(--input_padding);
|
|
123
|
+
transition: box-shadow var(--animation_ms);
|
|
124
|
+
}
|
|
125
|
+
.k-pay-field:focus-within {
|
|
126
|
+
box-shadow: var(--focus_shadow);
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
document.head.appendChild(style);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
What Stripe *does* draw for a split card field is the text — the digits themselves, and their
|
|
134
|
+
placeholder — and that still has to cross into the iframe as an `appearance` value, with the same
|
|
135
|
+
two problems as anything else that crosses that boundary:
|
|
136
|
+
|
|
137
|
+
- kempo-css declares its text colours with `light-dark(…)`, and the *declared* value of a custom
|
|
138
|
+
property is that function's literal text — reading one off `document.documentElement` hands
|
|
139
|
+
Stripe a string it cannot parse, not a colour. A throwaway element that actually *uses* the
|
|
140
|
+
token in a real CSS property forces the browser to resolve it into the value it would paint
|
|
141
|
+
with, which is why every colour below goes through a probe rather than a direct property read.
|
|
142
|
+
- `--tc` and `--tc_muted` carry an alpha channel — `rgba(255, 255, 255, 0.93)`, not a fully
|
|
143
|
+
opaque colour — and Stripe's colour variables reject that outright: `stripe.elements(): invalid
|
|
144
|
+
variable value "rgba(255, 255, 255, 0.93)" provided to "colorText"; "colorText" accepts a valid
|
|
145
|
+
HEX, rgb(), or hsl() CSS color.` Not a theory — that is Stripe's own console warning, seen live
|
|
146
|
+
once this was wired up. `compositeColor` is what fixes both problems in one step: it paints the
|
|
147
|
+
value onto a real canvas pixel *over the background it will actually sit on* and reads the
|
|
148
|
+
bytes back, which leaves a plain opaque `rgb(...)` regardless of what colour function or alpha
|
|
149
|
+
the source value used — the same value the browser would have painted, with nothing left for
|
|
150
|
+
Stripe's parser to reject.
|
|
151
|
+
*/
|
|
152
|
+
let compositeContext = null;
|
|
153
|
+
|
|
154
|
+
const compositeColor = (foreground, background) => {
|
|
155
|
+
if(!compositeContext){
|
|
156
|
+
const canvas = document.createElement('canvas');
|
|
157
|
+
canvas.width = canvas.height = 1;
|
|
158
|
+
compositeContext = canvas.getContext('2d', { willReadFrequently: true });
|
|
159
|
+
}
|
|
160
|
+
compositeContext.fillStyle = background;
|
|
161
|
+
compositeContext.fillRect(0, 0, 1, 1);
|
|
162
|
+
compositeContext.fillStyle = foreground;
|
|
163
|
+
compositeContext.fillRect(0, 0, 1, 1);
|
|
164
|
+
const [r, g, b] = compositeContext.getImageData(0, 0, 1, 1).data;
|
|
165
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const readTokens = () => {
|
|
169
|
+
// The background text will actually sit on is `--input_bg`, not the page's — needed as the
|
|
170
|
+
// compositing base below, never sent to Stripe itself now that the field's own box is real CSS.
|
|
171
|
+
const probe = document.createElement('div');
|
|
172
|
+
probe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;background-color:var(--input_bg);outline-color:var(--tc_muted);';
|
|
173
|
+
document.body.appendChild(probe);
|
|
174
|
+
const probeStyles = getComputedStyle(probe);
|
|
175
|
+
const inputBackground = compositeColor(probeStyles.backgroundColor, 'black');
|
|
176
|
+
const rawMutedText = probeStyles.outlineColor;
|
|
177
|
+
probe.remove();
|
|
178
|
+
|
|
179
|
+
const pageProbe = document.createElement('div');
|
|
180
|
+
pageProbe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;background-color:var(--c_bg);color:var(--tc);';
|
|
181
|
+
document.body.appendChild(pageProbe);
|
|
182
|
+
const pageStyles = getComputedStyle(pageProbe);
|
|
183
|
+
const pageBackground = compositeColor(pageStyles.backgroundColor, 'black');
|
|
184
|
+
const text = compositeColor(pageStyles.color, inputBackground);
|
|
185
|
+
const mutedText = compositeColor(rawMutedText, inputBackground);
|
|
186
|
+
pageProbe.remove();
|
|
187
|
+
|
|
188
|
+
// These, unlike the ones above, are plain opaque literals in kempo-css (a fixed rgb(), a fixed
|
|
189
|
+
// rem) — no light-dark, no alpha, no nested var() — so reading them directly off the root is
|
|
190
|
+
// already the resolved value. Still composited: nothing stops a future kempo-css release from
|
|
191
|
+
// switching one of these to oklch or adding alpha, and the failure mode is exactly this quiet.
|
|
192
|
+
const root = getComputedStyle(document.documentElement);
|
|
193
|
+
const primary = root.getPropertyValue('--c_primary').trim();
|
|
194
|
+
const danger = root.getPropertyValue('--c_danger').trim();
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
pageBackground,
|
|
198
|
+
text,
|
|
199
|
+
mutedText,
|
|
200
|
+
primary: primary ? compositeColor(primary, pageBackground) : undefined,
|
|
201
|
+
danger: danger ? compositeColor(danger, pageBackground) : undefined,
|
|
202
|
+
radius: root.getPropertyValue('--radius').trim() || undefined,
|
|
203
|
+
fontFamily: getComputedStyle(document.body).fontFamily || undefined,
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Perceived brightness, to pick which of the processor's two base themes to build on. A dark base
|
|
208
|
+
// with light overrides looks far better than a light one with the colours swapped out from under it.
|
|
209
|
+
const isDark = colour => {
|
|
210
|
+
const [r = 255, g = 255, b = 255] = (String(colour).match(/[\d.]+/g) || []).map(Number);
|
|
211
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const buildAppearance = tokens => ({
|
|
215
|
+
theme: isDark(tokens.pageBackground) ? 'night' : 'stripe',
|
|
216
|
+
variables: {
|
|
217
|
+
colorBackground: tokens.pageBackground,
|
|
218
|
+
colorText: tokens.text,
|
|
219
|
+
...(tokens.mutedText ? { colorTextPlaceholder: tokens.mutedText } : {}),
|
|
220
|
+
...(tokens.primary ? { colorPrimary: tokens.primary } : {}),
|
|
221
|
+
...(tokens.danger ? { colorDanger: tokens.danger } : {}),
|
|
222
|
+
...(tokens.radius ? { borderRadius: tokens.radius } : {}),
|
|
223
|
+
...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/*
|
|
228
|
+
The split card fields' *own* styling option — `appearance` (above) is confirmed, by testing, not
|
|
229
|
+
to reach them at all: neither `variables.colorText` nor `rules['.Input']` change anything about
|
|
230
|
+
what they render, silently, the same silent-no-effect failure twice over. `cardNumber` /
|
|
231
|
+
`cardExpiry` / `cardCvc` predate the `appearance` API and have always taken their own `style`
|
|
232
|
+
option instead — `base` for the normal state, `invalid` for one Stripe itself has flagged, each a
|
|
233
|
+
CSS-in-JS-shaped object rather than the appearance API's flat variables. This is the one that
|
|
234
|
+
actually paints the typed digits and the placeholder a colour anyone can read.
|
|
235
|
+
*/
|
|
236
|
+
const buildFieldStyle = tokens => ({
|
|
237
|
+
base: {
|
|
238
|
+
color: tokens.text,
|
|
239
|
+
fontFamily: tokens.fontFamily || undefined,
|
|
240
|
+
'::placeholder': { color: tokens.mutedText },
|
|
241
|
+
},
|
|
242
|
+
...(tokens.danger ? { invalid: { color: tokens.danger, iconColor: tokens.danger } } : {}),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
export default class PaymentForm extends LightComponent {
|
|
246
|
+
static properties = {
|
|
247
|
+
clientSecret: { type: String, attribute: 'client-secret' },
|
|
248
|
+
publishableKey: { type: String, attribute: 'publishable-key' },
|
|
249
|
+
returnUrl: { type: String, attribute: 'return-url' },
|
|
250
|
+
submitLabel: { type: String, attribute: 'submit-label' },
|
|
251
|
+
phase: { type: String, state: true },
|
|
252
|
+
message: { type: String, state: true },
|
|
253
|
+
fieldError: { type: String, state: true },
|
|
254
|
+
expressAvailable: { type: Boolean, state: true },
|
|
255
|
+
postalCode: { type: String, state: true },
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
#stripe = null;
|
|
259
|
+
#elements = null;
|
|
260
|
+
#mountedFor = null;
|
|
261
|
+
#themeWatchers = [];
|
|
262
|
+
#fields = {};
|
|
263
|
+
#fieldErrors = { number: '', expiry: '', cvc: '' };
|
|
264
|
+
|
|
265
|
+
constructor(){
|
|
266
|
+
super();
|
|
267
|
+
this.clientSecret = '';
|
|
268
|
+
this.publishableKey = '';
|
|
269
|
+
/*
|
|
270
|
+
Payment methods that leave the site to authenticate — a bank's 3-D Secure page — have to be
|
|
271
|
+
told where to come back to, and the processor refuses to start one without it. Defaulting to
|
|
272
|
+
this page means that works without every caller remembering to set it, and a caller with a
|
|
273
|
+
real thank-you page still overrides it.
|
|
274
|
+
*/
|
|
275
|
+
this.returnUrl = window.location.href;
|
|
276
|
+
this.submitLabel = 'Pay';
|
|
277
|
+
this.phase = 'idle';
|
|
278
|
+
this.message = '';
|
|
279
|
+
this.fieldError = '';
|
|
280
|
+
this.expressAvailable = false;
|
|
281
|
+
this.postalCode = '';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/*
|
|
285
|
+
Lifecycle
|
|
286
|
+
*/
|
|
287
|
+
|
|
288
|
+
connectedCallback(){
|
|
289
|
+
super.connectedCallback();
|
|
290
|
+
injectFieldStyles();
|
|
291
|
+
this.#watchTheme();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
updated(){
|
|
295
|
+
super.updated();
|
|
296
|
+
this.#mount();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
disconnectedCallback(){
|
|
300
|
+
super.disconnectedCallback();
|
|
301
|
+
for(const stop of this.#themeWatchers) stop();
|
|
302
|
+
this.#themeWatchers = [];
|
|
303
|
+
this.#elements = null;
|
|
304
|
+
this.#fields = {};
|
|
305
|
+
this.#mountedFor = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
Restyles the fields in place when the page's theme changes. `elements.update` is the only way in
|
|
310
|
+
— the fields are in the processor's iframes, and re-mounting to restyle would throw away
|
|
311
|
+
whatever the customer had already typed. One call restyles every field mounted under this
|
|
312
|
+
element's `elements` group, express checkout included, since they all share one group.
|
|
313
|
+
|
|
314
|
+
Two sources, because kempo-css has two: an explicit `theme` attribute on <html>, and the system
|
|
315
|
+
preference it falls back to when that says `auto`.
|
|
316
|
+
*/
|
|
317
|
+
#watchTheme(){
|
|
318
|
+
const restyle = () => {
|
|
319
|
+
if(!this.#elements) return;
|
|
320
|
+
const tokens = readTokens();
|
|
321
|
+
this.#elements.update({ appearance: buildAppearance(tokens) });
|
|
322
|
+
// `elements.update` only reaches the appearance-API elements (Express Checkout here) — the
|
|
323
|
+
// three split fields need their own `style` re-applied individually, same as at mount time.
|
|
324
|
+
const fieldStyle = buildFieldStyle(tokens);
|
|
325
|
+
this.#fields.cardNumber?.update({ style: fieldStyle });
|
|
326
|
+
this.#fields.cardExpiry?.update({ style: fieldStyle });
|
|
327
|
+
this.#fields.cardCvc?.update({ style: fieldStyle });
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
|
331
|
+
query.addEventListener('change', restyle);
|
|
332
|
+
this.#themeWatchers.push(() => query.removeEventListener('change', restyle));
|
|
333
|
+
|
|
334
|
+
const observer = new MutationObserver(restyle);
|
|
335
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['theme'] });
|
|
336
|
+
this.#themeWatchers.push(() => observer.disconnect());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/*
|
|
340
|
+
Mounting
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
async #mount(){
|
|
344
|
+
if(!this.clientSecret || this.#mountedFor === this.clientSecret) return;
|
|
345
|
+
this.#mountedFor = this.clientSecret;
|
|
346
|
+
|
|
347
|
+
const expressTarget = this.querySelector('[data-express-checkout]');
|
|
348
|
+
const numberTarget = this.querySelector('[data-card-number]');
|
|
349
|
+
const expiryTarget = this.querySelector('[data-card-expiry]');
|
|
350
|
+
const cvcTarget = this.querySelector('[data-card-cvc]');
|
|
351
|
+
if(!expressTarget || !numberTarget || !expiryTarget || !cvcTarget) return;
|
|
352
|
+
|
|
353
|
+
this.phase = 'loading';
|
|
354
|
+
this.message = '';
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
let key = this.publishableKey;
|
|
358
|
+
if(!key){
|
|
359
|
+
const [error, config] = await getConfig();
|
|
360
|
+
if(error) throw new Error(error.msg);
|
|
361
|
+
if(!config.configured) throw new Error('No payment provider is connected to this site yet');
|
|
362
|
+
key = config.publishableKey;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const Stripe = await loadStripeJs();
|
|
366
|
+
this.#stripe = Stripe(key);
|
|
367
|
+
const tokens = readTokens();
|
|
368
|
+
const fieldStyle = buildFieldStyle(tokens);
|
|
369
|
+
this.#elements = this.#stripe.elements({ clientSecret: this.clientSecret, appearance: buildAppearance(tokens) });
|
|
370
|
+
|
|
371
|
+
const expressCheckout = this.#elements.create('expressCheckout');
|
|
372
|
+
expressCheckout.mount(expressTarget);
|
|
373
|
+
expressCheckout.on('ready', ({ availablePaymentMethods }) => {
|
|
374
|
+
this.expressAvailable = !!availablePaymentMethods && Object.keys(availablePaymentMethods).length > 0;
|
|
375
|
+
});
|
|
376
|
+
// Fired by a click on whichever wallet button the customer chose. The element has already
|
|
377
|
+
// collected everything it needs by this point — confirming is the whole handler.
|
|
378
|
+
expressCheckout.on('confirm', async () => {
|
|
379
|
+
const { error, paymentIntent } = await this.#stripe.confirmPayment({
|
|
380
|
+
elements: this.#elements,
|
|
381
|
+
confirmParams: { return_url: this.returnUrl },
|
|
382
|
+
redirect: 'if_required',
|
|
383
|
+
});
|
|
384
|
+
this.#finish(error, paymentIntent);
|
|
385
|
+
});
|
|
386
|
+
this.#fields.expressCheckout = expressCheckout;
|
|
387
|
+
|
|
388
|
+
const onFieldChange = (field, event) => {
|
|
389
|
+
this.#fieldErrors[field] = event.error?.message || '';
|
|
390
|
+
this.fieldError = Object.values(this.#fieldErrors).find(Boolean) || '';
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const cardNumber = this.#elements.create('cardNumber', { showIcon: true, style: fieldStyle });
|
|
394
|
+
cardNumber.mount(numberTarget);
|
|
395
|
+
cardNumber.on('change', event => onFieldChange('number', event));
|
|
396
|
+
this.#fields.cardNumber = cardNumber;
|
|
397
|
+
|
|
398
|
+
const cardExpiry = this.#elements.create('cardExpiry', { style: fieldStyle });
|
|
399
|
+
cardExpiry.mount(expiryTarget);
|
|
400
|
+
cardExpiry.on('change', event => onFieldChange('expiry', event));
|
|
401
|
+
this.#fields.cardExpiry = cardExpiry;
|
|
402
|
+
|
|
403
|
+
// Not passed to confirmCardPayment directly — Stripe.js auto-links split card fields created
|
|
404
|
+
// under the same `elements` group, so mounting is all cardExpiry/cardCvc are needed for.
|
|
405
|
+
const cardCvc = this.#elements.create('cardCvc', { style: fieldStyle });
|
|
406
|
+
cardCvc.mount(cvcTarget);
|
|
407
|
+
cardCvc.on('change', event => onFieldChange('cvc', event));
|
|
408
|
+
this.#fields.cardCvc = cardCvc;
|
|
409
|
+
|
|
410
|
+
this.phase = 'ready';
|
|
411
|
+
} catch(error) {
|
|
412
|
+
// A remount is the only way out of a failed load, so let the next update try again.
|
|
413
|
+
this.#mountedFor = null;
|
|
414
|
+
this.phase = 'error';
|
|
415
|
+
this.message = error.message;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/*
|
|
420
|
+
Confirming
|
|
421
|
+
*/
|
|
422
|
+
|
|
423
|
+
submit = async event => {
|
|
424
|
+
event?.preventDefault();
|
|
425
|
+
if(this.phase !== 'ready') return;
|
|
426
|
+
|
|
427
|
+
this.phase = 'submitting';
|
|
428
|
+
this.message = '';
|
|
429
|
+
|
|
430
|
+
const { error, paymentIntent } = await this.#stripe.confirmCardPayment(this.clientSecret, {
|
|
431
|
+
payment_method: {
|
|
432
|
+
card: this.#fields.cardNumber,
|
|
433
|
+
...(this.postalCode ? { billing_details: { address: { postal_code: this.postalCode } } } : {}),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
this.#finish(error, paymentIntent);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
#finish(error, paymentIntent){
|
|
441
|
+
if(error){
|
|
442
|
+
this.phase = 'ready';
|
|
443
|
+
this.message = error.message || 'The payment could not be completed';
|
|
444
|
+
this.dispatchEvent(new CustomEvent('payment-failed', { detail: { message: this.message }, bubbles: true }));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if(paymentIntent?.status === 'processing'){
|
|
449
|
+
this.phase = 'processing';
|
|
450
|
+
this.message = 'Payment received — it is still being confirmed by the bank.';
|
|
451
|
+
this.dispatchEvent(new CustomEvent('payment-processing', { detail: { paymentIntent }, bubbles: true }));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
this.phase = 'done';
|
|
456
|
+
this.message = '';
|
|
457
|
+
this.dispatchEvent(new CustomEvent('payment-succeeded', { detail: { paymentIntent }, bubbles: true }));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/*
|
|
461
|
+
Rendering
|
|
462
|
+
*/
|
|
463
|
+
|
|
464
|
+
renderLightDom(){
|
|
465
|
+
/*
|
|
466
|
+
Every mount target below is rendered unconditionally, in every phase. Lit reuses a node only
|
|
467
|
+
while the template shape around it holds still — put one behind a conditional and a re-render
|
|
468
|
+
replaces it with a fresh one, tearing out whichever processor iframe was living there along
|
|
469
|
+
with anything the customer had already typed into it. The express-checkout wrapper is toggled
|
|
470
|
+
with the `hidden` attribute rather than removed for the same reason.
|
|
471
|
+
*/
|
|
472
|
+
return html`
|
|
473
|
+
<div class="mb" ?hidden=${!this.expressAvailable}>
|
|
474
|
+
<div class="full" data-express-checkout></div>
|
|
475
|
+
<p class="small tc-muted ta-center my">or pay with card</p>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<form @submit=${this.submit}>
|
|
479
|
+
<label class="mb">
|
|
480
|
+
<span class="small tc-muted d-b">Card number</span>
|
|
481
|
+
<div class="k-pay-field" data-card-number></div>
|
|
482
|
+
</label>
|
|
483
|
+
|
|
484
|
+
<div class="row mb">
|
|
485
|
+
<label class="col mrh">
|
|
486
|
+
<span class="small tc-muted d-b">Expiry</span>
|
|
487
|
+
<div class="k-pay-field" data-card-expiry></div>
|
|
488
|
+
</label>
|
|
489
|
+
<label class="col mrh">
|
|
490
|
+
<span class="small tc-muted d-b">CVC</span>
|
|
491
|
+
<div class="k-pay-field" data-card-cvc></div>
|
|
492
|
+
</label>
|
|
493
|
+
<label class="col">
|
|
494
|
+
<span class="small tc-muted d-b">ZIP / postal</span>
|
|
495
|
+
<input
|
|
496
|
+
type="text"
|
|
497
|
+
autocomplete="postal-code"
|
|
498
|
+
.value=${this.postalCode}
|
|
499
|
+
@input=${event => { this.postalCode = event.target.value; }}
|
|
500
|
+
/>
|
|
501
|
+
</label>
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
${this.fieldError ? html`<p class="tc-danger small">${this.fieldError}</p>` : ''}
|
|
505
|
+
|
|
506
|
+
${this.phase === 'loading' ? html`<p class="tc-muted small mt"><k-spinner></k-spinner> Loading the payment form…</p>` : ''}
|
|
507
|
+
|
|
508
|
+
${this.message ? html`
|
|
509
|
+
<p class="${this.phase === 'processing' ? 'tc-muted' : 'tc-danger'} small">${this.message}</p>
|
|
510
|
+
` : ''}
|
|
511
|
+
|
|
512
|
+
${this.phase === 'done' ? html`<p class="tc-success">Payment complete.</p>` : html`
|
|
513
|
+
<button type="submit" class="primary full mt" ?disabled=${this.phase !== 'ready'}>
|
|
514
|
+
${this.phase === 'submitting' ? 'Paying…' : this.submitLabel}
|
|
515
|
+
</button>
|
|
516
|
+
`}
|
|
517
|
+
</form>
|
|
518
|
+
`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
customElements.define('k-pay-form', PaymentForm);
|
package/public/sdk.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Browser client, served at /payments/sdk.js.
|
|
3
|
+
|
|
4
|
+
Mirrors the server SDK's names so a call reads the same on either side, and returns the same
|
|
5
|
+
[error, data] tuples the rest of kempo uses.
|
|
6
|
+
|
|
7
|
+
Note what is *not* here: nothing that confirms a payment. Confirming is the payment form's job
|
|
8
|
+
and it talks to the processor directly, so no card detail ever passes through this site. See
|
|
9
|
+
components/PaymentForm.js.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BASE = '/payments/api';
|
|
13
|
+
|
|
14
|
+
const request = async (path, options = {}) => {
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`${BASE}${path}`, { credentials: 'same-origin', ...options });
|
|
17
|
+
const data = await response.json().catch(() => ({}));
|
|
18
|
+
if(!response.ok) return [{ code: response.status, msg: data.error || response.statusText }, null];
|
|
19
|
+
return [null, data];
|
|
20
|
+
} catch(error) {
|
|
21
|
+
return [{ code: 0, msg: error.message }, null];
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const json = (method, body) => ({
|
|
26
|
+
method,
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
body: JSON.stringify(body ?? {}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const query = params => {
|
|
32
|
+
const search = new URLSearchParams();
|
|
33
|
+
for(const [key, value] of Object.entries(params || {})){
|
|
34
|
+
if(value !== undefined && value !== null && value !== '') search.set(key, value);
|
|
35
|
+
}
|
|
36
|
+
const string = search.toString();
|
|
37
|
+
return string ? `?${string}` : '';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
Which processor is configured and its publishable key. The one call here that works for a visitor
|
|
42
|
+
with no account, because a checkout page has to.
|
|
43
|
+
*/
|
|
44
|
+
export const getConfig = () => request('/config');
|
|
45
|
+
|
|
46
|
+
export const listPayments = (params = {}) => request(`/payments${query(params)}`);
|
|
47
|
+
|
|
48
|
+
export const getPayment = id => request(`/payments/${id}`);
|
|
49
|
+
|
|
50
|
+
export const createPayment = body => request('/payments', json('POST', body));
|
|
51
|
+
|
|
52
|
+
export const getClientSecret = id => request(`/payments/${id}/client-secret`);
|
|
53
|
+
|
|
54
|
+
export const capturePayment = (id, { amount } = {}) => request(`/payments/${id}/capture`, json('POST', { amount }));
|
|
55
|
+
|
|
56
|
+
export const cancelPayment = id => request(`/payments/${id}/cancel`, json('POST'));
|
|
57
|
+
|
|
58
|
+
export const refundPayment = (id, { amount, reason } = {}) => request(`/payments/${id}/refund`, json('POST', { amount, reason }));
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
Re-reads the payment from the processor. For the case where a webhook has not arrived — see the
|
|
62
|
+
route's own note.
|
|
63
|
+
*/
|
|
64
|
+
export const syncPayment = id => request(`/payments/${id}/sync`, json('POST'));
|
|
65
|
+
|
|
66
|
+
export const getSummary = () => request('/summary');
|
|
67
|
+
|
|
68
|
+
export const listProviders = () => request('/providers');
|
|
69
|
+
|
|
70
|
+
export const getSettings = () => request('/settings');
|
|
71
|
+
|
|
72
|
+
export const saveSettings = body => request('/settings', json('PUT', body));
|