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,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);
|