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,149 @@
|
|
|
1
|
+
import { providerForPayment } from '../../providers/index.js';
|
|
2
|
+
import { formatAmount } from '../money/currencies.js';
|
|
3
|
+
import { hasNulByte } from './text.js';
|
|
4
|
+
import { canCancel, canCapture, refundableAmount } from './statuses.js';
|
|
5
|
+
import { getPayment } from './store.js';
|
|
6
|
+
import { applyProviderState, applyRefunds } from './sync.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
The three things somebody does to a payment after it exists: take the money that was held,
|
|
10
|
+
release it, or give it back.
|
|
11
|
+
|
|
12
|
+
Each of them goes back to the processor the payment was originally made through, not the one the
|
|
13
|
+
site is configured to use now. Money that is sitting at Stripe is not refundable by PayPal, and a
|
|
14
|
+
site that switched processors last month still has last month's payments to answer for.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
Turns an authorisation into a charge.
|
|
19
|
+
|
|
20
|
+
Only meaningful for a manual-capture payment. An automatic one was already charged when the
|
|
21
|
+
customer confirmed it, and asking the processor to capture it returns an error whose wording
|
|
22
|
+
("payment_intent_unexpected_state") explains nothing to the person who pressed the button — so
|
|
23
|
+
the refusal happens here, in words.
|
|
24
|
+
*/
|
|
25
|
+
export const capturePayment = async (id, { amount } = {}) => {
|
|
26
|
+
const [loadError, payment] = await getPayment(id);
|
|
27
|
+
if(loadError) return [loadError, null];
|
|
28
|
+
|
|
29
|
+
if(!canCapture(payment)){
|
|
30
|
+
return [{
|
|
31
|
+
code: 409,
|
|
32
|
+
msg: payment.captureMethod === 'automatic'
|
|
33
|
+
? 'This payment was charged when the customer confirmed it — there is nothing left to capture'
|
|
34
|
+
: `A payment can only be captured while it is awaiting capture, and this one is ${payment.status.replace(/_/g, ' ')}`,
|
|
35
|
+
}, null];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
Capturing less than was authorised is allowed and normal — an order that shipped short. More is
|
|
40
|
+
not: the hold is for a fixed amount and the customer never agreed to anything above it.
|
|
41
|
+
*/
|
|
42
|
+
if(amount !== undefined && amount !== null){
|
|
43
|
+
if(!Number.isInteger(amount) || amount <= 0){
|
|
44
|
+
return [{ code: 400, msg: 'A capture amount must be a whole number of the currency’s smallest unit' }, null];
|
|
45
|
+
}
|
|
46
|
+
if(amount > payment.amount){
|
|
47
|
+
return [{ code: 400, msg: 'Cannot capture more than was authorised' }, null];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [providerError, provider] = providerForPayment(payment);
|
|
52
|
+
if(providerError) return [providerError, null];
|
|
53
|
+
|
|
54
|
+
const [captureError, state] = await provider.capture(payment.providerRef, { amount });
|
|
55
|
+
if(captureError) return [captureError, null];
|
|
56
|
+
|
|
57
|
+
return applyProviderState(payment, state);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
Releases a payment that has not been taken.
|
|
62
|
+
|
|
63
|
+
Not a refund: nothing has moved, so nothing comes back. The hold on the customer's card
|
|
64
|
+
disappears — though their bank may take a few days to stop showing it, which is worth saying on
|
|
65
|
+
screen because it is the single most common "you charged me twice" support ticket.
|
|
66
|
+
*/
|
|
67
|
+
export const cancelPayment = async id => {
|
|
68
|
+
const [loadError, payment] = await getPayment(id);
|
|
69
|
+
if(loadError) return [loadError, null];
|
|
70
|
+
|
|
71
|
+
if(!canCancel(payment)){
|
|
72
|
+
return [{ code: 409, msg: `This payment is already ${payment.status.replace(/_/g, ' ')}` }, null];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const [providerError, provider] = providerForPayment(payment);
|
|
76
|
+
if(providerError) return [providerError, null];
|
|
77
|
+
|
|
78
|
+
const [cancelError, state] = await provider.cancel(payment.providerRef);
|
|
79
|
+
if(cancelError) return [cancelError, null];
|
|
80
|
+
|
|
81
|
+
return applyProviderState(payment, state);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
Gives money back, in full or in part.
|
|
86
|
+
|
|
87
|
+
Omitting the amount refunds everything still outstanding, which is what a "Refund" button should
|
|
88
|
+
do and what most callers want. Passing one refunds exactly that much, and several partial refunds
|
|
89
|
+
can be issued until the outstanding amount reaches zero.
|
|
90
|
+
*/
|
|
91
|
+
export const refundPayment = async (id, { amount, reason, issuedBy } = {}) => {
|
|
92
|
+
// Checked before the processor is asked: once it has refunded, a failed insert is a refund nobody recorded.
|
|
93
|
+
if(hasNulByte(reason, issuedBy)){
|
|
94
|
+
return [{ code: 400, msg: 'A refund reason cannot contain null characters' }, null];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [loadError, payment] = await getPayment(id);
|
|
98
|
+
if(loadError) return [loadError, null];
|
|
99
|
+
|
|
100
|
+
const outstanding = refundableAmount(payment);
|
|
101
|
+
|
|
102
|
+
if(!outstanding){
|
|
103
|
+
return [{
|
|
104
|
+
code: 409,
|
|
105
|
+
msg: payment.status === 'requires_capture'
|
|
106
|
+
? 'Nothing has been charged yet — cancel the payment to release the hold instead'
|
|
107
|
+
: payment.status === 'succeeded'
|
|
108
|
+
? 'This payment has already been refunded in full'
|
|
109
|
+
: `A payment can only be refunded once it has succeeded, and this one is ${payment.status.replace(/_/g, ' ')}`,
|
|
110
|
+
}, null];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const requested = amount === undefined || amount === null ? outstanding : amount;
|
|
114
|
+
|
|
115
|
+
if(!Number.isInteger(requested) || requested <= 0){
|
|
116
|
+
return [{ code: 400, msg: 'A refund amount must be a whole number of the currency’s smallest unit' }, null];
|
|
117
|
+
}
|
|
118
|
+
if(requested > outstanding){
|
|
119
|
+
return [{ code: 400, msg: `Only ${formatAmount(outstanding, payment.currency)} is left to refund on this payment` }, null];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const [providerError, provider] = providerForPayment(payment);
|
|
123
|
+
if(providerError) return [providerError, null];
|
|
124
|
+
|
|
125
|
+
const [refundError, refund] = await provider.refund(payment.providerRef, { amount: requested, reason });
|
|
126
|
+
if(refundError) return [refundError, null];
|
|
127
|
+
|
|
128
|
+
return applyRefunds(payment, [refund], { issuedBy });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/*
|
|
132
|
+
The client secret for a payment that is already in flight, fetched fresh from the processor.
|
|
133
|
+
|
|
134
|
+
A checkout page that was reloaded, or a customer coming back to finish paying, needs this to
|
|
135
|
+
mount the payment form again — and it is deliberately not something the payment row carries
|
|
136
|
+
around, so it is here rather than on every read.
|
|
137
|
+
*/
|
|
138
|
+
export const getClientSecret = async id => {
|
|
139
|
+
const [loadError, payment] = await getPayment(id);
|
|
140
|
+
if(loadError) return [loadError, null];
|
|
141
|
+
|
|
142
|
+
const [providerError, provider] = providerForPayment(payment);
|
|
143
|
+
if(providerError) return [providerError, null];
|
|
144
|
+
|
|
145
|
+
const [fetchError, state] = await provider.retrieveIntent(payment.providerRef);
|
|
146
|
+
if(fetchError) return [fetchError, null];
|
|
147
|
+
|
|
148
|
+
return [null, { payment, clientSecret: state.clientSecret }];
|
|
149
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { activeProvider } from '../../providers/index.js';
|
|
2
|
+
import { isValidAmount, isValidCurrency, normaliseCurrency } from '../money/currencies.js';
|
|
3
|
+
import { CAPTURE_METHODS, parseCaptureMethod, readConfig } from '../config/settings.js';
|
|
4
|
+
import { insertPayment, newId } from './store.js';
|
|
5
|
+
import { firePaymentCreated } from './sync.js';
|
|
6
|
+
import { hasNulByte } from './text.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Starting a payment: ask the processor for an intent, write down what it said, hand back the one
|
|
10
|
+
piece the browser needs.
|
|
11
|
+
|
|
12
|
+
The client secret is returned and never stored. It is short-lived, it is derivable from the
|
|
13
|
+
processor at any time, and a copy in the database is one more place it can leak from for no
|
|
14
|
+
benefit at all — `getClientSecret` re-fetches it when a page needs it again.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const createPayment = async ({
|
|
18
|
+
amount,
|
|
19
|
+
currency,
|
|
20
|
+
description = null,
|
|
21
|
+
metadata = null,
|
|
22
|
+
captureMethod = null,
|
|
23
|
+
owner = null,
|
|
24
|
+
reference = null,
|
|
25
|
+
userId = null,
|
|
26
|
+
customerEmail = null,
|
|
27
|
+
} = {}) => {
|
|
28
|
+
if(hasNulByte(description, metadata, owner, reference, userId, customerEmail)){
|
|
29
|
+
return [{ code: 400, msg: 'Payment details cannot contain null characters' }, null];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
A capture method the caller actually passed is validated, never guessed at — and before any
|
|
34
|
+
provider is contacted. Falling back to the site default here would turn a mistyped "hold this
|
|
35
|
+
card" into "charge this card", which is the one direction this parameter must never fail in.
|
|
36
|
+
*/
|
|
37
|
+
let method = null;
|
|
38
|
+
if(captureMethod){
|
|
39
|
+
method = parseCaptureMethod(captureMethod);
|
|
40
|
+
if(!method) return [{ code: 400, msg: `Capture method must be one of: ${CAPTURE_METHODS.join(', ')}` }, null];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const config = await readConfig();
|
|
44
|
+
method ??= config.captureMethod;
|
|
45
|
+
const code = normaliseCurrency(currency || config.currency);
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
Validated here rather than left to the processor. A caller that passes 12.99 instead of 1299 is
|
|
49
|
+
charging a hundredth of what it meant to, and Stripe would take that instruction happily — it
|
|
50
|
+
is a perfectly valid amount, just not the one anybody intended. Refusing a non-integer is the
|
|
51
|
+
only place that mistake can be caught.
|
|
52
|
+
*/
|
|
53
|
+
if(!isValidCurrency(code)){
|
|
54
|
+
return [{ code: 400, msg: `"${currency}" is not a currency code` }, null];
|
|
55
|
+
}
|
|
56
|
+
if(!Number.isInteger(amount)){
|
|
57
|
+
return [{ code: 400, msg: 'Amount must be a whole number of the currency’s smallest unit — 1299 for $12.99' }, null];
|
|
58
|
+
}
|
|
59
|
+
if(!isValidAmount(amount, code)){
|
|
60
|
+
return [{ code: 400, msg: `${amount} is not a chargeable amount in ${code.toUpperCase()}` }, null];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const [providerError, provider] = await activeProvider();
|
|
64
|
+
if(providerError) return [providerError, null];
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
Our id is generated before the intent and sent as the idempotency key, so a request that times
|
|
68
|
+
out and is retried by the caller lands on the same intent instead of a second one. Two intents
|
|
69
|
+
for one order is two authorisation holds on a real customer's card.
|
|
70
|
+
*/
|
|
71
|
+
const id = newId();
|
|
72
|
+
|
|
73
|
+
const [intentError, intent] = await provider.createIntent({
|
|
74
|
+
amount,
|
|
75
|
+
currency: code,
|
|
76
|
+
description,
|
|
77
|
+
metadata: { ...(metadata || {}), kempoPaymentId: id, ...(owner ? { kempoOwner: owner } : {}), ...(reference ? { kempoReference: reference } : {}) },
|
|
78
|
+
captureMethod: method,
|
|
79
|
+
customerEmail,
|
|
80
|
+
idempotencyKey: id,
|
|
81
|
+
});
|
|
82
|
+
if(intentError) return [intentError, null];
|
|
83
|
+
|
|
84
|
+
const [insertError, payment] = await insertPayment({
|
|
85
|
+
id,
|
|
86
|
+
provider: provider.name,
|
|
87
|
+
providerRef: intent.ref,
|
|
88
|
+
livemode: intent.livemode,
|
|
89
|
+
status: intent.status,
|
|
90
|
+
captureMethod: method,
|
|
91
|
+
amount,
|
|
92
|
+
amountCaptured: intent.amountCaptured,
|
|
93
|
+
currency: code,
|
|
94
|
+
owner,
|
|
95
|
+
reference,
|
|
96
|
+
description,
|
|
97
|
+
metadata,
|
|
98
|
+
userId,
|
|
99
|
+
customerEmail,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
An intent that exists at the processor with no row here is invisible: nothing would ever
|
|
104
|
+
capture it, refund it or release the hold. Cancelling it is best-effort — if that fails too,
|
|
105
|
+
the intent expires on its own, and the id is logged so it can be found by hand in the meantime.
|
|
106
|
+
*/
|
|
107
|
+
if(insertError){
|
|
108
|
+
const [cancelError] = await provider.cancel(intent.ref);
|
|
109
|
+
if(cancelError) console.error(`[kempo-payments] Orphaned ${provider.name} intent ${intent.ref} — could not be recorded or cancelled`);
|
|
110
|
+
return [insertError, null];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await firePaymentCreated(payment);
|
|
114
|
+
|
|
115
|
+
return [null, { payment, clientSecret: intent.clientSecret }];
|
|
116
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The status vocabulary every provider maps onto, so that nothing outside server/providers/ ever
|
|
3
|
+
has to know whose words it is reading.
|
|
4
|
+
|
|
5
|
+
These happen to be Stripe's names, which is not laziness — they are the clearest description of
|
|
6
|
+
the states a card payment actually passes through, and a second processor with fewer states maps
|
|
7
|
+
into a subset rather than needing new ones. What matters is that the mapping lives in the
|
|
8
|
+
provider module and this list is the only thing the rest of the extension matches against.
|
|
9
|
+
*/
|
|
10
|
+
export const STATUSES = [
|
|
11
|
+
'requires_payment_method', // started; nobody has entered a card yet, or the last one was declined
|
|
12
|
+
'requires_confirmation', // a card is attached, waiting to be submitted
|
|
13
|
+
'requires_action', // the bank wants the customer to do something — 3-D Secure, usually
|
|
14
|
+
'processing', // submitted; the processor has not finished
|
|
15
|
+
'requires_capture', // authorised only. The money is held, not taken. Capture or it expires
|
|
16
|
+
'succeeded', // taken
|
|
17
|
+
'canceled', // abandoned or released before capture
|
|
18
|
+
'failed', // the processor gave up on it
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const isStatus = status => STATUSES.includes(status);
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
Nothing more will happen to a payment in one of these states without somebody starting a new one.
|
|
25
|
+
Refunds are not a status change — a fully refunded payment is still `succeeded`, with
|
|
26
|
+
`amountRefunded === amountCaptured`, because the charge did happen and the money did move twice.
|
|
27
|
+
*/
|
|
28
|
+
export const FINAL = new Set(['succeeded', 'canceled', 'failed']);
|
|
29
|
+
|
|
30
|
+
export const isFinal = status => FINAL.has(status);
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
A payment still expecting something from the customer's browser. The distinction the checkout UI
|
|
34
|
+
cares about: these are the states where showing the payment form again is the right thing to do,
|
|
35
|
+
and every other state is one where it is not.
|
|
36
|
+
*/
|
|
37
|
+
export const AWAITING_CUSTOMER = new Set(['requires_payment_method', 'requires_confirmation', 'requires_action']);
|
|
38
|
+
|
|
39
|
+
export const isAwaitingCustomer = status => AWAITING_CUSTOMER.has(status);
|
|
40
|
+
|
|
41
|
+
export const canCapture = payment =>
|
|
42
|
+
payment?.captureMethod === 'manual' && payment?.status === 'requires_capture';
|
|
43
|
+
|
|
44
|
+
export const canCancel = payment =>
|
|
45
|
+
!isFinal(payment?.status);
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
What is left to give back. A payment that was only authorised has nothing to refund — releasing
|
|
49
|
+
the hold is a cancel, not a refund, and offering the wrong one produces a processor error the
|
|
50
|
+
person reading it cannot act on.
|
|
51
|
+
*/
|
|
52
|
+
export const refundableAmount = payment => {
|
|
53
|
+
if(payment?.status !== 'succeeded') return 0;
|
|
54
|
+
return Math.max(0, (payment.amountCaptured || 0) - (payment.amountRefunded || 0));
|
|
55
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
+
import db from 'kempo/server/db/index.js';
|
|
4
|
+
import { kempoPayment, kempoPaymentRefund } from '../../db/schema.js';
|
|
5
|
+
import { hasNulByte } from './text.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Every read and write of this extension's own rows, and nothing else. No provider calls, no
|
|
9
|
+
permission checks, no hooks — those belong to the layers either side, and keeping them out is
|
|
10
|
+
what lets the data-layer test suite exercise all of this against a real database without a Stripe
|
|
11
|
+
account existing anywhere.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const newId = () => crypto.randomBytes(8).toString('hex');
|
|
15
|
+
|
|
16
|
+
export const getPayment = async id => {
|
|
17
|
+
if(!id) return [{ code: 400, msg: 'A payment id is required' }, null];
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const [row] = await db.select().from(kempoPayment).where(eq(kempoPayment.id, id)).limit(1);
|
|
21
|
+
if(!row) return [{ code: 404, msg: 'No such payment' }, null];
|
|
22
|
+
return [null, row];
|
|
23
|
+
} catch {
|
|
24
|
+
return [{ code: 500, msg: 'Could not load the payment' }, null];
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
The lookup a webhook needs: the processor knows its own id for a payment and nothing about ours.
|
|
30
|
+
*/
|
|
31
|
+
export const getPaymentByRef = async (provider, providerRef) => {
|
|
32
|
+
if(!provider || !providerRef) return [{ code: 400, msg: 'A provider and reference are required' }, null];
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const [row] = await db.select().from(kempoPayment)
|
|
36
|
+
.where(and(eq(kempoPayment.provider, provider), eq(kempoPayment.providerRef, providerRef)))
|
|
37
|
+
.limit(1);
|
|
38
|
+
if(!row) return [{ code: 404, msg: 'No such payment' }, null];
|
|
39
|
+
return [null, row];
|
|
40
|
+
} catch {
|
|
41
|
+
return [{ code: 500, msg: 'Could not load the payment' }, null];
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
The lookup a consumer extension needs: it knows its own order number, not a payment id. Newest
|
|
47
|
+
first, because a retried checkout leaves earlier abandoned attempts against the same reference
|
|
48
|
+
and the live one is the last.
|
|
49
|
+
*/
|
|
50
|
+
export const paymentsForReference = async (owner, reference) => {
|
|
51
|
+
if(!owner || !reference) return [{ code: 400, msg: 'An owner and reference are required' }, null];
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const rows = await db.select().from(kempoPayment)
|
|
55
|
+
.where(and(eq(kempoPayment.owner, owner), eq(kempoPayment.reference, reference)))
|
|
56
|
+
.orderBy(desc(kempoPayment.createdAt));
|
|
57
|
+
return [null, rows];
|
|
58
|
+
} catch {
|
|
59
|
+
return [{ code: 500, msg: 'Could not load payments for that reference' }, null];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const insertPayment = async values => {
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const row = {
|
|
66
|
+
id: values.id || newId(),
|
|
67
|
+
provider: values.provider,
|
|
68
|
+
providerRef: values.providerRef,
|
|
69
|
+
livemode: !!values.livemode,
|
|
70
|
+
status: values.status,
|
|
71
|
+
captureMethod: values.captureMethod || 'automatic',
|
|
72
|
+
amount: values.amount,
|
|
73
|
+
amountCaptured: values.amountCaptured || 0,
|
|
74
|
+
amountRefunded: values.amountRefunded || 0,
|
|
75
|
+
currency: values.currency,
|
|
76
|
+
owner: values.owner || null,
|
|
77
|
+
reference: values.reference || null,
|
|
78
|
+
description: values.description || null,
|
|
79
|
+
metadata: values.metadata || null,
|
|
80
|
+
userId: values.userId || null,
|
|
81
|
+
customerEmail: values.customerEmail || null,
|
|
82
|
+
lastError: values.lastError || null,
|
|
83
|
+
disputed: false,
|
|
84
|
+
createdAt: now,
|
|
85
|
+
updatedAt: now,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await db.insert(kempoPayment).values(row);
|
|
90
|
+
return [null, row];
|
|
91
|
+
} catch {
|
|
92
|
+
return [{ code: 500, msg: 'Could not record the payment' }, null];
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const updatePayment = async (id, changes) => {
|
|
97
|
+
try {
|
|
98
|
+
const [row] = await db.update(kempoPayment)
|
|
99
|
+
.set({ ...changes, updatedAt: new Date() })
|
|
100
|
+
.where(eq(kempoPayment.id, id))
|
|
101
|
+
.returning();
|
|
102
|
+
if(!row) return [{ code: 404, msg: 'No such payment' }, null];
|
|
103
|
+
return [null, row];
|
|
104
|
+
} catch {
|
|
105
|
+
return [{ code: 500, msg: 'Could not update the payment' }, null];
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
Filtered, paged, newest first. `status` accepts a list so the admin screen can offer "needs
|
|
111
|
+
attention" as one filter rather than making somebody look at four.
|
|
112
|
+
*/
|
|
113
|
+
export const listPayments = async ({ status, provider, owner, reference, userId, limit = 50, offset = 0 } = {}) => {
|
|
114
|
+
if(hasNulByte(status, provider, owner, reference, userId)){
|
|
115
|
+
return [{ code: 400, msg: 'Filters cannot contain null characters' }, null];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const conditions = [];
|
|
119
|
+
if(status) conditions.push(Array.isArray(status) ? inArray(kempoPayment.status, status) : eq(kempoPayment.status, status));
|
|
120
|
+
if(provider) conditions.push(eq(kempoPayment.provider, provider));
|
|
121
|
+
if(owner) conditions.push(eq(kempoPayment.owner, owner));
|
|
122
|
+
if(reference) conditions.push(eq(kempoPayment.reference, reference));
|
|
123
|
+
if(userId) conditions.push(eq(kempoPayment.userId, userId));
|
|
124
|
+
|
|
125
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
126
|
+
const take = Math.min(200, Math.max(1, Number(limit) || 50));
|
|
127
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const rows = await db.select().from(kempoPayment)
|
|
131
|
+
.where(where)
|
|
132
|
+
.orderBy(desc(kempoPayment.createdAt))
|
|
133
|
+
.limit(take)
|
|
134
|
+
.offset(skip);
|
|
135
|
+
|
|
136
|
+
const [counted] = await db.select({ total: sql`count(*)::int` }).from(kempoPayment).where(where);
|
|
137
|
+
|
|
138
|
+
return [null, { payments: rows, total: counted?.total ?? rows.length, limit: take, offset: skip }];
|
|
139
|
+
} catch {
|
|
140
|
+
return [{ code: 500, msg: 'Could not list payments' }, null];
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
What the admin screen puts across the top. One grouped query rather than one per status, and the
|
|
146
|
+
gross/net pair kept separate on purpose — a day with £1,000 taken and £900 refunded is not the
|
|
147
|
+
same day as one with £100 taken, and a single "total" would show them identically.
|
|
148
|
+
*/
|
|
149
|
+
export const summary = async () => {
|
|
150
|
+
try {
|
|
151
|
+
const rows = await db.select({
|
|
152
|
+
status: kempoPayment.status,
|
|
153
|
+
currency: kempoPayment.currency,
|
|
154
|
+
count: sql`count(*)::int`,
|
|
155
|
+
captured: sql`coalesce(sum(${kempoPayment.amountCaptured}), 0)::int`,
|
|
156
|
+
refunded: sql`coalesce(sum(${kempoPayment.amountRefunded}), 0)::int`,
|
|
157
|
+
})
|
|
158
|
+
.from(kempoPayment)
|
|
159
|
+
.groupBy(kempoPayment.status, kempoPayment.currency);
|
|
160
|
+
|
|
161
|
+
const [disputes] = await db.select({ count: sql`count(*)::int` })
|
|
162
|
+
.from(kempoPayment)
|
|
163
|
+
.where(eq(kempoPayment.disputed, true));
|
|
164
|
+
|
|
165
|
+
return [null, { byStatus: rows, disputed: disputes?.count ?? 0 }];
|
|
166
|
+
} catch {
|
|
167
|
+
return [{ code: 500, msg: 'Could not summarise payments' }, null];
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const listRefunds = async paymentId => {
|
|
172
|
+
try {
|
|
173
|
+
const rows = await db.select().from(kempoPaymentRefund)
|
|
174
|
+
.where(eq(kempoPaymentRefund.paymentId, paymentId))
|
|
175
|
+
.orderBy(desc(kempoPaymentRefund.createdAt));
|
|
176
|
+
return [null, rows];
|
|
177
|
+
} catch {
|
|
178
|
+
return [{ code: 500, msg: 'Could not load refunds' }, null];
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/*
|
|
183
|
+
Records a refund, or updates the one already recorded for the same provider reference.
|
|
184
|
+
|
|
185
|
+
Both paths reach here: the admin issuing one, and a webhook reporting one — including refunds
|
|
186
|
+
nobody here issued at all, made from the processor's own dashboard. Keying on the provider's
|
|
187
|
+
reference is what stops those two accounts of the same refund becoming two rows.
|
|
188
|
+
*/
|
|
189
|
+
export const recordRefund = async ({ paymentId, provider, providerRef, amount, currency, status, reason, issuedBy }) => {
|
|
190
|
+
const now = new Date();
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const [existing] = await db.select().from(kempoPaymentRefund)
|
|
194
|
+
.where(and(eq(kempoPaymentRefund.provider, provider), eq(kempoPaymentRefund.providerRef, providerRef)))
|
|
195
|
+
.limit(1);
|
|
196
|
+
|
|
197
|
+
if(existing){
|
|
198
|
+
const [row] = await db.update(kempoPaymentRefund)
|
|
199
|
+
.set({
|
|
200
|
+
status,
|
|
201
|
+
amount,
|
|
202
|
+
// A refund made in the dashboard has no kempo user behind it; one already attributed
|
|
203
|
+
// keeps whoever issued it rather than being blanked by the webhook that follows.
|
|
204
|
+
issuedBy: existing.issuedBy || issuedBy || null,
|
|
205
|
+
reason: existing.reason || reason || null,
|
|
206
|
+
updatedAt: now,
|
|
207
|
+
})
|
|
208
|
+
.where(eq(kempoPaymentRefund.id, existing.id))
|
|
209
|
+
.returning();
|
|
210
|
+
return [null, row];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const row = {
|
|
214
|
+
id: newId(),
|
|
215
|
+
paymentId,
|
|
216
|
+
provider,
|
|
217
|
+
providerRef,
|
|
218
|
+
amount,
|
|
219
|
+
currency,
|
|
220
|
+
status,
|
|
221
|
+
reason: reason || null,
|
|
222
|
+
issuedBy: issuedBy || null,
|
|
223
|
+
createdAt: now,
|
|
224
|
+
updatedAt: now,
|
|
225
|
+
};
|
|
226
|
+
await db.insert(kempoPaymentRefund).values(row);
|
|
227
|
+
return [null, row];
|
|
228
|
+
} catch {
|
|
229
|
+
return [{ code: 500, msg: 'Could not record the refund' }, null];
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
The refunded total, recomputed from the refund rows rather than incremented.
|
|
235
|
+
|
|
236
|
+
Incrementing is what produces a payment that says it has been refunded twice, because the admin
|
|
237
|
+
action and the webhook that follows it are two reports of one refund. Summing rows that are
|
|
238
|
+
themselves deduplicated by provider reference cannot double-count, however many times an event
|
|
239
|
+
is redelivered.
|
|
240
|
+
*/
|
|
241
|
+
export const refundedTotal = async paymentId => {
|
|
242
|
+
try {
|
|
243
|
+
const [row] = await db.select({ total: sql`coalesce(sum(${kempoPaymentRefund.amount}), 0)::int` })
|
|
244
|
+
.from(kempoPaymentRefund)
|
|
245
|
+
.where(and(eq(kempoPaymentRefund.paymentId, paymentId), inArray(kempoPaymentRefund.status, ['succeeded', 'pending'])));
|
|
246
|
+
return [null, row?.total ?? 0];
|
|
247
|
+
} catch {
|
|
248
|
+
return [{ code: 500, msg: 'Could not total the refunds' }, null];
|
|
249
|
+
}
|
|
250
|
+
};
|