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,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>
@@ -0,0 +1,3 @@
1
+ <content id="kempo-payments-nav" name="Payments" owner="kempo-payments" enabled="true" locked="true" location="admin-nav-extensions" priority="3">
2
+ <k-aside-item icon="credit_card" href="/admin/extension/kempo-payments/" no-expand>Payments</k-aside-item>
3
+ </content>