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,435 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { getSetting } from 'kempo/server/sdk.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The Stripe processor module. Nothing outside this file knows Stripe exists.
|
|
6
|
+
|
|
7
|
+
The contract every provider module implements is documented in server/providers/index.js. This is
|
|
8
|
+
the reference implementation of it, and the only one that ships today — the shape is what makes
|
|
9
|
+
adding PayPal or Square a new file here rather than a rewrite everywhere else.
|
|
10
|
+
|
|
11
|
+
Two decisions worth stating, because they are the ones somebody would otherwise undo:
|
|
12
|
+
|
|
13
|
+
- `automatic_payment_methods` is enabled on every intent. That single flag is what makes Apple
|
|
14
|
+
Pay, Google Pay, Klarna, Afterpay and the rest appear in the payment form for the customers
|
|
15
|
+
whose device and country support them, with no extra integration and no separate merchant
|
|
16
|
+
account. Turning it off to hand-pick payment method types trades all of that away for nothing.
|
|
17
|
+
|
|
18
|
+
- No `apiVersion` is pinned. The SDK sends the version it was built and tested against, which is
|
|
19
|
+
the one this code was written for; naming a different one here is how you get a response shape
|
|
20
|
+
the installed SDK does not expect.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const name = 'stripe';
|
|
24
|
+
export const label = 'Stripe';
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
Where Stripe should be told to send events, appended to the site's own origin. Shown on the
|
|
28
|
+
settings screen so the value pasted into the Stripe dashboard is not one somebody has to derive.
|
|
29
|
+
*/
|
|
30
|
+
export const webhookPath = '/payments/api/webhooks/stripe';
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
The header its signature arrives in. Declared by the provider rather than assumed by the route,
|
|
34
|
+
because every processor picked a different name for it and the route should not have to know all
|
|
35
|
+
of them to accept one.
|
|
36
|
+
*/
|
|
37
|
+
export const signatureHeader = 'stripe-signature';
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
The events the endpoint needs to be sent, listed so the settings screen can show exactly what to
|
|
41
|
+
tick in the Stripe dashboard rather than leaving somebody to work it out from this file.
|
|
42
|
+
|
|
43
|
+
Subscribing to everything would also work and is what "send all events" does, but it fills the
|
|
44
|
+
event log with things this extension deliberately ignores, and the log is the diagnostic — a
|
|
45
|
+
screen of noise is a screen nobody reads when a payment goes missing.
|
|
46
|
+
*/
|
|
47
|
+
export const webhookEvents = [
|
|
48
|
+
'payment_intent.succeeded',
|
|
49
|
+
'payment_intent.payment_failed',
|
|
50
|
+
'payment_intent.processing',
|
|
51
|
+
'payment_intent.canceled',
|
|
52
|
+
'payment_intent.requires_action',
|
|
53
|
+
'payment_intent.amount_capturable_updated',
|
|
54
|
+
'charge.refunded',
|
|
55
|
+
'charge.refund.updated',
|
|
56
|
+
'charge.dispute.created',
|
|
57
|
+
'charge.dispute.closed',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
The settings holding this provider's credentials, in the order a settings screen should render
|
|
62
|
+
them. Declared here rather than in the screen so a second provider's fields arrive with it.
|
|
63
|
+
Every name must also exist in kempo-config.json — a static test checks that.
|
|
64
|
+
*/
|
|
65
|
+
export const credentialFields = [
|
|
66
|
+
{
|
|
67
|
+
name: 'stripe_publishable_key',
|
|
68
|
+
label: 'Publishable key',
|
|
69
|
+
type: 'string',
|
|
70
|
+
placeholder: 'pk_test_…',
|
|
71
|
+
help: 'From Developers → API keys. This one is public — it is embedded in the payment form in every customer’s browser.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'stripe_secret_key',
|
|
75
|
+
label: 'Secret key',
|
|
76
|
+
type: 'secret',
|
|
77
|
+
placeholder: 'sk_test_…',
|
|
78
|
+
help: 'From the same screen, revealed once. It can move money, so it is encrypted here and never sent back to a browser.',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'stripe_webhook_secret',
|
|
82
|
+
label: 'Webhook signing secret',
|
|
83
|
+
type: 'secret',
|
|
84
|
+
placeholder: 'whsec_…',
|
|
85
|
+
help: 'Shown when you add the endpoint below under Developers → Webhooks. Without it every incoming event is rejected, so no payment is ever confirmed.',
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const credentials = async () => {
|
|
90
|
+
const read = async key => {
|
|
91
|
+
const [error, value] = await getSetting('kempo-payments', key, '');
|
|
92
|
+
return error ? '' : String(value || '');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const [publishableKey, secretKey, webhookSecret] = await Promise.all([
|
|
96
|
+
read('stripe_publishable_key'),
|
|
97
|
+
read('stripe_secret_key'),
|
|
98
|
+
read('stripe_webhook_secret'),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
return { publishableKey, secretKey, webhookSecret };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
One client per secret key, kept between requests. Rebuilt the moment the key changes, which is
|
|
106
|
+
what makes rotating a key on the settings screen take effect without a restart.
|
|
107
|
+
*/
|
|
108
|
+
let cached = { key: null, client: null };
|
|
109
|
+
|
|
110
|
+
const clientFor = secretKey => {
|
|
111
|
+
if(cached.key !== secretKey){
|
|
112
|
+
cached = { key: secretKey, client: new Stripe(secretKey) };
|
|
113
|
+
}
|
|
114
|
+
return cached.client;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const withClient = async () => {
|
|
118
|
+
const { secretKey } = await credentials();
|
|
119
|
+
if(!secretKey) return [{ code: 409, msg: 'Stripe is not connected yet — add a secret key on the payments settings screen' }, null];
|
|
120
|
+
return [null, clientFor(secretKey)];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
Two kinds of message come back from Stripe, and only one of them is written for whoever ends up
|
|
125
|
+
reading it.
|
|
126
|
+
|
|
127
|
+
A **card error** ("Your card was declined", "Your card has insufficient funds. Try a different
|
|
128
|
+
card.") is written for the person paying, so it passes through whole — replacing it with something
|
|
129
|
+
vaguer would take away the one thing they can act on.
|
|
130
|
+
|
|
131
|
+
Everything else is written for the person who set the account up, and can be a good deal more than
|
|
132
|
+
a caller should hand a customer: asking for a currency the account cannot take returns the account's
|
|
133
|
+
entire supported-currency list and a dashboard link — measured at ~1,500 characters. Those are
|
|
134
|
+
logged in full for whoever runs the site, and only the first sentence goes back, capped in length.
|
|
135
|
+
|
|
136
|
+
The status code is clamped: a Stripe 402 is meaningful, but its network and rate-limit errors
|
|
137
|
+
should not become a 0 that a caller reads as its own fault.
|
|
138
|
+
*/
|
|
139
|
+
const MAX_MESSAGE = 200;
|
|
140
|
+
|
|
141
|
+
export const failure = error => {
|
|
142
|
+
const code = Number.isInteger(error?.statusCode) && error.statusCode >= 400 && error.statusCode < 600
|
|
143
|
+
? error.statusCode
|
|
144
|
+
: 502;
|
|
145
|
+
const message = error?.message || 'The payment processor rejected the request';
|
|
146
|
+
|
|
147
|
+
if(error?.type === 'StripeCardError') return [{ code, msg: message }, null];
|
|
148
|
+
|
|
149
|
+
console.warn(`[kempo-payments] Stripe ${error?.type || 'error'}: ${message}`);
|
|
150
|
+
const firstSentence = message.split(/(?<=[.!?])\s/)[0];
|
|
151
|
+
return [{ code, msg: firstSentence.length > MAX_MESSAGE ? `${firstSentence.slice(0, MAX_MESSAGE - 1)}…` : firstSentence }, null];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const STATUS_MAP = {
|
|
155
|
+
requires_payment_method: 'requires_payment_method',
|
|
156
|
+
requires_confirmation: 'requires_confirmation',
|
|
157
|
+
requires_action: 'requires_action',
|
|
158
|
+
processing: 'processing',
|
|
159
|
+
requires_capture: 'requires_capture',
|
|
160
|
+
succeeded: 'succeeded',
|
|
161
|
+
canceled: 'canceled',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
A Stripe PaymentIntent, in kempo's vocabulary.
|
|
166
|
+
|
|
167
|
+
`amountRefunded` only has a real answer when the charge behind the intent has been expanded — an
|
|
168
|
+
unexpanded `latest_charge` is a bare id string. Reporting 0 in that case would overwrite a real
|
|
169
|
+
refund total with a wrong one, so it comes back undefined and the caller keeps what it has.
|
|
170
|
+
*/
|
|
171
|
+
export const fromIntent = intent => {
|
|
172
|
+
const charge = intent.latest_charge && typeof intent.latest_charge === 'object' ? intent.latest_charge : null;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
ref: intent.id,
|
|
176
|
+
status: STATUS_MAP[intent.status] || 'processing',
|
|
177
|
+
amount: intent.amount,
|
|
178
|
+
amountCaptured: intent.amount_received ?? 0,
|
|
179
|
+
amountRefunded: charge ? charge.amount_refunded : undefined,
|
|
180
|
+
currency: intent.currency,
|
|
181
|
+
captureMethod: intent.capture_method === 'manual' ? 'manual' : 'automatic',
|
|
182
|
+
livemode: !!intent.livemode,
|
|
183
|
+
lastError: intent.last_payment_error?.message || null,
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const status = async () => {
|
|
188
|
+
const { publishableKey, secretKey, webhookSecret } = await credentials();
|
|
189
|
+
|
|
190
|
+
/*
|
|
191
|
+
Which mode the keys are for is read off the keys themselves rather than kept as a separate
|
|
192
|
+
toggle. A toggle can disagree with the keys; a prefix cannot, and "the switch says test but the
|
|
193
|
+
key is live" is the one configuration mistake in this extension that costs real money.
|
|
194
|
+
*/
|
|
195
|
+
const mode = secretKey.startsWith('sk_live_') ? 'live' : secretKey.startsWith('sk_test_') ? 'test' : null;
|
|
196
|
+
|
|
197
|
+
return [null, {
|
|
198
|
+
provider: name,
|
|
199
|
+
label,
|
|
200
|
+
configured: !!(secretKey && publishableKey),
|
|
201
|
+
hasSecretKey: !!secretKey,
|
|
202
|
+
hasPublishableKey: !!publishableKey,
|
|
203
|
+
hasWebhookSecret: !!webhookSecret,
|
|
204
|
+
/*
|
|
205
|
+
Which credentials are set, keyed by setting name — booleans, never values. The settings screen
|
|
206
|
+
renders its fields from `credentialFields` and needs to mark the ones already filled in; going
|
|
207
|
+
through the field's own name means it does that without a table of provider-specific special
|
|
208
|
+
cases that a second provider would have to be added to.
|
|
209
|
+
*/
|
|
210
|
+
fields: {
|
|
211
|
+
stripe_publishable_key: !!publishableKey,
|
|
212
|
+
stripe_secret_key: !!secretKey,
|
|
213
|
+
stripe_webhook_secret: !!webhookSecret,
|
|
214
|
+
},
|
|
215
|
+
mode,
|
|
216
|
+
livemode: mode === 'live',
|
|
217
|
+
/*
|
|
218
|
+
Keys from two different modes cannot be used together: an intent created with a test secret
|
|
219
|
+
key has a client secret the live publishable key cannot confirm, and the failure surfaces in
|
|
220
|
+
the browser as a generic error with nothing pointing at the cause.
|
|
221
|
+
*/
|
|
222
|
+
mismatchedKeys: !!(secretKey && publishableKey && mode
|
|
223
|
+
&& !publishableKey.startsWith(mode === 'live' ? 'pk_live_' : 'pk_test_')),
|
|
224
|
+
webhookPath,
|
|
225
|
+
}];
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const clientConfig = async () => {
|
|
229
|
+
const { publishableKey } = await credentials();
|
|
230
|
+
if(!publishableKey) return [{ code: 409, msg: 'Stripe is not connected yet' }, null];
|
|
231
|
+
return [null, { publishableKey }];
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const createIntent = async ({ amount, currency, description, metadata, captureMethod, customerEmail, idempotencyKey }) => {
|
|
235
|
+
const [error, stripe] = await withClient();
|
|
236
|
+
if(error) return [error, null];
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const intent = await stripe.paymentIntents.create({
|
|
240
|
+
amount,
|
|
241
|
+
currency,
|
|
242
|
+
capture_method: captureMethod === 'manual' ? 'manual' : 'automatic',
|
|
243
|
+
description: description || undefined,
|
|
244
|
+
receipt_email: customerEmail || undefined,
|
|
245
|
+
/*
|
|
246
|
+
Stripe metadata values must be strings, and it rejects a key whose value is not one — which
|
|
247
|
+
is how a payment arrives at the dashboard missing the order number somebody was counting on
|
|
248
|
+
being able to search for.
|
|
249
|
+
*/
|
|
250
|
+
metadata: Object.fromEntries(
|
|
251
|
+
Object.entries(metadata || {}).map(([key, value]) => [key, String(value)]),
|
|
252
|
+
),
|
|
253
|
+
automatic_payment_methods: { enabled: true },
|
|
254
|
+
}, idempotencyKey ? { idempotencyKey } : undefined);
|
|
255
|
+
|
|
256
|
+
return [null, { ...fromIntent(intent), clientSecret: intent.client_secret }];
|
|
257
|
+
} catch(err) {
|
|
258
|
+
return failure(err);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const retrieveIntent = async ref => {
|
|
263
|
+
const [error, stripe] = await withClient();
|
|
264
|
+
if(error) return [error, null];
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const intent = await stripe.paymentIntents.retrieve(ref, { expand: ['latest_charge'] });
|
|
268
|
+
return [null, { ...fromIntent(intent), clientSecret: intent.client_secret }];
|
|
269
|
+
} catch(err) {
|
|
270
|
+
return failure(err);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export const capture = async (ref, { amount } = {}) => {
|
|
275
|
+
const [error, stripe] = await withClient();
|
|
276
|
+
if(error) return [error, null];
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const intent = await stripe.paymentIntents.capture(ref, {
|
|
280
|
+
...(amount ? { amount_to_capture: amount } : {}),
|
|
281
|
+
expand: ['latest_charge'],
|
|
282
|
+
});
|
|
283
|
+
return [null, fromIntent(intent)];
|
|
284
|
+
} catch(err) {
|
|
285
|
+
return failure(err);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export const cancel = async ref => {
|
|
290
|
+
const [error, stripe] = await withClient();
|
|
291
|
+
if(error) return [error, null];
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const intent = await stripe.paymentIntents.cancel(ref);
|
|
295
|
+
return [null, fromIntent(intent)];
|
|
296
|
+
} catch(err) {
|
|
297
|
+
return failure(err);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/*
|
|
302
|
+
Stripe accepts exactly three reasons and rejects anything else outright, so a free-text reason is
|
|
303
|
+
carried as metadata instead of being dropped. The person issuing the refund typed it for somebody
|
|
304
|
+
to read later; losing it because it was not on a list of three is not an acceptable outcome.
|
|
305
|
+
*/
|
|
306
|
+
const STRIPE_REASONS = new Set(['duplicate', 'fraudulent', 'requested_by_customer']);
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
A refund, in kempo's vocabulary. `pending` is a normal answer, not a failure: a refund is not
|
|
310
|
+
instant for every payment method, and the webhook is what eventually says which it became.
|
|
311
|
+
*/
|
|
312
|
+
const refundFrom = entry => ({
|
|
313
|
+
ref: entry.id,
|
|
314
|
+
amount: entry.amount,
|
|
315
|
+
currency: entry.currency,
|
|
316
|
+
status: entry.status === 'succeeded' ? 'succeeded' : entry.status === 'failed' ? 'failed' : 'pending',
|
|
317
|
+
reason: entry.reason || entry.metadata?.reason || null,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
export const refund = async (ref, { amount, reason } = {}) => {
|
|
321
|
+
const [error, stripe] = await withClient();
|
|
322
|
+
if(error) return [error, null];
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const created = await stripe.refunds.create({
|
|
326
|
+
payment_intent: ref,
|
|
327
|
+
...(amount ? { amount } : {}),
|
|
328
|
+
...(STRIPE_REASONS.has(reason) ? { reason } : {}),
|
|
329
|
+
...(reason && !STRIPE_REASONS.has(reason) ? { metadata: { reason: String(reason) } } : {}),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return [null, { ...refundFrom(created), reason: reason || null }];
|
|
333
|
+
} catch(err) {
|
|
334
|
+
return failure(err);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/*
|
|
339
|
+
Every refund on a payment, asked of Stripe directly.
|
|
340
|
+
|
|
341
|
+
Needed because the `charge.refunded` event no longer carries them: on the current API version it
|
|
342
|
+
reports only the new running total (`amount_refunded`), with no list of which refunds make it up.
|
|
343
|
+
An event that says "100 has been refunded" cannot be turned into a refund row without asking who
|
|
344
|
+
refunded it — found by testing, when a refund reached the webhook and was recorded as nothing.
|
|
345
|
+
*/
|
|
346
|
+
export const listRefunds = async ref => {
|
|
347
|
+
const [error, stripe] = await withClient();
|
|
348
|
+
if(error) return [error, null];
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const listed = await stripe.refunds.list({ payment_intent: ref, limit: 100 });
|
|
352
|
+
return [null, listed.data.map(refundFrom)];
|
|
353
|
+
} catch(err) {
|
|
354
|
+
return failure(err);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/*
|
|
359
|
+
Verifies a webhook against the signing secret and returns the parsed event.
|
|
360
|
+
|
|
361
|
+
`payload` must be the exact bytes that arrived. Stripe signs the raw body, so a payload that has
|
|
362
|
+
been through JSON.parse and re-stringified fails verification even though it is the same data —
|
|
363
|
+
key order and whitespace are part of what was signed.
|
|
364
|
+
*/
|
|
365
|
+
export const verifyEvent = async ({ payload, signature }) => {
|
|
366
|
+
const { secretKey, webhookSecret } = await credentials();
|
|
367
|
+
if(!secretKey) return [{ code: 409, msg: 'Stripe is not connected' }, null];
|
|
368
|
+
if(!webhookSecret) return [{ code: 409, msg: 'No webhook signing secret is configured' }, null];
|
|
369
|
+
if(!signature) return [{ code: 400, msg: 'Missing signature' }, null];
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
return [null, clientFor(secretKey).webhooks.constructEvent(payload, signature, webhookSecret)];
|
|
373
|
+
} catch {
|
|
374
|
+
// Deliberately vague back to the caller: a signature check that explains itself is one that
|
|
375
|
+
// helps somebody shape the next attempt.
|
|
376
|
+
return [{ code: 400, msg: 'Signature verification failed' }, null];
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/*
|
|
381
|
+
Translates a verified Stripe event into the small set of things this extension acts on.
|
|
382
|
+
|
|
383
|
+
Anything not listed comes back as `ignored`, which is still recorded and still answered with a
|
|
384
|
+
200 — telling Stripe an event failed makes it retry an event nothing was ever going to do
|
|
385
|
+
anything with, for days.
|
|
386
|
+
*/
|
|
387
|
+
export const interpretEvent = event => {
|
|
388
|
+
const object = event.data?.object || {};
|
|
389
|
+
|
|
390
|
+
switch(event.type){
|
|
391
|
+
case 'payment_intent.succeeded':
|
|
392
|
+
case 'payment_intent.processing':
|
|
393
|
+
case 'payment_intent.canceled':
|
|
394
|
+
case 'payment_intent.requires_action':
|
|
395
|
+
case 'payment_intent.amount_capturable_updated':
|
|
396
|
+
return { kind: 'payment', providerRef: object.id, payment: fromIntent(object) };
|
|
397
|
+
|
|
398
|
+
/*
|
|
399
|
+
Stripe leaves a failed intent in `requires_payment_method` so the customer can try another
|
|
400
|
+
card, so the status alone carries no sign that anything went wrong. The message is the only
|
|
401
|
+
record of the decline, which is why it is lifted out explicitly here.
|
|
402
|
+
*/
|
|
403
|
+
case 'payment_intent.payment_failed':
|
|
404
|
+
return {
|
|
405
|
+
kind: 'payment',
|
|
406
|
+
providerRef: object.id,
|
|
407
|
+
payment: { ...fromIntent(object), lastError: object.last_payment_error?.message || 'The payment failed' },
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
case 'charge.refunded':
|
|
411
|
+
return {
|
|
412
|
+
kind: 'refund',
|
|
413
|
+
providerRef: object.payment_intent,
|
|
414
|
+
amountRefunded: object.amount_refunded,
|
|
415
|
+
// Empty on current API versions, which report only the total — see `listRefunds`.
|
|
416
|
+
refunds: (object.refunds?.data || []).map(refundFrom),
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
case 'charge.refund.updated':
|
|
420
|
+
return { kind: 'refund', providerRef: object.payment_intent, refunds: [refundFrom(object)] };
|
|
421
|
+
|
|
422
|
+
case 'charge.dispute.created':
|
|
423
|
+
return { kind: 'dispute', providerRef: object.payment_intent, disputed: true, reason: object.reason || null };
|
|
424
|
+
|
|
425
|
+
/*
|
|
426
|
+
A dispute won leaves the payment exactly as it was; a dispute lost has already taken the money
|
|
427
|
+
back, and the flag is what stops the payment reading as a clean sale afterwards.
|
|
428
|
+
*/
|
|
429
|
+
case 'charge.dispute.closed':
|
|
430
|
+
return { kind: 'dispute', providerRef: object.payment_intent, disputed: object.status !== 'won', reason: object.reason || null };
|
|
431
|
+
|
|
432
|
+
default:
|
|
433
|
+
return { kind: 'ignored' };
|
|
434
|
+
}
|
|
435
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The declared settings, read from kempo-config.json itself.
|
|
5
|
+
|
|
6
|
+
This exists so the settings route can preserve each setting's type and description when it
|
|
7
|
+
writes. `setSetting` takes both as arguments and stores exactly what it is given — pass null and
|
|
8
|
+
the description shown on kempo's own /admin/settings screen is erased, which is how a perfectly
|
|
9
|
+
working save quietly degrades a screen belonging to somebody else.
|
|
10
|
+
|
|
11
|
+
The type matters more here than in most extensions: writing a credential without `secret` stores
|
|
12
|
+
an API key as plain text, in a row the admin settings screen will happily show to anybody who can
|
|
13
|
+
open it. Reading the type out of the config rather than restating it means there is one list, and
|
|
14
|
+
a static test checks the two agree.
|
|
15
|
+
*/
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const config = require('../../../kempo-config.json');
|
|
18
|
+
|
|
19
|
+
export const DECLARED_SETTINGS = new Map(
|
|
20
|
+
(config.settings || []).map(setting => [setting.name, setting]),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const typeOf = name => DECLARED_SETTINGS.get(name)?.type || 'string';
|
|
24
|
+
export const descriptionOf = name => DECLARED_SETTINGS.get(name)?.description || null;
|
|
25
|
+
export const isSecret = name => typeOf(name) === 'secret';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getSetting } from 'kempo/server/sdk.js';
|
|
2
|
+
import { isValidCurrency, normaliseCurrency } from '../money/currencies.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Reading the settings, and validating them, in one place.
|
|
6
|
+
|
|
7
|
+
Everything on the settings screen is editable, and two of the values decide how money is taken —
|
|
8
|
+
a currency that is not a currency, or a capture method that is neither of the two that exist,
|
|
9
|
+
must not reach a processor. So every value the rest of the extension sees has been through a
|
|
10
|
+
`normalise*` below and is known-good, with a working default behind it rather than an error.
|
|
11
|
+
|
|
12
|
+
Credentials are deliberately not here. Each provider module reads its own, because their names
|
|
13
|
+
and number are the provider's business — see server/providers/stripe.js.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const OWNER = 'kempo-payments';
|
|
17
|
+
|
|
18
|
+
export const CAPTURE_METHODS = ['automatic', 'manual'];
|
|
19
|
+
|
|
20
|
+
export const DEFAULTS = {
|
|
21
|
+
provider: 'stripe',
|
|
22
|
+
currency: 'usd',
|
|
23
|
+
captureMethod: 'automatic',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
What a caller or a form actually typed, forgiving about case and stray spaces and nothing else.
|
|
28
|
+
`null` means it is not one of the two — and a caller that was *handed* the value must refuse it
|
|
29
|
+
rather than guess (see createPayment and the settings route), because "charge now" and "hold for
|
|
30
|
+
later" are opposites: guessing wrong takes a customer's money when only a reservation was meant.
|
|
31
|
+
Found by testing against Stripe, where every spelling except the exact lowercase `manual` —
|
|
32
|
+
`Manual`, `MANUAL `, a typo, `hold` — quietly became an immediate charge.
|
|
33
|
+
*/
|
|
34
|
+
export const parseCaptureMethod = value => {
|
|
35
|
+
// Strings only: coercing would read `["manual"]` out of a JSON body as "manual".
|
|
36
|
+
if(typeof value !== 'string') return null;
|
|
37
|
+
const method = value.trim().toLowerCase();
|
|
38
|
+
return CAPTURE_METHODS.includes(method) ? method : null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Only for the stored site default, where the alternative to a fallback is no payments at all.
|
|
42
|
+
export const normaliseCaptureMethod = value => parseCaptureMethod(value) ?? DEFAULTS.captureMethod;
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
A bad currency falls back rather than throwing, but never silently in a place that matters:
|
|
46
|
+
`createPayment` validates the currency it was actually handed and refuses an invalid one there.
|
|
47
|
+
This fallback only covers the site-wide default, where the alternative is an extension that
|
|
48
|
+
cannot take any payment at all until somebody fixes a typo.
|
|
49
|
+
*/
|
|
50
|
+
export const normaliseDefaultCurrency = value => {
|
|
51
|
+
const currency = normaliseCurrency(value);
|
|
52
|
+
return isValidCurrency(currency) ? currency : DEFAULTS.currency;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const readConfig = async () => {
|
|
56
|
+
const read = async (name, fallback) => {
|
|
57
|
+
const [error, value] = await getSetting(OWNER, name, fallback);
|
|
58
|
+
return error ? fallback : value;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const [provider, currency, captureMethod] = await Promise.all([
|
|
62
|
+
read('provider', DEFAULTS.provider),
|
|
63
|
+
read('currency', DEFAULTS.currency),
|
|
64
|
+
read('capture_method', DEFAULTS.captureMethod),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
provider: String(provider || DEFAULTS.provider),
|
|
69
|
+
currency: normaliseDefaultCurrency(currency),
|
|
70
|
+
captureMethod: normaliseCaptureMethod(captureMethod),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Money is integers here, always — the amount in the currency's smallest unit. $12.99 is 1299.
|
|
3
|
+
|
|
4
|
+
This module exists because "smallest unit" is not two decimal places everywhere, and code that
|
|
5
|
+
assumes it is will be out by a factor of a hundred in Japan and by ten in Kuwait. Those are not
|
|
6
|
+
display bugs; they are charging the wrong amount.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const ZERO_DECIMAL = new Set([
|
|
10
|
+
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
|
|
11
|
+
'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
Currencies whose minor unit is a thousandth. Stripe additionally requires the amount to be a
|
|
16
|
+
whole number of hundredths — a trailing zero — which `isValidAmount` enforces rather than
|
|
17
|
+
silently rounding somebody's total.
|
|
18
|
+
*/
|
|
19
|
+
export const THREE_DECIMAL = new Set(['bhd', 'jod', 'kwd', 'omr', 'tnd']);
|
|
20
|
+
|
|
21
|
+
export const decimalsFor = currency => {
|
|
22
|
+
const code = normaliseCurrency(currency);
|
|
23
|
+
if(ZERO_DECIMAL.has(code)) return 0;
|
|
24
|
+
if(THREE_DECIMAL.has(code)) return 3;
|
|
25
|
+
return 2;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
ISO 4217 is three letters. Anything else is refused rather than coerced: a currency of "dollars"
|
|
30
|
+
reaching a processor is a rejected charge at best, and a charge in the wrong currency at worst.
|
|
31
|
+
*/
|
|
32
|
+
export const normaliseCurrency = currency => String(currency ?? '').trim().toLowerCase();
|
|
33
|
+
|
|
34
|
+
export const isValidCurrency = currency => /^[a-z]{3}$/.test(normaliseCurrency(currency));
|
|
35
|
+
|
|
36
|
+
export const isValidAmount = (amount, currency) => {
|
|
37
|
+
if(!Number.isInteger(amount) || amount <= 0) return false;
|
|
38
|
+
if(THREE_DECIMAL.has(normaliseCurrency(currency)) && amount % 10 !== 0) return false;
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
Major units in, minor units out — for a caller that genuinely has "12.99" and not 1299, such as
|
|
44
|
+
an admin typing a refund amount into a box. Rounds rather than truncates, because 12.99 * 100 is
|
|
45
|
+
1298.9999999999998 in floating point and truncation would quietly short the merchant a cent.
|
|
46
|
+
*/
|
|
47
|
+
export const toMinor = (major, currency) => {
|
|
48
|
+
const amount = Number(major);
|
|
49
|
+
if(!Number.isFinite(amount)) return null;
|
|
50
|
+
return Math.round(amount * 10 ** decimalsFor(currency));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const fromMinor = (minor, currency) => Number(minor) / 10 ** decimalsFor(currency);
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
For display only. Falls back to a bare number with the code appended if the runtime has no data
|
|
57
|
+
for the currency, which is better than throwing on a screen whose job is to show a total.
|
|
58
|
+
*/
|
|
59
|
+
export const formatAmount = (minor, currency, locale = 'en-US') => {
|
|
60
|
+
const code = normaliseCurrency(currency);
|
|
61
|
+
const value = fromMinor(minor, code);
|
|
62
|
+
try {
|
|
63
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency: code.toUpperCase() }).format(value);
|
|
64
|
+
} catch {
|
|
65
|
+
return `${value.toFixed(decimalsFor(code))} ${code.toUpperCase()}`;
|
|
66
|
+
}
|
|
67
|
+
};
|