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,179 @@
|
|
|
1
|
+
import { triggerHook } from 'kempo/server/sdk.js';
|
|
2
|
+
import { providerForPayment } from '../../providers/index.js';
|
|
3
|
+
import { getPayment, recordRefund, refundedTotal, updatePayment } from './store.js';
|
|
4
|
+
import { stripNulBytes } from './text.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Where a payment's row is brought into line with what the processor says, and where the hooks
|
|
8
|
+
other extensions listen to are fired.
|
|
9
|
+
|
|
10
|
+
Everything that can change a payment — the admin capturing one, a webhook arriving, a page asking
|
|
11
|
+
for the current state — funnels through here rather than writing the row itself. That is what
|
|
12
|
+
makes "did the order get fulfilled?" answerable: there is exactly one place a payment can become
|
|
13
|
+
`succeeded`, and it is the same place that fires `payment:succeeded`.
|
|
14
|
+
|
|
15
|
+
Hooks fire only on a real transition. A processor that redelivers the same event ten times, or an
|
|
16
|
+
admin screen that polls, produces no changes and therefore no hooks — which matters because the
|
|
17
|
+
handler on the other end sends an email, issues a licence key or ships a box.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
The events this extension fires. Consumers register handlers for them in their own
|
|
22
|
+
kempo-config.json — see the README.
|
|
23
|
+
|
|
24
|
+
`payment:succeeded` is the one to hang fulfilment off. It fires once per payment, when the money
|
|
25
|
+
has actually been taken, whether that was automatic or a capture somebody performed by hand.
|
|
26
|
+
*/
|
|
27
|
+
export const EVENTS = [
|
|
28
|
+
'payment:created', // { payment } — an attempt has been started; no money has moved
|
|
29
|
+
'payment:authorized', // { payment } — held on the customer's card, waiting to be captured
|
|
30
|
+
'payment:succeeded', // { payment } — taken
|
|
31
|
+
'payment:failed', // { payment, error } — the processor refused it
|
|
32
|
+
'payment:canceled', // { payment } — abandoned, or the hold released
|
|
33
|
+
'payment:refunded', // { payment, refund } — some or all of it given back
|
|
34
|
+
'payment:disputed', // { payment, disputed, reason } — a chargeback opened, or closed
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const fire = async (event, data) => {
|
|
38
|
+
const [error] = await triggerHook(event, data);
|
|
39
|
+
if(error) console.warn(`[kempo-payments] Could not fire ${event}: ${error.msg}`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
Persists a provider's view of a payment and fires whatever that change means.
|
|
44
|
+
|
|
45
|
+
`state` is the normalised shape a provider module returns. Fields it leaves undefined are fields
|
|
46
|
+
the provider had no answer for on this call, and they are left alone rather than zeroed — an
|
|
47
|
+
unexpanded charge reports no refund total, and writing 0 over a real one would erase a refund
|
|
48
|
+
from the record without erasing it from the customer's statement.
|
|
49
|
+
*/
|
|
50
|
+
export const applyProviderState = async (payment, state, { failed = false } = {}) => {
|
|
51
|
+
const changes = {};
|
|
52
|
+
|
|
53
|
+
if(state.status && state.status !== payment.status) changes.status = state.status;
|
|
54
|
+
if(Number.isInteger(state.amountCaptured) && state.amountCaptured !== payment.amountCaptured){
|
|
55
|
+
changes.amountCaptured = state.amountCaptured;
|
|
56
|
+
}
|
|
57
|
+
if(Number.isInteger(state.amountRefunded) && state.amountRefunded !== payment.amountRefunded){
|
|
58
|
+
changes.amountRefunded = state.amountRefunded;
|
|
59
|
+
}
|
|
60
|
+
if(typeof state.livemode === 'boolean' && state.livemode !== payment.livemode) changes.livemode = state.livemode;
|
|
61
|
+
if(state.captureMethod && state.captureMethod !== payment.captureMethod) changes.captureMethod = state.captureMethod;
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
A decline message is kept until something works, then cleared. A payment that eventually
|
|
65
|
+
succeeded on the third card is not a failed payment, and leaving the first card's message on it
|
|
66
|
+
makes every screen showing the payment say otherwise.
|
|
67
|
+
*/
|
|
68
|
+
const nextError = stripNulBytes(failed ? (state.lastError || 'The payment failed') : state.lastError ?? null);
|
|
69
|
+
if((nextError || null) !== (payment.lastError || null)) changes.lastError = nextError || null;
|
|
70
|
+
|
|
71
|
+
if(!Object.keys(changes).length) return [null, { payment, changed: false }];
|
|
72
|
+
|
|
73
|
+
const [error, updated] = await updatePayment(payment.id, changes);
|
|
74
|
+
if(error) return [error, null];
|
|
75
|
+
|
|
76
|
+
if(failed) await fire('payment:failed', { payment: updated, error: updated.lastError });
|
|
77
|
+
|
|
78
|
+
if(changes.status){
|
|
79
|
+
if(changes.status === 'succeeded') await fire('payment:succeeded', { payment: updated });
|
|
80
|
+
if(changes.status === 'requires_capture') await fire('payment:authorized', { payment: updated });
|
|
81
|
+
if(changes.status === 'canceled') await fire('payment:canceled', { payment: updated });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [null, { payment: updated, changed: true }];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
Records refunds the provider reported and rolls the payment's refunded total up from them.
|
|
89
|
+
|
|
90
|
+
The total is recomputed from the refund rows rather than added to, so an admin refund and the
|
|
91
|
+
webhook that reports the same refund a second later cannot between them claim twice the money was
|
|
92
|
+
returned.
|
|
93
|
+
*/
|
|
94
|
+
export const applyRefunds = async (payment, refunds, { issuedBy } = {}) => {
|
|
95
|
+
const recorded = [];
|
|
96
|
+
|
|
97
|
+
for(const refund of refunds || []){
|
|
98
|
+
if(!refund?.ref) continue;
|
|
99
|
+
|
|
100
|
+
const [error, row] = await recordRefund({
|
|
101
|
+
paymentId: payment.id,
|
|
102
|
+
provider: payment.provider,
|
|
103
|
+
providerRef: refund.ref,
|
|
104
|
+
amount: refund.amount,
|
|
105
|
+
currency: refund.currency || payment.currency,
|
|
106
|
+
status: refund.status || 'pending',
|
|
107
|
+
reason: stripNulBytes(refund.reason),
|
|
108
|
+
issuedBy,
|
|
109
|
+
});
|
|
110
|
+
if(error) return [error, null];
|
|
111
|
+
recorded.push(row);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [totalError, total] = await refundedTotal(payment.id);
|
|
115
|
+
if(totalError) return [totalError, null];
|
|
116
|
+
|
|
117
|
+
let current = payment;
|
|
118
|
+
if(total !== payment.amountRefunded){
|
|
119
|
+
const [error, updated] = await updatePayment(payment.id, { amountRefunded: total });
|
|
120
|
+
if(error) return [error, null];
|
|
121
|
+
current = updated;
|
|
122
|
+
|
|
123
|
+
for(const refund of recorded){
|
|
124
|
+
await fire('payment:refunded', { payment: current, refund });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return [null, { payment: current, refunds: recorded }];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const applyDispute = async (payment, { disputed, reason }) => {
|
|
132
|
+
if(payment.disputed === disputed) return [null, { payment, changed: false }];
|
|
133
|
+
|
|
134
|
+
const [error, updated] = await updatePayment(payment.id, { disputed });
|
|
135
|
+
if(error) return [error, null];
|
|
136
|
+
|
|
137
|
+
await fire('payment:disputed', { payment: updated, disputed, reason: reason || null });
|
|
138
|
+
return [null, { payment: updated, changed: true }];
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const firePaymentCreated = payment => fire('payment:created', { payment });
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
Asks the processor what it thinks and writes the answer down.
|
|
145
|
+
|
|
146
|
+
The webhook is the authority in normal operation — this is for the cases where it is not: a
|
|
147
|
+
webhook that has not arrived yet, a site whose signing secret was wrong for a week, a payment
|
|
148
|
+
somebody is looking at right now and wants the truth about. Safe to call as often as wanted;
|
|
149
|
+
it only writes when something actually differs.
|
|
150
|
+
*/
|
|
151
|
+
export const syncPayment = async id => {
|
|
152
|
+
const [loadError, payment] = await getPayment(id);
|
|
153
|
+
if(loadError) return [loadError, null];
|
|
154
|
+
|
|
155
|
+
const [providerError, provider] = providerForPayment(payment);
|
|
156
|
+
if(providerError) return [providerError, null];
|
|
157
|
+
|
|
158
|
+
const [fetchError, state] = await provider.retrieveIntent(payment.providerRef);
|
|
159
|
+
if(fetchError) return [fetchError, null];
|
|
160
|
+
|
|
161
|
+
if(!provider.listRefunds) return applyProviderState(payment, state);
|
|
162
|
+
|
|
163
|
+
/*
|
|
164
|
+
Refunds are reconciled from the refunds themselves, not from the charge's running total. Both
|
|
165
|
+
would agree, but the total alone leaves a refund with no row, and the next refund's recompute
|
|
166
|
+
from rows would then quietly erase it. The total is also held back from `applyProviderState`
|
|
167
|
+
so that `applyRefunds` sees the change and fires `payment:refunded` for it.
|
|
168
|
+
*/
|
|
169
|
+
const [refundsError, refunds] = await provider.listRefunds(payment.providerRef);
|
|
170
|
+
if(refundsError) return [refundsError, null];
|
|
171
|
+
|
|
172
|
+
const [stateError, applied] = await applyProviderState(payment, { ...state, amountRefunded: undefined });
|
|
173
|
+
if(stateError) return [stateError, null];
|
|
174
|
+
|
|
175
|
+
const [applyError, result] = await applyRefunds(applied.payment, refunds);
|
|
176
|
+
if(applyError) return [applyError, null];
|
|
177
|
+
|
|
178
|
+
return [null, { payment: result.payment, changed: applied.changed || result.payment.amountRefunded !== payment.amountRefunded }];
|
|
179
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Postgres cannot store a NUL byte in a text or jsonb value, so one anywhere in what is about to be
|
|
3
|
+
written can only end in a failed insert. Found by testing, twice over:
|
|
4
|
+
|
|
5
|
+
- From a caller. On a payment that meant a processor intent was created first and cancelled again
|
|
6
|
+
once the insert failed; on a refund it was worse — the processor had already moved the money
|
|
7
|
+
when the insert failed, and the caller got a 500 for a refund that had happened. So what a
|
|
8
|
+
caller hands over is checked up front (`hasNulByte`) and refused as a 400 before any provider
|
|
9
|
+
is contacted.
|
|
10
|
+
- From the processor. That same refund's reason came back on the webhook, and could not be
|
|
11
|
+
recorded either. There is nothing to refuse there — the money has moved — so what the processor
|
|
12
|
+
sends is cleaned (`stripNulBytes`) rather than rejected.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const hasNulByte = (...values) => {
|
|
16
|
+
let found = false;
|
|
17
|
+
|
|
18
|
+
for(const value of values){
|
|
19
|
+
JSON.stringify(value ?? null, (key, entry) => {
|
|
20
|
+
if(typeof entry === 'string' && entry.includes('\u0000')) found = true;
|
|
21
|
+
return entry;
|
|
22
|
+
});
|
|
23
|
+
if(found) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
Works on a string or on anything JSON-shaped, so a whole webhook event can be cleaned before it is
|
|
30
|
+
stored: the event is kept verbatim as a jsonb log entry, and one NUL anywhere inside it fails
|
|
31
|
+
that insert and with it the whole delivery.
|
|
32
|
+
*/
|
|
33
|
+
export const stripNulBytes = value => {
|
|
34
|
+
const clean = entry => typeof entry === 'string' ? entry.replaceAll('\u0000', '') : entry;
|
|
35
|
+
|
|
36
|
+
if(value && typeof value === 'object') return JSON.parse(JSON.stringify(value, (key, entry) => clean(entry)));
|
|
37
|
+
return clean(value);
|
|
38
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getSession, currentUserHasPermission } from 'kempo/server/sdk.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The same shape kempo-thumbs and kempo-user-dirs use, for the same reason: a permission check
|
|
5
|
+
written out longhand in every route is one that eventually has a clause wrong somewhere.
|
|
6
|
+
|
|
7
|
+
Every route in this extension is gated except two, and both are deliberate: the webhook endpoint
|
|
8
|
+
(authenticated by the provider's signature instead, since the caller is Stripe and has no
|
|
9
|
+
session) and the client config endpoint (it returns a publishable key, which is designed to be
|
|
10
|
+
seen by every visitor, and a checkout page has to work for a guest with no account).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const requireSession = async request => {
|
|
14
|
+
const token = request.cookies?.session_token;
|
|
15
|
+
if(!token) return [{ code: 401, msg: 'Authentication required' }, null];
|
|
16
|
+
|
|
17
|
+
const [error, session] = await getSession({ token });
|
|
18
|
+
if(error || !session?.user) return [{ code: 401, msg: 'Authentication required' }, null];
|
|
19
|
+
|
|
20
|
+
return [null, { token, user: session.user }];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const requirePermission = async (token, name) => {
|
|
24
|
+
const [error, allowed] = await currentUserHasPermission(token, name);
|
|
25
|
+
if(error) return [{ code: error.code, msg: error.msg }, null];
|
|
26
|
+
if(!allowed) return [{ code: 403, msg: 'Insufficient permissions' }, null];
|
|
27
|
+
return [null, true];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
The two together, since every gated route in this extension needs both and none of them needs
|
|
32
|
+
them apart.
|
|
33
|
+
*/
|
|
34
|
+
export const gate = async (request, permission) => {
|
|
35
|
+
const [sessionError, session] = await requireSession(request);
|
|
36
|
+
if(sessionError) return [sessionError, null];
|
|
37
|
+
|
|
38
|
+
const [permError] = await requirePermission(session.token, permission);
|
|
39
|
+
if(permError) return [permError, null];
|
|
40
|
+
|
|
41
|
+
return [null, session];
|
|
42
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { desc, eq } from 'drizzle-orm';
|
|
2
|
+
import db from 'kempo/server/db/index.js';
|
|
3
|
+
import { kempoPaymentEvent } from '../../db/schema.js';
|
|
4
|
+
import { getProvider } from '../../providers/index.js';
|
|
5
|
+
import { getPaymentByRef } from '../payments/store.js';
|
|
6
|
+
import { applyDispute, applyProviderState, applyRefunds } from '../payments/sync.js';
|
|
7
|
+
import { stripNulBytes } from '../payments/text.js';
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Where the processor tells us what happened.
|
|
11
|
+
|
|
12
|
+
This is the authority on whether a payment succeeded — not the browser. A customer whose
|
|
13
|
+
connection drops the instant after their card is charged never tells the site anything, and one
|
|
14
|
+
who is determined to can make their browser claim whatever they like. The webhook comes from the
|
|
15
|
+
processor, signed, and it arrives whether anybody's browser is still open or not.
|
|
16
|
+
|
|
17
|
+
Three properties this endpoint has to have, in order of how expensive they are to get wrong:
|
|
18
|
+
|
|
19
|
+
1. **Verified.** An unsigned request that reaches this endpoint could mark any payment paid. The
|
|
20
|
+
signature check happens before the body is even looked at.
|
|
21
|
+
2. **Idempotent.** Processors retry until they get a 2xx and may redeliver after one. Claiming a
|
|
22
|
+
row in `kempoPaymentEvent` — whose primary key is the processor's own event id — is what makes
|
|
23
|
+
the second delivery a no-op instead of a second order.
|
|
24
|
+
3. **Forgiving about what it does not recognise.** An unknown event type is recorded and answered
|
|
25
|
+
200. Returning an error would make the processor retry an event nothing here will ever act on.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const claimEvent = async ({ provider, eventId, type, payload }) => {
|
|
29
|
+
const id = `${provider}:${eventId}`;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const [row] = await db.insert(kempoPaymentEvent).values({
|
|
33
|
+
id,
|
|
34
|
+
provider,
|
|
35
|
+
providerEventId: eventId,
|
|
36
|
+
type,
|
|
37
|
+
payload,
|
|
38
|
+
createdAt: new Date(),
|
|
39
|
+
}).onConflictDoNothing().returning();
|
|
40
|
+
|
|
41
|
+
// No row back means the primary key was already taken: we have seen this event before.
|
|
42
|
+
return [null, row || null];
|
|
43
|
+
} catch {
|
|
44
|
+
return [{ code: 500, msg: 'Could not record the event' }, null];
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const finish = async (id, changes) => {
|
|
49
|
+
try {
|
|
50
|
+
await db.update(kempoPaymentEvent).set({ handledAt: new Date(), ...changes }).where(eq(kempoPaymentEvent.id, id));
|
|
51
|
+
} catch {
|
|
52
|
+
// The work itself already happened; failing to annotate the log entry is not worth failing the
|
|
53
|
+
// webhook over, because a non-2xx would make the processor redeliver work that is already done.
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const release = async id => {
|
|
58
|
+
try {
|
|
59
|
+
await db.delete(kempoPaymentEvent).where(eq(kempoPaymentEvent.id, id));
|
|
60
|
+
} catch {
|
|
61
|
+
// Nothing more to do: the caller is already returning the failure that made this necessary.
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const receiveWebhook = async ({ provider: providerName, payload, signature }) => {
|
|
66
|
+
const [providerError, provider] = getProvider(providerName);
|
|
67
|
+
if(providerError) return [providerError, null];
|
|
68
|
+
|
|
69
|
+
const [verifyError, event] = await provider.verifyEvent({ payload, signature });
|
|
70
|
+
if(verifyError) return [verifyError, null];
|
|
71
|
+
|
|
72
|
+
const [claimError, claimed] = await claimEvent({
|
|
73
|
+
provider: provider.name,
|
|
74
|
+
eventId: event.id,
|
|
75
|
+
type: event.type,
|
|
76
|
+
payload: stripNulBytes(event),
|
|
77
|
+
});
|
|
78
|
+
if(claimError) return [claimError, null];
|
|
79
|
+
if(!claimed) return [null, { duplicate: true, type: event.type }];
|
|
80
|
+
|
|
81
|
+
const interpreted = provider.interpretEvent(event);
|
|
82
|
+
|
|
83
|
+
if(interpreted.kind === 'ignored'){
|
|
84
|
+
await finish(claimed.id, {});
|
|
85
|
+
return [null, { handled: false, type: event.type }];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
An event about a payment this site has no record of. Normal, and not an error: one Stripe
|
|
90
|
+
account can sit behind a staging site and a production one, and each will be sent the other's
|
|
91
|
+
events. Recorded and acknowledged, so it stops being redelivered.
|
|
92
|
+
*/
|
|
93
|
+
const [lookupError, payment] = await getPaymentByRef(provider.name, interpreted.providerRef);
|
|
94
|
+
if(lookupError){
|
|
95
|
+
await finish(claimed.id, { error: lookupError.code === 404 ? 'No matching payment' : lookupError.msg });
|
|
96
|
+
return [null, { handled: false, type: event.type, unmatched: true }];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let applyError = null;
|
|
100
|
+
|
|
101
|
+
if(interpreted.kind === 'payment'){
|
|
102
|
+
[applyError] = await applyProviderState(payment, interpreted.payment, {
|
|
103
|
+
failed: event.type.endsWith('payment_failed'),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if(interpreted.kind === 'refund'){
|
|
108
|
+
let refunds = interpreted.refunds;
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
An event that says a refund happened without saying which one — Stripe's `charge.refunded`
|
|
112
|
+
reports only the new total — is answered by asking the processor, since there is nothing to
|
|
113
|
+
record otherwise. Without this such a refund was "handled" as a no-op and never appeared.
|
|
114
|
+
*/
|
|
115
|
+
if(!refunds.length && provider.listRefunds){
|
|
116
|
+
[applyError, refunds] = await provider.listRefunds(payment.providerRef);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if(!applyError) [applyError] = await applyRefunds(payment, refunds);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if(interpreted.kind === 'dispute'){
|
|
123
|
+
[applyError] = await applyDispute(payment, interpreted);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/*
|
|
127
|
+
Handled means applied. The claim is what turns a redelivery into a no-op, so a claim left behind
|
|
128
|
+
by an event that then failed to apply would make the processor's retry — the very thing the
|
|
129
|
+
non-2xx below asks for — answer "duplicate" and drop it for good, leaving the payment wrong
|
|
130
|
+
until somebody noticed. Found by testing: a refund that could not be recorded stayed
|
|
131
|
+
unrecorded, and its retry was swallowed. Releasing the claim lets the retry do the work.
|
|
132
|
+
*/
|
|
133
|
+
if(applyError){
|
|
134
|
+
await release(claimed.id);
|
|
135
|
+
return [applyError, null];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await finish(claimed.id, { paymentId: payment.id });
|
|
139
|
+
|
|
140
|
+
return [null, { handled: true, type: event.type, paymentId: payment.id }];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const listEvents = async (paymentId, limit = 25) => {
|
|
144
|
+
try {
|
|
145
|
+
const rows = await db.select().from(kempoPaymentEvent)
|
|
146
|
+
.where(eq(kempoPaymentEvent.paymentId, paymentId))
|
|
147
|
+
.orderBy(desc(kempoPaymentEvent.createdAt))
|
|
148
|
+
.limit(Math.min(100, Math.max(1, Number(limit) || 25)));
|
|
149
|
+
return [null, rows];
|
|
150
|
+
} catch {
|
|
151
|
+
return [{ code: 500, msg: 'Could not load the event log' }, null];
|
|
152
|
+
}
|
|
153
|
+
};
|
package/uninstall.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { listPayments } from './server/utils/payments/store.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Nothing here is deleted, and nothing here can be.
|
|
5
|
+
|
|
6
|
+
kempo drops this extension's three tables straight after this runs, which loses the site's record
|
|
7
|
+
of every payment it has ever taken. That record is a copy: the money, the charges and the refunds
|
|
8
|
+
all live at the processor, and they are entirely unaffected by anything happening on this server.
|
|
9
|
+
Reinstalling does not bring the rows back, but the processor's dashboard has never stopped having
|
|
10
|
+
them, and a payment can always be refunded there.
|
|
11
|
+
|
|
12
|
+
What this does is say so, loudly enough that somebody clicking uninstall on a site that has taken
|
|
13
|
+
real money knows what they are about to lose before they lose it — because there is no undo
|
|
14
|
+
anywhere in this stack, and "how much did we take last quarter" is a question that gets asked
|
|
15
|
+
months after the extension was removed.
|
|
16
|
+
|
|
17
|
+
Outstanding authorisations are called out separately. Those are holds on real customers' cards
|
|
18
|
+
that nothing will ever capture or release once this extension is gone; they expire on their own
|
|
19
|
+
after a week or so, but a customer looking at their statement in the meantime sees money missing.
|
|
20
|
+
*/
|
|
21
|
+
export default async () => {
|
|
22
|
+
const [error, data] = await listPayments({ limit: 200 });
|
|
23
|
+
if(error) return;
|
|
24
|
+
|
|
25
|
+
const uncaptured = data.payments.filter(payment => payment.status === 'requires_capture');
|
|
26
|
+
|
|
27
|
+
console.log(`[kempo-payments] Removing. ${data.total} payment record${data.total === 1 ? '' : 's'} will be dropped with the tables — the charges themselves are unaffected and remain in the processor's dashboard.`);
|
|
28
|
+
|
|
29
|
+
if(uncaptured.length){
|
|
30
|
+
console.warn(`[kempo-payments] ${uncaptured.length} payment${uncaptured.length === 1 ? ' is' : 's are'} authorised but not captured. Capture or cancel ${uncaptured.length === 1 ? 'it' : 'them'} in the processor's dashboard — nothing here will be able to once this is gone:`);
|
|
31
|
+
for(const payment of uncaptured){
|
|
32
|
+
console.warn(` ${payment.provider} ${payment.providerRef} — ${payment.amount} ${payment.currency.toUpperCase()}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
package/update.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import install from './install.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Updating is the same job as installing: make sure the indexes exist and say something useful if
|
|
5
|
+
the encryption key is missing. Both statements are `IF NOT EXISTS`, so this is safe from any
|
|
6
|
+
version to any other.
|
|
7
|
+
|
|
8
|
+
New settings, permissions and groups are added by kempo's own declarative diff before this runs,
|
|
9
|
+
and existing values are never overwritten — so a site's API keys, currency and capture method all
|
|
10
|
+
survive an update untouched.
|
|
11
|
+
*/
|
|
12
|
+
export default async () => {
|
|
13
|
+
await install();
|
|
14
|
+
};
|