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,304 @@
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 '/kempo/components/Permission.js';
5
+ import { html, css } from '/kempo-ui/lit-all.min.js';
6
+ import {
7
+ getPayment, capturePayment, cancelPayment, refundPayment, syncPayment,
8
+ } from '/payments/sdk.js';
9
+ import { formatAmount, fromMinor, statusLabel, statusTone, toMinor } from '/payments/utils/money.js';
10
+
11
+ /*
12
+ One payment, and everything anybody can do to it.
13
+
14
+ The event log is on the screen rather than behind a link because it is the answer to the question
15
+ this screen exists to settle: the customer says they paid and this says they did not. Nine times
16
+ out of ten the log is empty, which means no webhook ever arrived, which means the endpoint or its
17
+ signing secret is wrong — and that is visible at a glance rather than after an hour of guessing.
18
+ */
19
+ export default class PaymentDetail extends ShadowComponent {
20
+ static properties = {
21
+ paymentId: { type: String, attribute: 'payment-id' },
22
+ payment: { type: Object, state: true },
23
+ refunds: { type: Array, state: true },
24
+ events: { type: Array, state: true },
25
+ refundable: { type: Number, state: true },
26
+ refundAmount: { type: String, state: true },
27
+ refundReason: { type: String, state: true },
28
+ busy: { type: String, state: true },
29
+ error: { type: String, state: true },
30
+ notice: { type: String, state: true },
31
+ };
32
+
33
+ constructor(){
34
+ super();
35
+ this.paymentId = '';
36
+ this.payment = null;
37
+ this.refunds = [];
38
+ this.events = [];
39
+ this.refundable = 0;
40
+ this.refundAmount = '';
41
+ this.refundReason = '';
42
+ this.busy = '';
43
+ this.error = '';
44
+ this.notice = '';
45
+ }
46
+
47
+ /*
48
+ Lifecycle
49
+ */
50
+
51
+ updated(changed){
52
+ super.updated?.(changed);
53
+ if(changed.has('paymentId') && this.paymentId) this.load();
54
+ }
55
+
56
+ /*
57
+ Data
58
+ */
59
+
60
+ load = async () => {
61
+ this.error = '';
62
+ const [error, data] = await getPayment(this.paymentId);
63
+ if(error){
64
+ this.error = error.msg;
65
+ return;
66
+ }
67
+
68
+ this.payment = data.payment;
69
+ this.refunds = data.refunds;
70
+ this.events = data.events;
71
+ this.refundable = data.refundable;
72
+ // Prefilled with everything outstanding, which is what a refund usually is.
73
+ this.refundAmount = data.refundable ? String(fromMinor(data.refundable, data.payment.currency)) : '';
74
+ };
75
+
76
+ /*
77
+ Actions
78
+ */
79
+
80
+ #run = async (name, action) => {
81
+ this.busy = name;
82
+ this.error = '';
83
+ this.notice = '';
84
+
85
+ const [error] = await action();
86
+
87
+ this.busy = '';
88
+ if(error){
89
+ this.error = error.msg;
90
+ return false;
91
+ }
92
+
93
+ await this.load();
94
+ // The list behind this panel shows the status and the refunded total, both of which just moved.
95
+ this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }));
96
+ return true;
97
+ };
98
+
99
+ capture = () => this.#run('capture', () => capturePayment(this.payment.id));
100
+
101
+ cancel = () => this.#run('cancel', () => cancelPayment(this.payment.id));
102
+
103
+ sync = async () => {
104
+ const before = this.payment.status;
105
+ const ok = await this.#run('sync', () => syncPayment(this.payment.id));
106
+ if(ok){
107
+ this.notice = this.payment.status === before
108
+ ? 'The processor agrees with what is shown here — nothing changed.'
109
+ : `Updated from the processor: now ${statusLabel(this.payment.status).toLowerCase()}.`;
110
+ }
111
+ };
112
+
113
+ refund = async () => {
114
+ const amount = toMinor(this.refundAmount, this.payment.currency);
115
+ if(!amount || amount <= 0){
116
+ this.error = 'Enter an amount to refund';
117
+ return;
118
+ }
119
+
120
+ const ok = await this.#run('refund', () => refundPayment(this.payment.id, { amount, reason: this.refundReason || null }));
121
+ if(ok){
122
+ this.refundReason = '';
123
+ this.notice = 'Refund issued. It can take a few days to appear on the customer’s statement.';
124
+ }
125
+ };
126
+
127
+ /*
128
+ Rendering
129
+ */
130
+
131
+ #renderActions(){
132
+ const payment = this.payment;
133
+
134
+ return html`
135
+ <div class="actions d-f">
136
+ ${payment.status === 'requires_capture' ? html`
137
+ <k-permission has="payments:capture">
138
+ <button class="success" ?disabled=${!!this.busy} @click=${this.capture}>
139
+ Capture ${formatAmount(payment.amount, payment.currency)}
140
+ </button>
141
+ </k-permission>
142
+ ` : ''}
143
+
144
+ ${['succeeded', 'canceled', 'failed'].includes(payment.status) ? '' : html`
145
+ <k-permission has="payments:capture">
146
+ <button ?disabled=${!!this.busy} @click=${this.cancel}>Cancel</button>
147
+ </k-permission>
148
+ `}
149
+
150
+ <k-permission has="payments:view">
151
+ <button class="secondary" ?disabled=${!!this.busy} @click=${this.sync}>
152
+ <k-icon name="replay"></k-icon> Check with ${payment.provider}
153
+ </button>
154
+ </k-permission>
155
+
156
+ ${this.busy ? html`<k-spinner></k-spinner>` : ''}
157
+ </div>
158
+ `;
159
+ }
160
+
161
+ #renderRefund(){
162
+ const payment = this.payment;
163
+ if(!this.refundable) return '';
164
+
165
+ return html`
166
+ <k-permission has="payments:refund">
167
+ <div class="card refund">
168
+ <h3>Refund</h3>
169
+ <p class="small tc-muted">
170
+ ${formatAmount(this.refundable, payment.currency)} of
171
+ ${formatAmount(payment.amountCaptured, payment.currency)} is still refundable.
172
+ </p>
173
+
174
+ <label>
175
+ <span class="small tc-muted">Amount (${payment.currency.toUpperCase()})</span>
176
+ <input
177
+ type="number"
178
+ step="any"
179
+ min="0"
180
+ max=${fromMinor(this.refundable, payment.currency)}
181
+ .value=${this.refundAmount}
182
+ @input=${event => { this.refundAmount = event.target.value; }}
183
+ />
184
+ </label>
185
+
186
+ <label>
187
+ <span class="small tc-muted">Reason (kept on the record here; shown to nobody outside)</span>
188
+ <input
189
+ type="text"
190
+ placeholder="requested_by_customer, duplicate, fraudulent, or anything you like"
191
+ .value=${this.refundReason}
192
+ @input=${event => { this.refundReason = event.target.value; }}
193
+ />
194
+ </label>
195
+
196
+ <button class="danger" ?disabled=${!!this.busy} @click=${this.refund}>Refund</button>
197
+ </div>
198
+ </k-permission>
199
+ `;
200
+ }
201
+
202
+ #renderEvents(){
203
+ if(!this.events.length){
204
+ return html`
205
+ <p class="small tc-muted">
206
+ Nothing has been heard from the processor about this payment.
207
+ ${this.payment.status === 'succeeded' ? '' : html`
208
+ That is expected for a payment nobody has completed yet — but if the customer says they
209
+ paid, it means the webhook endpoint is not reaching this site. Check it on the Settings tab.
210
+ `}
211
+ </p>
212
+ `;
213
+ }
214
+
215
+ return html`
216
+ <table>
217
+ <thead><tr><th>Event</th><th>Received</th><th></th></tr></thead>
218
+ <tbody>
219
+ ${this.events.map(event => html`
220
+ <tr>
221
+ <td class="ff-mono small">${event.type}</td>
222
+ <td class="small tc-muted">${new Date(event.createdAt).toLocaleString()}</td>
223
+ <td class="small ${event.error ? 'tc-warning' : 'tc-muted'}">${event.error || (event.handledAt ? 'Handled' : 'Queued')}</td>
224
+ </tr>
225
+ `)}
226
+ </tbody>
227
+ </table>
228
+ `;
229
+ }
230
+
231
+ render(){
232
+ if(this.error && !this.payment) return html`<p class="tc-danger">${this.error}</p>`;
233
+ if(!this.payment) return html`<k-spinner></k-spinner>`;
234
+
235
+ const payment = this.payment;
236
+
237
+ return html`
238
+ <div class="head d-f">
239
+ <div>
240
+ <span class="amount">${formatAmount(payment.amount, payment.currency)}</span>
241
+ <span class="badge tc-${statusTone(payment.status)}">${statusLabel(payment.status)}</span>
242
+ ${payment.disputed ? html`<span class="badge tc-danger"><k-icon name="warning"></k-icon> Disputed</span>` : ''}
243
+ ${payment.livemode ? '' : html`<span class="badge tc-muted">Test</span>`}
244
+ </div>
245
+ </div>
246
+
247
+ ${payment.description ? html`<p>${payment.description}</p>` : ''}
248
+ ${payment.lastError ? html`<p class="tc-danger small">${payment.lastError}</p>` : ''}
249
+ ${this.error ? html`<p class="tc-danger small">${this.error}</p>` : ''}
250
+ ${this.notice ? html`<p class="tc-muted small">${this.notice}</p>` : ''}
251
+
252
+ ${this.#renderActions()}
253
+
254
+ <dl class="facts">
255
+ <dt>Processor</dt><dd>${payment.provider}</dd>
256
+ <dt>Reference</dt><dd class="ff-mono small">${payment.providerRef}</dd>
257
+ <dt>Captured</dt><dd>${formatAmount(payment.amountCaptured, payment.currency)}</dd>
258
+ <dt>Refunded</dt><dd>${formatAmount(payment.amountRefunded, payment.currency)}</dd>
259
+ <dt>Capture</dt><dd>${payment.captureMethod === 'manual' ? 'Manual — must be captured' : 'Automatic'}</dd>
260
+ ${payment.owner ? html`<dt>Requested by</dt><dd>${payment.owner}${payment.reference ? ` · ${payment.reference}` : ''}</dd>` : ''}
261
+ ${payment.customerEmail ? html`<dt>Email</dt><dd>${payment.customerEmail}</dd>` : ''}
262
+ <dt>Started</dt><dd>${new Date(payment.createdAt).toLocaleString()}</dd>
263
+ </dl>
264
+
265
+ ${this.#renderRefund()}
266
+
267
+ ${this.refunds.length ? html`
268
+ <h3>Refunds</h3>
269
+ <table>
270
+ <thead><tr><th>Amount</th><th>Status</th><th>Reason</th><th>When</th></tr></thead>
271
+ <tbody>
272
+ ${this.refunds.map(refund => html`
273
+ <tr>
274
+ <td>${formatAmount(refund.amount, refund.currency)}</td>
275
+ <td class="small">${refund.status}</td>
276
+ <td class="small tc-muted">${refund.reason || '—'}</td>
277
+ <td class="small tc-muted">${new Date(refund.createdAt).toLocaleString()}</td>
278
+ </tr>
279
+ `)}
280
+ </tbody>
281
+ </table>
282
+ ` : ''}
283
+
284
+ <h3>What the processor has told us</h3>
285
+ ${this.#renderEvents()}
286
+ `;
287
+ }
288
+
289
+ static styles = css`
290
+ :host { display: block; }
291
+ .head { align-items: baseline; justify-content: space-between; }
292
+ .amount { font-size: 1.6rem; font-weight: 600; margin-right: var(--spacer_h); }
293
+ .badge { font-size: var(--fs_small); margin-right: var(--spacer_h); white-space: nowrap; }
294
+ .actions { align-items: center; }
295
+ .actions > * { margin-right: var(--spacer_h); margin-bottom: var(--spacer_h); }
296
+ .facts { display: grid; grid-template-columns: max-content 1fr; column-gap: var(--spacer); row-gap: var(--spacer_q); margin: var(--spacer) 0; }
297
+ .facts dt { color: var(--tc_muted); font-size: var(--fs_small); }
298
+ .facts dd { margin: 0; }
299
+ .refund label { margin-bottom: var(--spacer_h); }
300
+ .refund input { max-width: 24rem; }
301
+ `;
302
+ }
303
+
304
+ customElements.define('k-pay-detail', PaymentDetail);
@@ -0,0 +1,261 @@
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 '/admin/extension/kempo-payments/components/PaymentDetail.js';
5
+ import { html, css } from '/kempo-ui/lit-all.min.js';
6
+ import { listPayments, getSummary } from '/payments/sdk.js';
7
+ import { formatAmount, statusLabel, statusTone } from '/payments/utils/money.js';
8
+
9
+ /*
10
+ Every payment the site has taken, and one of them at a time in detail.
11
+
12
+ The list and the detail are the same component rather than two screens because the only reason to
13
+ open a payment is something that just happened to it — a refund to issue, an authorisation to
14
+ capture — and coming back to a list that has not noticed is how somebody refunds the same payment
15
+ twice. Selecting is local state, so going back is instant and the filters are exactly as they
16
+ were left.
17
+ */
18
+
19
+ const FILTERS = [
20
+ { value: '', label: 'All payments' },
21
+ { value: 'succeeded', label: 'Paid' },
22
+ { value: 'requires_capture', label: 'Awaiting capture' },
23
+ { value: 'requires_payment_method,requires_confirmation,requires_action,processing', label: 'In progress' },
24
+ { value: 'canceled,failed', label: 'Cancelled and failed' },
25
+ ];
26
+
27
+ const PAGE_SIZE = 25;
28
+
29
+ export default class PaymentList extends ShadowComponent {
30
+ static properties = {
31
+ payments: { type: Array, state: true },
32
+ total: { type: Number, state: true },
33
+ offset: { type: Number, state: true },
34
+ filter: { type: String, state: true },
35
+ summary: { type: Object, state: true },
36
+ selected: { type: String, state: true },
37
+ loading: { type: Boolean, state: true },
38
+ error: { type: String, state: true },
39
+ };
40
+
41
+ constructor(){
42
+ super();
43
+ this.payments = [];
44
+ this.total = 0;
45
+ this.offset = 0;
46
+ this.filter = '';
47
+ this.summary = null;
48
+ this.selected = '';
49
+ this.loading = true;
50
+ this.error = '';
51
+ }
52
+
53
+ /*
54
+ Lifecycle
55
+ */
56
+
57
+ connectedCallback(){
58
+ super.connectedCallback();
59
+ this.load();
60
+ }
61
+
62
+ /*
63
+ Data
64
+ */
65
+
66
+ load = async () => {
67
+ this.loading = true;
68
+ this.error = '';
69
+
70
+ const [error, data] = await listPayments({
71
+ status: this.filter || undefined,
72
+ limit: PAGE_SIZE,
73
+ offset: this.offset,
74
+ });
75
+
76
+ this.loading = false;
77
+
78
+ if(error){
79
+ this.error = error.msg;
80
+ return;
81
+ }
82
+
83
+ this.payments = data.payments;
84
+ this.total = data.total;
85
+
86
+ const [, summary] = await getSummary();
87
+ this.summary = summary || null;
88
+ };
89
+
90
+ /*
91
+ A refund or a capture changes both the row and the totals above it, so the whole screen reloads
92
+ rather than the one row being patched — there is one payment on screen at that moment and the
93
+ request is cheap.
94
+ */
95
+ refresh = () => this.load();
96
+
97
+ changeFilter = event => {
98
+ this.filter = event.target.value;
99
+ this.offset = 0;
100
+ this.load();
101
+ };
102
+
103
+ page = delta => {
104
+ this.offset = Math.max(0, this.offset + delta * PAGE_SIZE);
105
+ this.load();
106
+ };
107
+
108
+ /*
109
+ Rendering
110
+ */
111
+
112
+ #renderSummary(){
113
+ if(!this.summary?.byStatus?.length) return '';
114
+
115
+ /*
116
+ Totals are per currency. Adding them together would be arithmetic on things that are not the
117
+ same unit, and the number it produced would look entirely plausible.
118
+ */
119
+ const totals = new Map();
120
+ for(const row of this.summary.byStatus){
121
+ if(row.status !== 'succeeded') continue;
122
+ const current = totals.get(row.currency) || { captured: 0, refunded: 0, count: 0 };
123
+ totals.set(row.currency, {
124
+ captured: current.captured + row.captured,
125
+ refunded: current.refunded + row.refunded,
126
+ count: current.count + row.count,
127
+ });
128
+ }
129
+
130
+ const awaiting = this.summary.byStatus
131
+ .filter(row => row.status === 'requires_capture')
132
+ .reduce((sum, row) => sum + row.count, 0);
133
+
134
+ return html`
135
+ <div class="tiles d-f">
136
+ ${[...totals.entries()].map(([currency, total]) => html`
137
+ <div class="tile card">
138
+ <span class="small tc-muted">Taken in ${currency.toUpperCase()}</span>
139
+ <span class="value">${formatAmount(total.captured - total.refunded, currency)}</span>
140
+ <span class="small tc-muted">
141
+ ${total.count} payment${total.count === 1 ? '' : 's'}${total.refunded ? html` · ${formatAmount(total.refunded, currency)} refunded` : ''}
142
+ </span>
143
+ </div>
144
+ `)}
145
+
146
+ ${awaiting ? html`
147
+ <div class="tile card">
148
+ <span class="small tc-muted">Awaiting capture</span>
149
+ <span class="value tc-warning">${awaiting}</span>
150
+ <span class="small tc-muted">Holds that expire if nobody captures them</span>
151
+ </div>
152
+ ` : ''}
153
+
154
+ ${this.summary.disputed ? html`
155
+ <div class="tile card">
156
+ <span class="small tc-muted">Disputed</span>
157
+ <span class="value tc-danger">${this.summary.disputed}</span>
158
+ <span class="small tc-muted">Answer these in the processor’s dashboard</span>
159
+ </div>
160
+ ` : ''}
161
+ </div>
162
+ `;
163
+ }
164
+
165
+ #renderTable(){
166
+ if(this.loading && !this.payments.length) return html`<k-spinner></k-spinner>`;
167
+
168
+ if(!this.payments.length){
169
+ return html`
170
+ <p class="tc-muted">
171
+ ${this.filter ? 'No payments match that filter.' : 'No payments yet. Take one from the “Take a payment” tab to check the connection end to end.'}
172
+ </p>
173
+ `;
174
+ }
175
+
176
+ return html`
177
+ <div class="table-wrapper">
178
+ <table>
179
+ <thead>
180
+ <tr><th>Amount</th><th>Status</th><th>For</th><th>Started</th><th></th></tr>
181
+ </thead>
182
+ <tbody>
183
+ ${this.payments.map(payment => html`
184
+ <tr @click=${() => { this.selected = payment.id; }}>
185
+ <td>
186
+ ${formatAmount(payment.amount, payment.currency)}
187
+ ${payment.amountRefunded ? html`<span class="small tc-muted d-b">−${formatAmount(payment.amountRefunded, payment.currency)} refunded</span>` : ''}
188
+ </td>
189
+ <td>
190
+ <span class="tc-${statusTone(payment.status)}">${statusLabel(payment.status)}</span>
191
+ ${payment.disputed ? html`<span class="small tc-danger d-b">Disputed</span>` : ''}
192
+ ${payment.livemode ? '' : html`<span class="small tc-muted d-b">Test</span>`}
193
+ </td>
194
+ <td class="small">${payment.description || payment.reference || '—'}</td>
195
+ <td class="small tc-muted">${new Date(payment.createdAt).toLocaleString()}</td>
196
+ <td class="small"><button class="link">Open</button></td>
197
+ </tr>
198
+ `)}
199
+ </tbody>
200
+ </table>
201
+ </div>
202
+
203
+ ${this.total > PAGE_SIZE ? html`
204
+ <div class="pager d-f">
205
+ <button ?disabled=${this.offset === 0} @click=${() => this.page(-1)}>Newer</button>
206
+ <span class="small tc-muted">${this.offset + 1}–${Math.min(this.offset + PAGE_SIZE, this.total)} of ${this.total}</span>
207
+ <button ?disabled=${this.offset + PAGE_SIZE >= this.total} @click=${() => this.page(1)}>Older</button>
208
+ </div>
209
+ ` : ''}
210
+ `;
211
+ }
212
+
213
+ render(){
214
+ if(this.selected){
215
+ return html`
216
+ <button class="link mb" @click=${() => { this.selected = ''; this.load(); }}>
217
+ <k-icon name="arrow" direction="left"></k-icon> All payments
218
+ </button>
219
+ <k-pay-detail payment-id=${this.selected} @changed=${this.refresh}></k-pay-detail>
220
+ `;
221
+ }
222
+
223
+ return html`
224
+ ${this.#renderSummary()}
225
+
226
+ <div class="controls d-f">
227
+ <select .value=${this.filter} @change=${this.changeFilter}>
228
+ ${FILTERS.map(option => html`<option value=${option.value} ?selected=${option.value === this.filter}>${option.label}</option>`)}
229
+ </select>
230
+ <button class="secondary" @click=${this.load}><k-icon name="replay"></k-icon> Refresh</button>
231
+ ${this.loading ? html`<k-spinner></k-spinner>` : ''}
232
+ </div>
233
+
234
+ ${this.error ? html`<p class="tc-danger">${this.error}</p>` : ''}
235
+
236
+ ${this.#renderTable()}
237
+ `;
238
+ }
239
+
240
+ static styles = css`
241
+ :host { display: block; }
242
+ .tiles > .tile { margin-right: var(--spacer); min-width: 12rem; }
243
+ .tile { display: flex; flex-direction: column; }
244
+ .tile .value { font-size: 1.6rem; line-height: 1.2; font-weight: 600; }
245
+ .controls { align-items: center; margin-bottom: var(--spacer); }
246
+ .controls > * { margin-right: var(--spacer_h); }
247
+ .controls select { margin-bottom: 0; max-width: 18rem; }
248
+ /*
249
+ Styled by element rather than a class, after a class named "row" turned every table row into
250
+ a flex container: kempo-css owns .row as its 12-column grid, so display:flex landed on the
251
+ tr and detached the body cells from the header entirely. Nothing on a table element should
252
+ carry a layout class from a framework that also lays out grids.
253
+ */
254
+ tbody tr { cursor: pointer; }
255
+ tbody tr:hover { background: var(--c_bg__alt); }
256
+ .pager { align-items: center; justify-content: flex-start; }
257
+ .pager > * { margin-right: var(--spacer_h); }
258
+ `;
259
+ }
260
+
261
+ customElements.define('k-pay-list', PaymentList);