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,310 @@
|
|
|
1
|
+
import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
|
|
2
|
+
import '/kempo-ui/components/Icon.js';
|
|
3
|
+
import '/kempo-ui/components/Spinner.js';
|
|
4
|
+
import { html, css } from '/kempo-ui/lit-all.min.js';
|
|
5
|
+
import { getSettings, saveSettings } from '/payments/sdk.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Connecting the site to a processor.
|
|
9
|
+
|
|
10
|
+
Three things on this screen are load-bearing and none of them is the form itself:
|
|
11
|
+
|
|
12
|
+
- **The mode banner.** Test keys and live keys look identical at a glance and behave identically
|
|
13
|
+
until real money is involved. Which one is in use is stated in words, at the top, always.
|
|
14
|
+
- **The webhook endpoint.** Copy-pasteable, with the exact list of events beside it. A site whose
|
|
15
|
+
endpoint is wrong takes payments perfectly and never learns that any of them succeeded, and the
|
|
16
|
+
symptom appears nowhere near the cause.
|
|
17
|
+
- **Blank means unchanged.** A secret cannot be read back, so its field is always empty. Saving
|
|
18
|
+
the form after changing the currency must not wipe the keys, and clearing one on purpose is a
|
|
19
|
+
separate, explicit button.
|
|
20
|
+
*/
|
|
21
|
+
export default class ProviderSettings extends ShadowComponent {
|
|
22
|
+
static properties = {
|
|
23
|
+
settings: { type: Object, state: true },
|
|
24
|
+
providers: { type: Array, state: true },
|
|
25
|
+
status: { type: Object, state: true },
|
|
26
|
+
encryptionConfigured: { type: Boolean, state: true },
|
|
27
|
+
values: { type: Object, state: true },
|
|
28
|
+
edits: { type: Object, state: true },
|
|
29
|
+
saving: { type: Boolean, state: true },
|
|
30
|
+
error: { type: String, state: true },
|
|
31
|
+
notice: { type: String, state: true },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
constructor(){
|
|
35
|
+
super();
|
|
36
|
+
this.settings = null;
|
|
37
|
+
this.providers = [];
|
|
38
|
+
this.status = null;
|
|
39
|
+
this.encryptionConfigured = true;
|
|
40
|
+
this.values = {};
|
|
41
|
+
/*
|
|
42
|
+
Credential fields are kept apart from the rest. They are the only ones whose current value
|
|
43
|
+
cannot be read back, so "what is in the box" and "what is stored" are different questions for
|
|
44
|
+
them and the same question for everything else.
|
|
45
|
+
*/
|
|
46
|
+
this.edits = {};
|
|
47
|
+
this.saving = false;
|
|
48
|
+
this.error = '';
|
|
49
|
+
this.notice = '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
Lifecycle
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
connectedCallback(){
|
|
57
|
+
super.connectedCallback();
|
|
58
|
+
this.load();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
Data
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
load = async () => {
|
|
66
|
+
const [error, data] = await getSettings();
|
|
67
|
+
if(error){
|
|
68
|
+
this.error = error.msg;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.settings = data.settings;
|
|
73
|
+
this.providers = data.providers;
|
|
74
|
+
this.status = data.status;
|
|
75
|
+
this.encryptionConfigured = data.encryptionConfigured;
|
|
76
|
+
this.values = { ...data.settings };
|
|
77
|
+
this.edits = {};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
get provider(){
|
|
81
|
+
return this.providers.find(entry => entry.name === this.values.provider) || this.providers[0] || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
set = (name, value) => {
|
|
85
|
+
this.values = { ...this.values, [name]: value };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
edit = (name, value) => {
|
|
89
|
+
this.edits = { ...this.edits, [name]: value };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
save = async () => {
|
|
93
|
+
this.saving = true;
|
|
94
|
+
this.error = '';
|
|
95
|
+
this.notice = '';
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
Only what was actually typed. An untouched credential field is not sent at all, which is what
|
|
99
|
+
makes "blank means unchanged" true at the wire rather than only in the route.
|
|
100
|
+
*/
|
|
101
|
+
const body = {
|
|
102
|
+
provider: this.values.provider,
|
|
103
|
+
currency: this.values.currency,
|
|
104
|
+
capture_method: this.values.captureMethod,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for(const field of this.provider?.credentialFields || []){
|
|
108
|
+
const value = this.edits[field.name];
|
|
109
|
+
if(value === null || (typeof value === 'string' && value.trim())) body[field.name] = value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const [error] = await saveSettings(body);
|
|
113
|
+
this.saving = false;
|
|
114
|
+
|
|
115
|
+
if(error){
|
|
116
|
+
this.error = error.msg;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Typed keys are dropped the moment they are stored — there is no reason for one to sit in a
|
|
121
|
+
// form field on an admin's screen for the rest of the afternoon.
|
|
122
|
+
this.edits = {};
|
|
123
|
+
this.notice = 'Saved.';
|
|
124
|
+
await this.load();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
clear = async name => {
|
|
128
|
+
this.edits = { ...this.edits, [name]: null };
|
|
129
|
+
await this.save();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
copyWebhookUrl = () => {
|
|
133
|
+
navigator.clipboard?.writeText(this.webhookUrl);
|
|
134
|
+
this.notice = 'Endpoint URL copied.';
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
get webhookUrl(){
|
|
138
|
+
return `${window.location.origin}${this.provider?.webhookPath || ''}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
Rendering
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
#renderBanner(){
|
|
146
|
+
if(!this.encryptionConfigured){
|
|
147
|
+
return html`
|
|
148
|
+
<div class="card banner tc-danger">
|
|
149
|
+
<strong>SETTINGS_ENCRYPTION_KEY is not set on this server.</strong>
|
|
150
|
+
<p class="small">
|
|
151
|
+
API credentials are encrypted with it, so none can be stored until it exists. Generate one with
|
|
152
|
+
<code class="ff-mono">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"</code>,
|
|
153
|
+
put it in the site’s environment, and restart. Back it up — losing it makes every stored secret unrecoverable.
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if(!this.status?.configured){
|
|
160
|
+
return html`
|
|
161
|
+
<div class="card banner tc-warning">
|
|
162
|
+
<strong>Not connected.</strong>
|
|
163
|
+
<p class="small">Nothing can be charged until a publishable key and a secret key are saved below.</p>
|
|
164
|
+
</div>
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if(this.status.mismatchedKeys){
|
|
169
|
+
return html`
|
|
170
|
+
<div class="card banner tc-danger">
|
|
171
|
+
<strong>These keys are from different modes.</strong>
|
|
172
|
+
<p class="small">
|
|
173
|
+
The secret key is a ${this.status.mode} key and the publishable key is not. A payment started
|
|
174
|
+
with one cannot be completed with the other, and the failure shows up in the customer’s browser
|
|
175
|
+
as a generic error. Use both keys from the same place.
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if(!this.status.hasWebhookSecret){
|
|
182
|
+
return html`
|
|
183
|
+
<div class="card banner tc-warning">
|
|
184
|
+
<strong>No webhook signing secret.</strong>
|
|
185
|
+
<p class="small">
|
|
186
|
+
Payments will be taken and none of them will ever be confirmed here — every incoming event is
|
|
187
|
+
rejected without it. Add the endpoint below, then paste its signing secret in.
|
|
188
|
+
</p>
|
|
189
|
+
</div>
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return html`
|
|
194
|
+
<div class="card banner ${this.status.livemode ? 'tc-danger' : 'tc-success'}">
|
|
195
|
+
<strong>
|
|
196
|
+
${this.status.livemode
|
|
197
|
+
? 'Connected in live mode — payments taken here are real.'
|
|
198
|
+
: 'Connected in test mode. No real money can move.'}
|
|
199
|
+
</strong>
|
|
200
|
+
</div>
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#renderCredentials(){
|
|
205
|
+
const provider = this.provider;
|
|
206
|
+
if(!provider) return '';
|
|
207
|
+
|
|
208
|
+
return provider.credentialFields.map(field => {
|
|
209
|
+
const isSet = !!this.status?.fields?.[field.name];
|
|
210
|
+
const value = this.edits[field.name];
|
|
211
|
+
|
|
212
|
+
return html`
|
|
213
|
+
<label class="field">
|
|
214
|
+
<span class="d-b">${field.label} ${isSet ? html`<span class="small tc-success">· set</span>` : ''}</span>
|
|
215
|
+
<span class="small tc-muted d-b">${field.help}</span>
|
|
216
|
+
<input
|
|
217
|
+
type=${field.type === 'secret' ? 'password' : 'text'}
|
|
218
|
+
autocomplete="off"
|
|
219
|
+
placeholder=${isSet ? '•••••••• — leave blank to keep' : field.placeholder}
|
|
220
|
+
.value=${typeof value === 'string' ? value : ''}
|
|
221
|
+
@input=${event => this.edit(field.name, event.target.value)}
|
|
222
|
+
/>
|
|
223
|
+
${isSet ? html`
|
|
224
|
+
<button class="link small" ?disabled=${this.saving} @click=${() => this.clear(field.name)}>Clear this key</button>
|
|
225
|
+
` : ''}
|
|
226
|
+
</label>
|
|
227
|
+
`;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
render(){
|
|
232
|
+
if(!this.settings) return html`<k-spinner></k-spinner>`;
|
|
233
|
+
|
|
234
|
+
const provider = this.provider;
|
|
235
|
+
|
|
236
|
+
return html`
|
|
237
|
+
${this.#renderBanner()}
|
|
238
|
+
${this.error ? html`<p class="tc-danger">${this.error}</p>` : ''}
|
|
239
|
+
${this.notice ? html`<p class="tc-success small">${this.notice}</p>` : ''}
|
|
240
|
+
|
|
241
|
+
<h2>Processor</h2>
|
|
242
|
+
<label class="field">
|
|
243
|
+
<span class="small tc-muted d-b">Where payments are taken</span>
|
|
244
|
+
<select .value=${this.values.provider} @change=${event => this.set('provider', event.target.value)}>
|
|
245
|
+
${this.providers.map(entry => html`
|
|
246
|
+
<option value=${entry.name} ?selected=${entry.name === this.values.provider}>${entry.label}</option>
|
|
247
|
+
`)}
|
|
248
|
+
</select>
|
|
249
|
+
</label>
|
|
250
|
+
|
|
251
|
+
${this.#renderCredentials()}
|
|
252
|
+
|
|
253
|
+
<h2>Webhook endpoint</h2>
|
|
254
|
+
<p class="small tc-muted">
|
|
255
|
+
Add this in the processor’s dashboard. It is how this site finds out a payment succeeded —
|
|
256
|
+
the customer’s browser is never trusted for that, because it may be closed, offline, or lying.
|
|
257
|
+
</p>
|
|
258
|
+
<div class="endpoint d-f">
|
|
259
|
+
<input class="ff-mono" type="text" readonly .value=${this.webhookUrl} />
|
|
260
|
+
<button class="secondary" @click=${this.copyWebhookUrl}><k-icon name="content_copy"></k-icon> Copy</button>
|
|
261
|
+
</div>
|
|
262
|
+
${provider?.webhookEvents?.length ? html`
|
|
263
|
+
<p class="small tc-muted">Subscribe it to these events:</p>
|
|
264
|
+
<ul class="events small ff-mono">
|
|
265
|
+
${provider.webhookEvents.map(event => html`<li>${event}</li>`)}
|
|
266
|
+
</ul>
|
|
267
|
+
` : ''}
|
|
268
|
+
<p class="small tc-muted">
|
|
269
|
+
On a machine the processor cannot reach — localhost — forward them instead:
|
|
270
|
+
<code class="ff-mono">stripe listen --forward-to ${this.webhookUrl}</code>.
|
|
271
|
+
That prints its own signing secret, which is the one to paste above while developing.
|
|
272
|
+
</p>
|
|
273
|
+
|
|
274
|
+
<h2>Defaults</h2>
|
|
275
|
+
<label class="field">
|
|
276
|
+
<span class="d-b">Currency</span>
|
|
277
|
+
<span class="small tc-muted d-b">Used when whatever is asking for a payment does not name one</span>
|
|
278
|
+
<input type="text" maxlength="3" .value=${this.values.currency} @input=${event => this.set('currency', event.target.value)} />
|
|
279
|
+
</label>
|
|
280
|
+
|
|
281
|
+
<label class="field">
|
|
282
|
+
<span class="d-b">Capture</span>
|
|
283
|
+
<span class="small tc-muted d-b">
|
|
284
|
+
Automatic charges the card when the customer confirms. Manual only places a hold, and somebody
|
|
285
|
+
has to capture it before it expires — right for anything that ships, wrong for anything instant.
|
|
286
|
+
</span>
|
|
287
|
+
<select .value=${this.values.captureMethod} @change=${event => this.set('captureMethod', event.target.value)}>
|
|
288
|
+
<option value="automatic" ?selected=${this.values.captureMethod === 'automatic'}>Automatic</option>
|
|
289
|
+
<option value="manual" ?selected=${this.values.captureMethod === 'manual'}>Manual</option>
|
|
290
|
+
</select>
|
|
291
|
+
</label>
|
|
292
|
+
|
|
293
|
+
<button class="primary" ?disabled=${this.saving} @click=${this.save}>
|
|
294
|
+
${this.saving ? 'Saving…' : 'Save settings'}
|
|
295
|
+
</button>
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
static styles = css`
|
|
300
|
+
:host { display: block; }
|
|
301
|
+
.banner { margin-bottom: var(--spacer); }
|
|
302
|
+
.banner p { margin-bottom: 0; }
|
|
303
|
+
.field { margin-bottom: var(--spacer); max-width: 40rem; }
|
|
304
|
+
.endpoint { align-items: center; margin-bottom: var(--spacer_h); }
|
|
305
|
+
.endpoint input { margin-bottom: 0; margin-right: var(--spacer_h); flex: 1 1 20rem; }
|
|
306
|
+
.events { margin-left: var(--spacer); }
|
|
307
|
+
`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
customElements.define('k-pay-settings', ProviderSettings);
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import LightComponent from '/kempo-ui/components/LightComponent.js';
|
|
2
|
+
import '/kempo-ui/components/Icon.js';
|
|
3
|
+
import '/kempo-ui/components/Spinner.js';
|
|
4
|
+
import '/payments/components/PaymentForm.js';
|
|
5
|
+
import { html } from '/kempo-ui/lit-all.min.js';
|
|
6
|
+
import { createPayment, getConfig, getPayment } from '/payments/sdk.js';
|
|
7
|
+
import { formatAmount, statusLabel, toMinor } from '/payments/utils/money.js';
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Takes one real payment, through the real form, against the real processor.
|
|
11
|
+
|
|
12
|
+
Worth a tab of its own because nothing else proves the connection works. Saved keys prove nothing
|
|
13
|
+
— they prove somebody pasted a string. This exercises the whole path end to end: an intent
|
|
14
|
+
created with the secret key, a form mounted with the publishable key, a confirmation from the
|
|
15
|
+
browser, and then the part that actually matters — a webhook coming back and moving the payment
|
|
16
|
+
to Paid. If the last step never happens, the payment sits at "Processing" on this screen, and
|
|
17
|
+
that is the webhook endpoint telling you it is not configured.
|
|
18
|
+
|
|
19
|
+
**Light DOM, and it has to be.** This component hosts `<k-pay-form>`, and Stripe's card fields do
|
|
20
|
+
not mount inside a shadow root — they reserve the height and then render nothing, with no error
|
|
21
|
+
anywhere (see PaymentForm.js's own note for what was actually measured). Everything else in this
|
|
22
|
+
extension's admin is a ShadowComponent; this one cannot be, and neither can anything else that
|
|
23
|
+
ends up containing the payment form.
|
|
24
|
+
|
|
25
|
+
Styling therefore comes from kempo-css utilities rather than a `static styles` block, and the
|
|
26
|
+
form's width from the 12-column grid.
|
|
27
|
+
*/
|
|
28
|
+
export default class TestPayment extends LightComponent {
|
|
29
|
+
static properties = {
|
|
30
|
+
config: { type: Object, state: true },
|
|
31
|
+
amount: { type: String, state: true },
|
|
32
|
+
description: { type: String, state: true },
|
|
33
|
+
captureMethod: { type: String, state: true },
|
|
34
|
+
payment: { type: Object, state: true },
|
|
35
|
+
clientSecret: { type: String, state: true },
|
|
36
|
+
outcome: { type: String, state: true },
|
|
37
|
+
starting: { type: Boolean, state: true },
|
|
38
|
+
error: { type: String, state: true },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
#poll = null;
|
|
42
|
+
|
|
43
|
+
constructor(){
|
|
44
|
+
super();
|
|
45
|
+
this.config = null;
|
|
46
|
+
this.amount = '10.00';
|
|
47
|
+
this.description = 'Test payment';
|
|
48
|
+
this.captureMethod = '';
|
|
49
|
+
this.payment = null;
|
|
50
|
+
this.clientSecret = '';
|
|
51
|
+
this.outcome = '';
|
|
52
|
+
this.starting = false;
|
|
53
|
+
this.error = '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
Lifecycle
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
connectedCallback(){
|
|
61
|
+
super.connectedCallback();
|
|
62
|
+
this.load();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
disconnectedCallback(){
|
|
66
|
+
super.disconnectedCallback();
|
|
67
|
+
clearInterval(this.#poll);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
load = async () => {
|
|
71
|
+
const [error, config] = await getConfig();
|
|
72
|
+
if(error){
|
|
73
|
+
this.error = error.msg;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.config = config;
|
|
77
|
+
this.captureMethod = config.captureMethod || 'automatic';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
Actions
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
start = async () => {
|
|
85
|
+
const amount = toMinor(this.amount, this.config.currency);
|
|
86
|
+
if(!amount || amount <= 0){
|
|
87
|
+
this.error = 'Enter an amount to charge';
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.starting = true;
|
|
92
|
+
this.error = '';
|
|
93
|
+
this.outcome = '';
|
|
94
|
+
|
|
95
|
+
const [error, result] = await createPayment({
|
|
96
|
+
amount,
|
|
97
|
+
currency: this.config.currency,
|
|
98
|
+
description: this.description || 'Test payment',
|
|
99
|
+
captureMethod: this.captureMethod,
|
|
100
|
+
owner: 'kempo-payments',
|
|
101
|
+
reference: 'admin-test',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.starting = false;
|
|
105
|
+
|
|
106
|
+
if(error){
|
|
107
|
+
this.error = error.msg;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.payment = result.payment;
|
|
112
|
+
this.clientSecret = result.clientSecret;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
The browser saying it worked is not the site knowing it worked — the webhook is. So after the
|
|
117
|
+
form reports success, this re-reads the payment from the server until the status catches up,
|
|
118
|
+
which is precisely the round trip being tested. It gives up after thirty seconds and says so,
|
|
119
|
+
because "it never arrived" is the answer somebody needs, not a spinner forever.
|
|
120
|
+
*/
|
|
121
|
+
confirmed = () => {
|
|
122
|
+
this.outcome = 'waiting';
|
|
123
|
+
const started = Date.now();
|
|
124
|
+
|
|
125
|
+
clearInterval(this.#poll);
|
|
126
|
+
this.#poll = setInterval(async () => {
|
|
127
|
+
const [error, data] = await getPayment(this.payment.id);
|
|
128
|
+
if(!error) this.payment = data.payment;
|
|
129
|
+
|
|
130
|
+
if(data?.payment?.status === 'succeeded'){
|
|
131
|
+
clearInterval(this.#poll);
|
|
132
|
+
this.outcome = 'confirmed';
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if(Date.now() - started > 30000){
|
|
137
|
+
clearInterval(this.#poll);
|
|
138
|
+
this.outcome = 'no-webhook';
|
|
139
|
+
}
|
|
140
|
+
}, 2000);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
Deliberately no `payment-failed` handler. The form already shows the processor's message inside
|
|
145
|
+
itself and goes back to accepting a card; repeating it here put the same red line on screen twice
|
|
146
|
+
and, being set-and-never-cleared, left it there after a successful retry.
|
|
147
|
+
*/
|
|
148
|
+
reset = () => {
|
|
149
|
+
clearInterval(this.#poll);
|
|
150
|
+
this.payment = null;
|
|
151
|
+
this.clientSecret = '';
|
|
152
|
+
this.outcome = '';
|
|
153
|
+
this.error = '';
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
Rendering
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
#renderOutcome(){
|
|
161
|
+
if(this.outcome === 'waiting'){
|
|
162
|
+
return html`
|
|
163
|
+
<p class="tc-muted"><k-spinner></k-spinner> Confirmed in the browser. Waiting for the processor’s webhook…</p>
|
|
164
|
+
`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if(this.outcome === 'confirmed'){
|
|
168
|
+
return html`
|
|
169
|
+
<div class="card tc-success">
|
|
170
|
+
<strong><k-icon name="check_circle"></k-icon> Everything works.</strong>
|
|
171
|
+
<p class="small">
|
|
172
|
+
The payment was taken and the webhook came back and confirmed it. That is the whole path —
|
|
173
|
+
any kempo (CMS) extension listening for <code class="ff-mono">payment:succeeded</code> would have fired just now.
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if(this.outcome === 'no-webhook'){
|
|
180
|
+
return html`
|
|
181
|
+
<div class="card tc-warning">
|
|
182
|
+
<strong>Paid, but nothing came back.</strong>
|
|
183
|
+
<p class="small">
|
|
184
|
+
The charge went through — it is ${statusLabel(this.payment.status).toLowerCase()} here, and the
|
|
185
|
+
processor’s dashboard will show it. What did not happen is the webhook, so this site would never
|
|
186
|
+
have learnt about it on its own, and no order would ever be fulfilled.
|
|
187
|
+
</p>
|
|
188
|
+
<p class="small mb0">
|
|
189
|
+
Add the endpoint from the Settings tab, or forward events with
|
|
190
|
+
<code class="ff-mono">stripe listen</code> if this site is running on localhost.
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
renderLightDom(){
|
|
200
|
+
if(this.error && !this.config) return html`<p class="tc-danger">${this.error}</p>`;
|
|
201
|
+
if(!this.config) return html`<k-spinner></k-spinner>`;
|
|
202
|
+
|
|
203
|
+
if(!this.config.configured){
|
|
204
|
+
return html`
|
|
205
|
+
<p class="tc-muted">
|
|
206
|
+
No processor is connected yet. Add its keys on the Settings tab and come back.
|
|
207
|
+
</p>
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if(this.payment){
|
|
212
|
+
return html`
|
|
213
|
+
<div class="row">
|
|
214
|
+
<div class="span-6 t-span-9 m-span-12">
|
|
215
|
+
${this.config.mode === 'live' ? html`
|
|
216
|
+
<div class="card tc-danger">
|
|
217
|
+
<strong>These are live keys.</strong>
|
|
218
|
+
<p class="small mb0">This will charge a real card. Switch to test keys unless that is what you meant.</p>
|
|
219
|
+
</div>
|
|
220
|
+
` : html`
|
|
221
|
+
<p class="small tc-muted">
|
|
222
|
+
Test mode. Use card <code class="ff-mono">4242 4242 4242 4242</code>, any future expiry, any CVC.
|
|
223
|
+
<code class="ff-mono">4000 0025 0000 3155</code> makes the bank ask for authentication;
|
|
224
|
+
<code class="ff-mono">4000 0000 0000 9995</code> is declined.
|
|
225
|
+
</p>
|
|
226
|
+
`}
|
|
227
|
+
|
|
228
|
+
<div class="card">
|
|
229
|
+
<p><strong>${formatAmount(this.payment.amount, this.payment.currency)}</strong> — ${this.payment.description}</p>
|
|
230
|
+
<k-pay-form
|
|
231
|
+
client-secret=${this.clientSecret}
|
|
232
|
+
submit-label="Pay ${formatAmount(this.payment.amount, this.payment.currency)}"
|
|
233
|
+
@payment-succeeded=${this.confirmed}
|
|
234
|
+
@payment-processing=${this.confirmed}
|
|
235
|
+
></k-pay-form>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
${this.#renderOutcome()}
|
|
239
|
+
|
|
240
|
+
<button class="link" @click=${this.reset}>Start another</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return html`
|
|
247
|
+
<div class="row">
|
|
248
|
+
<div class="span-5 t-span-7 m-span-12">
|
|
249
|
+
<p class="tc-muted">
|
|
250
|
+
Takes a payment the same way a checkout would, so the keys, the form and the webhook are all
|
|
251
|
+
exercised at once. In test mode nothing real moves.
|
|
252
|
+
</p>
|
|
253
|
+
|
|
254
|
+
${this.error ? html`<p class="tc-danger">${this.error}</p>` : ''}
|
|
255
|
+
|
|
256
|
+
<label class="mb">
|
|
257
|
+
<span class="d-b">Amount (${this.config.currency.toUpperCase()})</span>
|
|
258
|
+
<input type="number" step="any" min="0" .value=${this.amount} @input=${event => { this.amount = event.target.value; }} />
|
|
259
|
+
</label>
|
|
260
|
+
|
|
261
|
+
<label class="mb">
|
|
262
|
+
<span class="d-b">Description</span>
|
|
263
|
+
<input type="text" .value=${this.description} @input=${event => { this.description = event.target.value; }} />
|
|
264
|
+
</label>
|
|
265
|
+
|
|
266
|
+
<label class="mb">
|
|
267
|
+
<span class="d-b">Capture</span>
|
|
268
|
+
<select .value=${this.captureMethod} @change=${event => { this.captureMethod = event.target.value; }}>
|
|
269
|
+
<option value="automatic" ?selected=${this.captureMethod === 'automatic'}>Automatic — charge immediately</option>
|
|
270
|
+
<option value="manual" ?selected=${this.captureMethod === 'manual'}>Manual — authorise only, capture later</option>
|
|
271
|
+
</select>
|
|
272
|
+
</label>
|
|
273
|
+
|
|
274
|
+
<button class="primary" ?disabled=${this.starting} @click=${this.start}>
|
|
275
|
+
${this.starting ? 'Starting…' : 'Start payment'}
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
customElements.define('k-pay-test', TestPayment);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="Payments - Admin">
|
|
5
|
+
<content>
|
|
6
|
+
<script type="module" src="/kempo/components/PermissionTarget.js"></script>
|
|
7
|
+
<script type="module" src="/kempo-ui/components/Tabs.js"></script>
|
|
8
|
+
<script type="module" src="/admin/extension/kempo-payments/components/PaymentList.js"></script>
|
|
9
|
+
<script type="module" src="/admin/extension/kempo-payments/components/ProviderSettings.js"></script>
|
|
10
|
+
<script type="module" src="/admin/extension/kempo-payments/components/TestPayment.js"></script>
|
|
11
|
+
|
|
12
|
+
<div>
|
|
13
|
+
<h1>Payments</h1>
|
|
14
|
+
<p class="tc-muted">
|
|
15
|
+
Every charge this site has taken, and the connection to the processor that takes them. Nothing
|
|
16
|
+
here holds a card number — those go from the customer's browser straight to the processor, and
|
|
17
|
+
what is kept is this site's record of what happened to the money.
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<k-tabs id="tabs" active="payments" persistent-id="kempo-payments-admin-tab">
|
|
21
|
+
<k-tab id="paymentsTab" for="payments">Payments</k-tab>
|
|
22
|
+
<k-tab id="testTab" for="test">Take a payment</k-tab>
|
|
23
|
+
<k-tab id="settingsTab" for="settings">Settings</k-tab>
|
|
24
|
+
|
|
25
|
+
<k-tab-content id="paymentsContent" name="payments">
|
|
26
|
+
<k-pay-list></k-pay-list>
|
|
27
|
+
</k-tab-content>
|
|
28
|
+
|
|
29
|
+
<k-tab-content id="testContent" name="test">
|
|
30
|
+
<k-pay-test></k-pay-test>
|
|
31
|
+
</k-tab-content>
|
|
32
|
+
|
|
33
|
+
<k-tab-content id="settingsContent" name="settings">
|
|
34
|
+
<k-pay-settings></k-pay-settings>
|
|
35
|
+
</k-tab-content>
|
|
36
|
+
</k-tabs>
|
|
37
|
+
|
|
38
|
+
<!--
|
|
39
|
+
Neither of these can wrap the <k-tab>/<k-tab-content> pairs the way k-permission normally
|
|
40
|
+
wraps content: k-tabs finds its tabs with a direct-child query, so any element between
|
|
41
|
+
<k-tabs> and <k-tab> — including a hidden one — breaks tab selection outright, not just the
|
|
42
|
+
display. k-permission-target instead reaches in by id and hides the tab and its pane in place,
|
|
43
|
+
leaving both exactly where <k-tabs> expects to find them.
|
|
44
|
+
|
|
45
|
+
This is a courtesy, same as everywhere else in this file: the actual boundary is server-side,
|
|
46
|
+
on each route.
|
|
47
|
+
-->
|
|
48
|
+
<k-permission-target has="payments:view" target="#paymentsTab, #paymentsContent"></k-permission-target>
|
|
49
|
+
<k-permission-target has="payments:create" target="#testTab, #testContent"></k-permission-target>
|
|
50
|
+
<k-permission-target has="payments:settings" target="#settingsTab, #settingsContent"></k-permission-target>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<script type="module">
|
|
54
|
+
import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
55
|
+
|
|
56
|
+
await customElements.whenDefined('k-tabs');
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
The markup defaults to the Payments tab, and persistent-id can restore whichever tab a
|
|
60
|
+
returning visitor left open — including one this particular visitor cannot see, if their
|
|
61
|
+
permissions changed since. The k-permission-target elements above hide a tab's own button and
|
|
62
|
+
pane; this handles never leaving a hidden one selected.
|
|
63
|
+
*/
|
|
64
|
+
const [error, data] = await getAllCurrentUserPermissions();
|
|
65
|
+
const permissions = new Set(error ? [] : (data?.permissions || []));
|
|
66
|
+
|
|
67
|
+
const tabs = document.getElementById('tabs');
|
|
68
|
+
if(tabs){
|
|
69
|
+
const visibility = {
|
|
70
|
+
payments: permissions.has('payments:view'),
|
|
71
|
+
test: permissions.has('payments:create'),
|
|
72
|
+
settings: permissions.has('payments:settings'),
|
|
73
|
+
};
|
|
74
|
+
if(!visibility[tabs.active]){
|
|
75
|
+
const fallback = Object.keys(visibility).find(name => visibility[name]);
|
|
76
|
+
if(fallback) tabs.active = fallback;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
</content>
|
|
81
|
+
</page>
|