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,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
+ };