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
package/install.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import db from 'kempo/server/db/index.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Two things the declarative config cannot do.
|
|
6
|
+
|
|
7
|
+
**The indexes.** kempo's installer generates `CREATE TABLE` from the Drizzle schema and reads
|
|
8
|
+
columns and primary keys off it — indexes and unique constraints declared on a table are not
|
|
9
|
+
carried across. For most extensions that is a performance footnote. Here one of them is a
|
|
10
|
+
correctness guarantee: without a unique index on `(provider, providerRef)` two rows can claim the
|
|
11
|
+
same charge, and every webhook after that updates whichever one the database happens to return
|
|
12
|
+
first. So they are created here, by hand, matching what server/db/schema.js declares. A static
|
|
13
|
+
test checks the two lists agree — a schema index that this file forgets exists only in
|
|
14
|
+
development, where `drizzle-kit push` makes it.
|
|
15
|
+
|
|
16
|
+
**The warning.** Every credential this extension stores is a `secret` setting, and kempo can only
|
|
17
|
+
encrypt those when SETTINGS_ENCRYPTION_KEY is set. Without it the settings are silently not
|
|
18
|
+
created, and the first sign of trouble is a settings screen that refuses to save a key with no
|
|
19
|
+
explanation. Saying so at install time is the difference between a two-minute fix and an hour.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const INDEXES = [
|
|
23
|
+
sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoPayment_providerRef_idx" ON "kempoPayment" ("provider", "providerRef")`,
|
|
24
|
+
sql`CREATE INDEX IF NOT EXISTS "kempoPayment_reference_idx" ON "kempoPayment" ("owner", "reference")`,
|
|
25
|
+
sql`CREATE INDEX IF NOT EXISTS "kempoPaymentRefund_payment_idx" ON "kempoPaymentRefund" ("paymentId")`,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export default async () => {
|
|
29
|
+
for(const statement of INDEXES){
|
|
30
|
+
try {
|
|
31
|
+
await db.execute(statement);
|
|
32
|
+
} catch(error) {
|
|
33
|
+
console.warn(`[kempo-payments] Could not create an index: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if(!process.env.SETTINGS_ENCRYPTION_KEY){
|
|
38
|
+
console.warn([
|
|
39
|
+
'[kempo-payments] SETTINGS_ENCRYPTION_KEY is not set, so the API credentials cannot be stored.',
|
|
40
|
+
' Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
|
|
41
|
+
' Put it in .env, restart, then reinstall this extension.',
|
|
42
|
+
' Back it up somewhere — losing it makes every stored secret on the site unrecoverable.',
|
|
43
|
+
].join('\n'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('[kempo-payments] Installed. Connect a processor at /admin/extension/kempo-payments/ — nothing can be charged until you do.');
|
|
48
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"docs": "https://github.com/dustinpoissant/kempo-payments#readme",
|
|
3
|
+
"git": "https://github.com/dustinpoissant/kempo-payments",
|
|
4
|
+
"public-scope": "payments",
|
|
5
|
+
"schema": "./server/db/schema.js",
|
|
6
|
+
"permissions": [
|
|
7
|
+
{ "name": "payments:view", "description": "See payments, what they were for, and what happened to them" },
|
|
8
|
+
{ "name": "payments:create", "description": "Take a payment — start a new charge over the API" },
|
|
9
|
+
{ "name": "payments:capture", "description": "Capture or cancel a payment that was only authorised" },
|
|
10
|
+
{ "name": "payments:refund", "description": "Refund a payment, in full or in part" },
|
|
11
|
+
{ "name": "payments:settings", "description": "Change the payment provider and its API credentials" }
|
|
12
|
+
],
|
|
13
|
+
"settings": [
|
|
14
|
+
{
|
|
15
|
+
"name": "provider",
|
|
16
|
+
"value": "stripe",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Which payment processor money actually goes through. Only 'stripe' ships today"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "currency",
|
|
22
|
+
"value": "usd",
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The currency a payment is taken in when the caller does not name one. ISO 4217, lowercase"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "capture_method",
|
|
28
|
+
"value": "automatic",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "'automatic' charges the card as soon as the customer confirms. 'manual' only authorises it, and somebody has to capture it before the hold expires"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "stripe_publishable_key",
|
|
34
|
+
"value": "",
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "Stripe publishable key (pk_test_… or pk_live_…). This one is meant to be seen — it is what the payment form in the browser is built with"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "stripe_secret_key",
|
|
40
|
+
"value": "",
|
|
41
|
+
"type": "secret",
|
|
42
|
+
"description": "Stripe secret key (sk_test_… or sk_live_…). Encrypted at rest and never sent to a browser"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "stripe_webhook_secret",
|
|
46
|
+
"value": "",
|
|
47
|
+
"type": "secret",
|
|
48
|
+
"description": "Stripe webhook signing secret (whsec_…). Without it every webhook is rejected, and no payment is ever confirmed"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"groups": [
|
|
52
|
+
{
|
|
53
|
+
"name": "kempo-payments:operator",
|
|
54
|
+
"description": "Handles payments day to day — looks them up, captures authorisations and issues refunds — without being able to see or change the API credentials",
|
|
55
|
+
"permissions": [
|
|
56
|
+
"payments:view",
|
|
57
|
+
"payments:capture",
|
|
58
|
+
"payments:refund"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "kempo-payments:administrator",
|
|
63
|
+
"description": "Everything an operator can do, plus connecting the site to a payment processor",
|
|
64
|
+
"permissions": [
|
|
65
|
+
"payments:view",
|
|
66
|
+
"payments:create",
|
|
67
|
+
"payments:capture",
|
|
68
|
+
"payments:refund",
|
|
69
|
+
"payments:settings"
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kempo-payments",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Payment processing for kempo — provider-agnostic payment intents, capture and refunds, with Stripe built in",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dustin Poissant",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "sdk.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "kempo-test -l minimal",
|
|
11
|
+
"link:local": "npm install ../kempo ../kempo-server ../kempo-ui --no-save --no-legacy-peer-deps --force"
|
|
12
|
+
},
|
|
13
|
+
"kempo": {
|
|
14
|
+
"public-scope": "payments"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./install": "./install.js",
|
|
18
|
+
"./update": "./update.js",
|
|
19
|
+
"./uninstall": "./uninstall.js",
|
|
20
|
+
"./sdk": "./sdk.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"admin",
|
|
24
|
+
"public",
|
|
25
|
+
"server",
|
|
26
|
+
"install.js",
|
|
27
|
+
"update.js",
|
|
28
|
+
"uninstall.js",
|
|
29
|
+
"sdk.js",
|
|
30
|
+
"kempo-config.json"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/dustinpoissant/kempo-payments.git"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"drizzle-orm": "^0.45.2",
|
|
38
|
+
"stripe": "^22.5.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"kempo": ">=4.2.40",
|
|
42
|
+
"kempo-server": ">=3.3.0",
|
|
43
|
+
"kempo-ui": ">=0.4.53"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"drizzle-kit": "^0.31.10",
|
|
47
|
+
"kempo-testing-framework": "^1.4.11",
|
|
48
|
+
"postgres": "^3.4.9"
|
|
49
|
+
},
|
|
50
|
+
"allowScripts": {
|
|
51
|
+
"esbuild@0.18.20": true,
|
|
52
|
+
"esbuild@0.25.12": true,
|
|
53
|
+
"esbuild@0.28.2": true,
|
|
54
|
+
"puppeteer@25.7.0": true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { activeProvider } from '../../../server/providers/index.js';
|
|
2
|
+
import { readConfig } from '../../../server/utils/config/settings.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
What a payment form in a browser needs to exist: which processor is configured, its publishable
|
|
6
|
+
key, and the currency amounts will be in.
|
|
7
|
+
|
|
8
|
+
The one route here with no session check, deliberately. A publishable key is designed to be
|
|
9
|
+
public — it is embedded in the page for every visitor and can do nothing on its own but tokenise
|
|
10
|
+
a card the person holding it already has. Gating this would mean guests cannot check out, which
|
|
11
|
+
is most of the people checking out.
|
|
12
|
+
|
|
13
|
+
Nothing secret can reach here by accident: the provider module decides what its `clientConfig` is
|
|
14
|
+
allowed to contain, and the secret key is not part of it.
|
|
15
|
+
*/
|
|
16
|
+
export default async (request, response) => {
|
|
17
|
+
const config = await readConfig();
|
|
18
|
+
|
|
19
|
+
const [providerError, provider] = await activeProvider();
|
|
20
|
+
if(providerError){
|
|
21
|
+
return response.json({ configured: false, provider: config.provider, currency: config.currency });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [statusError, status] = await provider.status();
|
|
25
|
+
if(statusError) return response.status(statusError.code).json({ error: statusError.msg });
|
|
26
|
+
|
|
27
|
+
if(!status.configured){
|
|
28
|
+
return response.json({
|
|
29
|
+
configured: false,
|
|
30
|
+
provider: provider.name,
|
|
31
|
+
label: provider.label,
|
|
32
|
+
currency: config.currency,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const [configError, clientConfig] = await provider.clientConfig();
|
|
37
|
+
if(configError) return response.status(configError.code).json({ error: configError.msg });
|
|
38
|
+
|
|
39
|
+
response.json({
|
|
40
|
+
configured: true,
|
|
41
|
+
provider: provider.name,
|
|
42
|
+
label: provider.label,
|
|
43
|
+
currency: config.currency,
|
|
44
|
+
captureMethod: config.captureMethod,
|
|
45
|
+
mode: status.mode,
|
|
46
|
+
...clientConfig,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { listPayments } from '../../../server/utils/payments/store.js';
|
|
2
|
+
import { gate } from '../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The payment list behind the admin screen. Paged, because a working store's payment table is the
|
|
6
|
+
one that grows forever.
|
|
7
|
+
|
|
8
|
+
`status` accepts a comma-separated list so "needs attention" can be one filter rather than four
|
|
9
|
+
round trips.
|
|
10
|
+
*/
|
|
11
|
+
export default async (request, response) => {
|
|
12
|
+
const [gateError] = await gate(request, 'payments:view');
|
|
13
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
14
|
+
|
|
15
|
+
const query = request.query || {};
|
|
16
|
+
const status = query.status ? String(query.status).split(',').map(value => value.trim()).filter(Boolean) : undefined;
|
|
17
|
+
|
|
18
|
+
const [error, result] = await listPayments({
|
|
19
|
+
status: status?.length === 1 ? status[0] : status,
|
|
20
|
+
provider: query.provider,
|
|
21
|
+
owner: query.owner,
|
|
22
|
+
reference: query.reference,
|
|
23
|
+
userId: query.userId,
|
|
24
|
+
limit: query.limit,
|
|
25
|
+
offset: query.offset,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
29
|
+
|
|
30
|
+
response.json(result);
|
|
31
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createPayment } from '../../../server/utils/payments/create.js';
|
|
2
|
+
import { gate } from '../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Starts a payment over HTTP.
|
|
6
|
+
|
|
7
|
+
Gated behind `payments:create`, which no ordinary visitor holds — and that is not an oversight.
|
|
8
|
+
The amount is in the request body, so an open version of this route would let anyone decide what
|
|
9
|
+
they owe. A customer-facing checkout works the other way round: the consumer extension computes
|
|
10
|
+
the amount on its own server, calls `createPayment` from the SDK, and sends only the resulting
|
|
11
|
+
client secret to the browser.
|
|
12
|
+
|
|
13
|
+
What this route is for is the other callers — the admin's own test payment, a script, an
|
|
14
|
+
integration that already authenticates as somebody trusted.
|
|
15
|
+
*/
|
|
16
|
+
export default async (request, response) => {
|
|
17
|
+
const [gateError, session] = await gate(request, 'payments:create');
|
|
18
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
19
|
+
|
|
20
|
+
const body = request.body || {};
|
|
21
|
+
|
|
22
|
+
const [error, result] = await createPayment({
|
|
23
|
+
amount: typeof body.amount === 'string' ? Number(body.amount) : body.amount,
|
|
24
|
+
currency: body.currency,
|
|
25
|
+
description: body.description || null,
|
|
26
|
+
metadata: body.metadata && typeof body.metadata === 'object' ? body.metadata : null,
|
|
27
|
+
captureMethod: body.captureMethod || null,
|
|
28
|
+
owner: body.owner || null,
|
|
29
|
+
reference: body.reference || null,
|
|
30
|
+
/*
|
|
31
|
+
Whoever is signed in, unless they named somebody else. A payment created on behalf of a
|
|
32
|
+
customer by a member of staff belongs to the customer, not to the person who typed it in.
|
|
33
|
+
*/
|
|
34
|
+
userId: body.userId || session.user.id,
|
|
35
|
+
customerEmail: body.customerEmail || null,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
39
|
+
|
|
40
|
+
response.json(result);
|
|
41
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getPayment, listRefunds } from '../../../../server/utils/payments/store.js';
|
|
2
|
+
import { listEvents } from '../../../../server/utils/webhooks/receive.js';
|
|
3
|
+
import { refundableAmount } from '../../../../server/utils/payments/statuses.js';
|
|
4
|
+
import { gate } from '../../../../server/utils/permissions/gate.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
One payment, with everything anybody looking at it needs in one response: the refunds against it
|
|
8
|
+
and the processor events that produced its current state.
|
|
9
|
+
|
|
10
|
+
The event log is included rather than being a separate screen because it is the answer to the
|
|
11
|
+
only hard question a payment screen ever gets — "the customer says they paid, why does this say
|
|
12
|
+
they did not" — and the answer is usually a webhook that never arrived.
|
|
13
|
+
*/
|
|
14
|
+
export default async (request, response) => {
|
|
15
|
+
const [gateError] = await gate(request, 'payments:view');
|
|
16
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
17
|
+
|
|
18
|
+
const [error, payment] = await getPayment(request.params?.id);
|
|
19
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
20
|
+
|
|
21
|
+
const [, refunds] = await listRefunds(payment.id);
|
|
22
|
+
const [, events] = await listEvents(payment.id);
|
|
23
|
+
|
|
24
|
+
response.json({
|
|
25
|
+
payment,
|
|
26
|
+
refunds: refunds || [],
|
|
27
|
+
events: events || [],
|
|
28
|
+
refundable: refundableAmount(payment),
|
|
29
|
+
});
|
|
30
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cancelPayment } from '../../../../../server/utils/payments/actions.js';
|
|
2
|
+
import { gate } from '../../../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Releases a payment nothing has been taken from.
|
|
6
|
+
|
|
7
|
+
Gated on `payments:capture` rather than a permission of its own: capturing and cancelling are the
|
|
8
|
+
two halves of one decision — resolve an authorisation — and whoever is trusted to complete a sale
|
|
9
|
+
is the same person trusted to call it off.
|
|
10
|
+
*/
|
|
11
|
+
export default async (request, response) => {
|
|
12
|
+
const [gateError] = await gate(request, 'payments:capture');
|
|
13
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
14
|
+
|
|
15
|
+
const [error, result] = await cancelPayment(request.params?.id);
|
|
16
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
17
|
+
|
|
18
|
+
response.json(result);
|
|
19
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { capturePayment } from '../../../../../server/utils/payments/actions.js';
|
|
2
|
+
import { gate } from '../../../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Takes the money that was authorised.
|
|
6
|
+
|
|
7
|
+
An amount may be given to capture less than was held — an order that shipped short. Leaving it
|
|
8
|
+
off captures the whole authorisation, which is what the button on the admin screen does.
|
|
9
|
+
*/
|
|
10
|
+
export default async (request, response) => {
|
|
11
|
+
const [gateError] = await gate(request, 'payments:capture');
|
|
12
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
13
|
+
|
|
14
|
+
const amount = request.body?.amount;
|
|
15
|
+
|
|
16
|
+
const [error, result] = await capturePayment(request.params?.id, {
|
|
17
|
+
amount: amount === undefined || amount === null || amount === '' ? undefined : Number(amount),
|
|
18
|
+
});
|
|
19
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
20
|
+
|
|
21
|
+
response.json(result);
|
|
22
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getClientSecret } from '../../../../../server/utils/payments/actions.js';
|
|
2
|
+
import { gate } from '../../../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The client secret for a payment already in flight, so a payment form can be mounted against it
|
|
6
|
+
again — a reloaded checkout, or a customer coming back to finish.
|
|
7
|
+
|
|
8
|
+
Gated on `payments:create`, the same permission that starting a payment needs. A client secret is
|
|
9
|
+
not a credential for the site, but it does let whoever holds it pay, cancel or read that one
|
|
10
|
+
payment, so it is not something to hand out on the strength of knowing an id.
|
|
11
|
+
|
|
12
|
+
A customer-facing "resume checkout" belongs in the consumer extension: it knows which order
|
|
13
|
+
belongs to which session and can answer that question from its own records. This route cannot.
|
|
14
|
+
*/
|
|
15
|
+
export default async (request, response) => {
|
|
16
|
+
const [gateError] = await gate(request, 'payments:create');
|
|
17
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
18
|
+
|
|
19
|
+
const [error, result] = await getClientSecret(request.params?.id);
|
|
20
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
21
|
+
|
|
22
|
+
response.json(result);
|
|
23
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { refundPayment } from '../../../../../server/utils/payments/actions.js';
|
|
2
|
+
import { gate } from '../../../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Gives money back.
|
|
6
|
+
|
|
7
|
+
Omitting the amount refunds everything still outstanding. The reason is recorded and shown next
|
|
8
|
+
to the refund afterwards — a free-text one is kept even where the processor only accepts a short
|
|
9
|
+
list of its own, because it was written for a person to read, not for the processor.
|
|
10
|
+
|
|
11
|
+
`issuedBy` is the signed-in user rather than anything in the body. Who refunded what is exactly
|
|
12
|
+
the field nobody should be able to set on their own behalf.
|
|
13
|
+
*/
|
|
14
|
+
export default async (request, response) => {
|
|
15
|
+
const [gateError, session] = await gate(request, 'payments:refund');
|
|
16
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
17
|
+
|
|
18
|
+
const amount = request.body?.amount;
|
|
19
|
+
|
|
20
|
+
const [error, result] = await refundPayment(request.params?.id, {
|
|
21
|
+
amount: amount === undefined || amount === null || amount === '' ? undefined : Number(amount),
|
|
22
|
+
reason: request.body?.reason || null,
|
|
23
|
+
issuedBy: session.user.id,
|
|
24
|
+
});
|
|
25
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
26
|
+
|
|
27
|
+
response.json(result);
|
|
28
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { syncPayment } from '../../../../../server/utils/payments/sync.js';
|
|
2
|
+
import { gate } from '../../../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Asks the processor what it thinks and writes the answer down.
|
|
6
|
+
|
|
7
|
+
A repair tool, not part of the normal path — webhooks are. It exists because the normal path has
|
|
8
|
+
one failure mode that is invisible from here: an endpoint that was never registered, or was
|
|
9
|
+
registered with the wrong signing secret, means payments succeed at the processor and stay
|
|
10
|
+
`processing` on this site forever. This is the button that fixes a payment in that state, and the
|
|
11
|
+
proof of what went wrong when it fixes every one of them.
|
|
12
|
+
|
|
13
|
+
A POST rather than a GET because it writes and it can fire hooks.
|
|
14
|
+
*/
|
|
15
|
+
export default async (request, response) => {
|
|
16
|
+
const [gateError] = await gate(request, 'payments:view');
|
|
17
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
18
|
+
|
|
19
|
+
const [error, result] = await syncPayment(request.params?.id);
|
|
20
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
21
|
+
|
|
22
|
+
response.json(result);
|
|
23
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { listProviders } from '../../../server/providers/index.js';
|
|
2
|
+
import { gate } from '../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The processors this installation could be pointed at, and the credential fields each one needs.
|
|
6
|
+
|
|
7
|
+
The settings screen builds its form from this rather than hard-coding Stripe's three keys, so a
|
|
8
|
+
second provider module arrives with its own form and nothing in the admin has to be edited to
|
|
9
|
+
show it.
|
|
10
|
+
*/
|
|
11
|
+
export default async (request, response) => {
|
|
12
|
+
const [gateError] = await gate(request, 'payments:settings');
|
|
13
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
14
|
+
|
|
15
|
+
response.json({ providers: listProviders() });
|
|
16
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { activeProvider, listProviders } from '../../../server/providers/index.js';
|
|
2
|
+
import { readConfig } from '../../../server/utils/config/settings.js';
|
|
3
|
+
import { gate } from '../../../server/utils/permissions/gate.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Everything the settings screen renders, and not one character of a secret.
|
|
7
|
+
|
|
8
|
+
A credential comes back as a boolean — whether it is set — rather than a masked string. Masking
|
|
9
|
+
in the response and hoping nothing logs it is a weaker promise than never putting it in the
|
|
10
|
+
response at all, and the screen has no use for the value anyway: editing a secret is replace-only
|
|
11
|
+
by design.
|
|
12
|
+
|
|
13
|
+
What it does come back with is `mode` and `mismatchedKeys`, both derived from key *prefixes*.
|
|
14
|
+
Those are the two things somebody staring at a payment form that will not load needs to be told,
|
|
15
|
+
and neither of them requires revealing a key to answer.
|
|
16
|
+
*/
|
|
17
|
+
export default async (request, response) => {
|
|
18
|
+
const [gateError] = await gate(request, 'payments:settings');
|
|
19
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
20
|
+
|
|
21
|
+
const config = await readConfig();
|
|
22
|
+
const [providerError, provider] = await activeProvider();
|
|
23
|
+
|
|
24
|
+
const status = providerError ? null : (await provider.status())[1];
|
|
25
|
+
|
|
26
|
+
response.json({
|
|
27
|
+
settings: config,
|
|
28
|
+
providers: listProviders(),
|
|
29
|
+
status,
|
|
30
|
+
/*
|
|
31
|
+
Said here rather than left for the admin to discover through a save that appears to work and
|
|
32
|
+
does nothing. Without this key kempo cannot store a secret setting at all.
|
|
33
|
+
*/
|
|
34
|
+
encryptionConfigured: !!process.env.SETTINGS_ENCRYPTION_KEY,
|
|
35
|
+
});
|
|
36
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { setSetting } from 'kempo/server/sdk.js';
|
|
2
|
+
import { getProvider, listProviders } from '../../../server/providers/index.js';
|
|
3
|
+
import { OWNER, CAPTURE_METHODS, parseCaptureMethod, readConfig } from '../../../server/utils/config/settings.js';
|
|
4
|
+
import { typeOf, descriptionOf, DECLARED_SETTINGS } from '../../../server/utils/config/declared.js';
|
|
5
|
+
import { isValidCurrency, normaliseCurrency } from '../../../server/utils/money/currencies.js';
|
|
6
|
+
import { gate } from '../../../server/utils/permissions/gate.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Saves the settings, one kempo setting per field.
|
|
10
|
+
|
|
11
|
+
These are ordinary kempo settings and also appear under /admin/settings grouped by owner —
|
|
12
|
+
nothing here is a second store. What this route adds is validation before the write, and its own
|
|
13
|
+
permission: `payments:settings` can be given to whoever connects the site to its processor
|
|
14
|
+
without also giving them `system:settings:update`, which is every setting on the installation.
|
|
15
|
+
|
|
16
|
+
**Blank means unchanged, not cleared.** A secret is never sent to the browser, so the field
|
|
17
|
+
showing it is always empty — saving the form after editing something else would otherwise wipe
|
|
18
|
+
the site's API keys and stop it taking money. Clearing one is possible, but it takes an explicit
|
|
19
|
+
`null`, which no accidental submit produces.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
Exported so a static test can check every name here is declared in kempo-config.json. A setting
|
|
24
|
+
written without its declared type is the specific failure that matters most in this extension:
|
|
25
|
+
`secret` is what makes an API key encrypted at rest, and a write that loses the type stores it as
|
|
26
|
+
plain text in a row the admin settings screen will show to anyone who can open it.
|
|
27
|
+
*/
|
|
28
|
+
export const SAVEABLE = [
|
|
29
|
+
'provider',
|
|
30
|
+
'currency',
|
|
31
|
+
'capture_method',
|
|
32
|
+
...listProviders().flatMap(provider => provider.credentialFields.map(field => field.name)),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export default async (request, response) => {
|
|
36
|
+
const [gateError] = await gate(request, 'payments:settings');
|
|
37
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
38
|
+
|
|
39
|
+
if(!process.env.SETTINGS_ENCRYPTION_KEY){
|
|
40
|
+
return response.status(500).json({
|
|
41
|
+
error: 'SETTINGS_ENCRYPTION_KEY is not set on this server, so API credentials cannot be stored. Add it to the environment and restart.',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const body = request.body || {};
|
|
46
|
+
const updates = {};
|
|
47
|
+
|
|
48
|
+
if(body.provider !== undefined){
|
|
49
|
+
const [error] = getProvider(String(body.provider));
|
|
50
|
+
if(error) return response.status(400).json({ error: error.msg });
|
|
51
|
+
updates.provider = String(body.provider);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if(body.currency !== undefined){
|
|
55
|
+
const currency = normaliseCurrency(body.currency);
|
|
56
|
+
if(!isValidCurrency(currency)){
|
|
57
|
+
return response.status(400).json({ error: 'Currency must be a three-letter ISO code, such as usd or gbp' });
|
|
58
|
+
}
|
|
59
|
+
updates.currency = currency;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if(body.capture_method !== undefined){
|
|
63
|
+
const method = parseCaptureMethod(body.capture_method);
|
|
64
|
+
if(!method){
|
|
65
|
+
return response.status(400).json({ error: `Capture method must be one of: ${CAPTURE_METHODS.join(', ')}` });
|
|
66
|
+
}
|
|
67
|
+
updates.capture_method = method;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for(const provider of listProviders()){
|
|
71
|
+
for(const field of provider.credentialFields){
|
|
72
|
+
const value = body[field.name];
|
|
73
|
+
if(value === undefined) continue;
|
|
74
|
+
|
|
75
|
+
// The deliberate clear. Anything else empty is a form that simply had nothing typed in it.
|
|
76
|
+
if(value === null){
|
|
77
|
+
updates[field.name] = '';
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const trimmed = String(value).trim();
|
|
82
|
+
if(!trimmed) continue;
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
The one paste that has to be refused rather than warned about. A secret key in the
|
|
86
|
+
publishable field would be handed to every visitor's browser by the config route, and by
|
|
87
|
+
the time anybody noticed it would have to be treated as compromised and rotated.
|
|
88
|
+
*/
|
|
89
|
+
if(field.type !== 'secret' && /^(sk|rk)_/.test(trimmed)){
|
|
90
|
+
return response.status(400).json({
|
|
91
|
+
error: `That looks like a secret key, not a ${field.label.toLowerCase()}. It would be sent to every visitor’s browser — check you have the right one.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
updates[field.name] = trimmed;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if(!Object.keys(updates).length){
|
|
100
|
+
return response.status(400).json({ error: 'Nothing to save' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for(const [name, value] of Object.entries(updates)){
|
|
104
|
+
// Belt and braces: nothing reaches setSetting that is not on the list above.
|
|
105
|
+
if(!SAVEABLE.includes(name) || !DECLARED_SETTINGS.has(name)) continue;
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
The type and description come from kempo-config.json rather than being restated here.
|
|
109
|
+
setSetting stores exactly what it is handed, so passing null for the description would erase
|
|
110
|
+
the text kempo's own /admin/settings screen shows next to each of these.
|
|
111
|
+
|
|
112
|
+
isPublic stays false throughout, and setSetting forces it false for a secret regardless.
|
|
113
|
+
*/
|
|
114
|
+
const [error] = await setSetting(OWNER, name, value, typeOf(name), false, descriptionOf(name));
|
|
115
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const config = await readConfig();
|
|
119
|
+
const [providerError, provider] = getProvider(config.provider);
|
|
120
|
+
const status = providerError ? null : (await provider.status())[1];
|
|
121
|
+
|
|
122
|
+
response.json({ settings: config, status });
|
|
123
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { summary } from '../../../server/utils/payments/store.js';
|
|
2
|
+
import { gate } from '../../../server/utils/permissions/gate.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The counts and totals across the top of the admin screen, grouped by status and currency.
|
|
6
|
+
|
|
7
|
+
Grouped by currency because summing across them is meaningless — 100 JPY and 100 GBP added
|
|
8
|
+
together is 200 of nothing.
|
|
9
|
+
*/
|
|
10
|
+
export default async (request, response) => {
|
|
11
|
+
const [gateError] = await gate(request, 'payments:view');
|
|
12
|
+
if(gateError) return response.status(gateError.code).json({ error: gateError.msg });
|
|
13
|
+
|
|
14
|
+
const [error, result] = await summary();
|
|
15
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
16
|
+
|
|
17
|
+
response.json(result);
|
|
18
|
+
};
|