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