shutro-erp 0.3.0 → 0.3.2
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 +58 -58
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +31 -1
- package/dist/account_portal/index.js +3 -2
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +31 -1
- package/dist/accounting/index.js +4 -0
- package/dist/assistant/routes.js +1 -1
- package/dist/automation/engine.js +1 -1
- package/dist/automation/web/messages.js +2 -2
- package/dist/cli/apps.d.ts +1 -1
- package/dist/cli/apps.js +14 -14
- package/dist/cli/bin.js +3 -0
- package/dist/cli/index.js +11 -11
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +60 -36
- package/dist/cli/launch.js +10 -7
- package/dist/cli/project.d.ts +7 -7
- package/dist/cli/project.js +25 -25
- package/dist/cli/prompt.js +2 -2
- package/dist/cli/templates.d.ts +3 -3
- package/dist/cli/templates.js +33 -28
- package/dist/cli/ui.d.ts +1 -8
- package/dist/cli/ui.js +36 -12
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +31 -1
- package/dist/integrations/inbound.d.ts +36 -0
- package/dist/integrations/inbound.js +171 -0
- package/dist/integrations/index.d.ts +18 -0
- package/dist/integrations/index.js +400 -0
- package/dist/integrations/models.d.ts +6 -0
- package/dist/integrations/models.js +119 -0
- package/dist/integrations/web/DeliveryTools.d.ts +4 -0
- package/dist/integrations/web/DeliveryTools.js +15 -0
- package/dist/integrations/web/EndpointTools.d.ts +7 -0
- package/dist/integrations/web/EndpointTools.js +25 -0
- package/dist/integrations/web/InboundTools.d.ts +4 -0
- package/dist/integrations/web/InboundTools.js +15 -0
- package/dist/integrations/web/WebhookTools.d.ts +7 -0
- package/dist/integrations/web/WebhookTools.js +25 -0
- package/dist/integrations/web/index.d.ts +16 -0
- package/dist/integrations/web/index.js +27 -0
- package/dist/integrations/web/messages.d.ts +2 -0
- package/dist/integrations/web/messages.js +53 -0
- package/dist/integrations/webhooks.d.ts +63 -0
- package/dist/integrations/webhooks.js +217 -0
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +31 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +31 -1
- package/dist/l10n_in/einvoice.js +1 -1
- package/dist/l10n_in/web/messages.js +2 -2
- package/dist/mail/index.js +2 -2
- package/dist/modules-sdk/index.d.ts +1 -1
- package/dist/modules-sdk/loader.js +2 -2
- package/dist/modules-sdk/manifest.d.ts +48 -0
- package/dist/notify/messages.js +3 -3
- package/dist/notify/routes.js +1 -1
- package/dist/notify/web/messages.js +1 -1
- package/dist/payment/.kosh/bindings.d.ts +22 -0
- package/dist/payment/.kosh/bindings.js +15 -0
- package/dist/payment/.kosh/schema.d.ts +1553 -0
- package/dist/payment/index.d.ts +34 -0
- package/dist/payment/index.js +375 -0
- package/dist/payment/module.d.ts +6853 -0
- package/dist/payment/module.js +74 -0
- package/dist/payment/providers.d.ts +64 -0
- package/dist/payment/providers.js +77 -0
- package/dist/payment/settle.d.ts +11 -0
- package/dist/payment/settle.js +59 -0
- package/dist/payment/sslcommerz.d.ts +12 -0
- package/dist/payment/sslcommerz.js +185 -0
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +31 -1
- package/dist/portal/routes-documents.js +5 -0
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +31 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +31 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +31 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +31 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +31 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +31 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +31 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +31 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +31 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +31 -1
- package/dist/sales/index.js +2 -0
- package/dist/server/api/client.js +1 -1
- package/dist/server/api/openapi.js +17 -1
- package/dist/server/api/rest.d.ts +6 -1
- package/dist/server/api/rest.js +11 -0
- package/dist/server/apps.js +5 -5
- package/dist/server/auth/api-keys.d.ts +13 -0
- package/dist/server/auth/api-keys.js +65 -0
- package/dist/server/auth/base.js +24 -0
- package/dist/server/auth/crypto.js +1 -1
- package/dist/server/auth/scopes.d.ts +12 -0
- package/dist/server/auth/scopes.js +51 -0
- package/dist/server/auth/service.d.ts +57 -1
- package/dist/server/auth/service.js +229 -5
- package/dist/server/demo.d.ts +1 -1
- package/dist/server/demo.js +1 -1
- package/dist/server/env-names.d.ts +2 -0
- package/dist/server/env-names.js +16 -0
- package/dist/server/host.js +5 -1
- package/dist/server/identity.d.ts +7 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/registry.js +1 -1
- package/dist/server/server.d.ts +5 -0
- package/dist/server/server.js +45 -5
- package/dist/settings/routes.js +1 -1
- package/dist/settings/web/ApiKeysPage.d.ts +8 -0
- package/dist/settings/web/ApiKeysPage.js +70 -0
- package/dist/settings/web/index.d.ts +1 -0
- package/dist/settings/web/index.js +4 -0
- package/dist/settings/web/messages.js +62 -0
- package/dist/storage/routes.js +1 -1
- package/dist/studio/build.js +1 -1
- package/dist/studio/generate.js +4 -4
- package/dist/studio/module.js +5 -5
- package/dist/studio/web/messages.js +2 -2
- package/dist/ui/app.js +26 -3
- package/dist/ui/i18n/messages.js +2 -2
- package/dist/ui/pwa/worker.js +2 -2
- package/dist/ui/registry.d.ts +15 -0
- package/dist/ui/registry.js +20 -0
- package/package.json +15 -3
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* `shutro-erp/payment`: online payments (RFC 0017 §5.5, step 4). A company's payment providers (Accounting ›
|
|
4
|
+
* Configuration), Pay now on the portal's open invoices, the provider's callbacks through an inbound webhook (logged,
|
|
5
|
+
* idempotent, retried — `shutro-erp/integrations`), and the payment registered and reconciled in Accounting, once.
|
|
6
|
+
* Routes:
|
|
7
|
+
*
|
|
8
|
+
* - `GET /api/portal/v1/payments/providers` the providers a portal user may pay with
|
|
9
|
+
* - `POST /api/portal/v1/payments` `{ document, id, provider }`: a transaction; where to go
|
|
10
|
+
* - `GET /api/portal/v1/payments/:reference` its state (asking the provider while it is pending)
|
|
11
|
+
* - `GET /api/v1/payment/fake/checkout/:reference` the fake provider's checkout page
|
|
12
|
+
* - `POST /api/v1/payment/fake/checkout/:reference?outcome=paid|declined`
|
|
13
|
+
* - `GET|POST /api/v1/payment/return/:reference` back from the provider: on to the portal
|
|
14
|
+
*
|
|
15
|
+
* The callbacks arrive at `POST /api/v1/hooks/<token>` (the provider's endpoint, made with its first payment).
|
|
16
|
+
*/
|
|
17
|
+
import { type KoshModule } from '../modules-sdk/index.js';
|
|
18
|
+
import { type PaymentAdapter } from './providers.js';
|
|
19
|
+
export { newReference, PaymentProvider, PaymentTransaction, PROVIDERS, paymentShutro, } from './module.js';
|
|
20
|
+
export { ADAPTERS, FAKE_SIGNATURE, fakeAdapter, type PaymentAdapter, type PaymentRequest, type PaymentResult, type PaymentStatus, } from './providers.js';
|
|
21
|
+
export { type Settled, settle } from './settle.js';
|
|
22
|
+
export { type SslcommerzOptions, sslcommerzAdapter, sslcommerzFromEnvironment, } from './sslcommerz.js';
|
|
23
|
+
export interface PaymentOptions {
|
|
24
|
+
/** The adapters by code (tests; default: the fake). */
|
|
25
|
+
readonly adapters?: ReadonlyMap<string, PaymentAdapter>;
|
|
26
|
+
/** The portal's address: where providers send customers back (`SHUTRO_ERP_PORTAL_URL`). */
|
|
27
|
+
readonly portalUrl?: string;
|
|
28
|
+
/** The API's public address: where providers call back (`SHUTRO_ERP_API_URL`). */
|
|
29
|
+
readonly apiUrl?: string;
|
|
30
|
+
}
|
|
31
|
+
/** Online payments with explicit options (tests, installations wiring their own adapters). */
|
|
32
|
+
export declare function paymentModule(options?: PaymentOptions): KoshModule;
|
|
33
|
+
/** Online payments (the fake adapter; addresses from the environment). */
|
|
34
|
+
export declare const payment: KoshModule;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* `shutro-erp/payment`: online payments (RFC 0017 §5.5, step 4). A company's payment providers (Accounting ›
|
|
4
|
+
* Configuration), Pay now on the portal's open invoices, the provider's callbacks through an inbound webhook (logged,
|
|
5
|
+
* idempotent, retried — `shutro-erp/integrations`), and the payment registered and reconciled in Accounting, once.
|
|
6
|
+
* Routes:
|
|
7
|
+
*
|
|
8
|
+
* - `GET /api/portal/v1/payments/providers` the providers a portal user may pay with
|
|
9
|
+
* - `POST /api/portal/v1/payments` `{ document, id, provider }`: a transaction; where to go
|
|
10
|
+
* - `GET /api/portal/v1/payments/:reference` its state (asking the provider while it is pending)
|
|
11
|
+
* - `GET /api/v1/payment/fake/checkout/:reference` the fake provider's checkout page
|
|
12
|
+
* - `POST /api/v1/payment/fake/checkout/:reference?outcome=paid|declined`
|
|
13
|
+
* - `GET|POST /api/v1/payment/return/:reference` back from the provider: on to the portal
|
|
14
|
+
*
|
|
15
|
+
* The callbacks arrive at `POST /api/v1/hooks/<token>` (the provider's endpoint, made with its first payment).
|
|
16
|
+
*/
|
|
17
|
+
import { accountPortal } from '../account_portal/index.js';
|
|
18
|
+
import { accounting } from '../accounting/index.js';
|
|
19
|
+
import { integrations, receive } from '../integrations/index.js';
|
|
20
|
+
import { defineModule } from '../modules-sdk/index.js';
|
|
21
|
+
import { portalDocuments } from '../portal/index.js';
|
|
22
|
+
import { HttpProblem } from '../server/index.js';
|
|
23
|
+
import { orm } from '../server/orm.js';
|
|
24
|
+
import { action, form, list } from '../views/index.js';
|
|
25
|
+
import { PaymentProvider, PaymentTransaction, paymentShutro } from './module.js';
|
|
26
|
+
import { ADAPTERS, FAKE_SIGNATURE, fakeAdapter } from './providers.js';
|
|
27
|
+
import { settle } from './settle.js';
|
|
28
|
+
import { sslcommerzFromEnvironment } from './sslcommerz.js';
|
|
29
|
+
export { newReference, PaymentProvider, PaymentTransaction, PROVIDERS, paymentShutro, } from './module.js';
|
|
30
|
+
export { ADAPTERS, FAKE_SIGNATURE, fakeAdapter, } from './providers.js';
|
|
31
|
+
export { settle } from './settle.js';
|
|
32
|
+
export { sslcommerzAdapter, sslcommerzFromEnvironment, } from './sslcommerz.js';
|
|
33
|
+
const typed = (env) => env;
|
|
34
|
+
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
35
|
+
/** A callback endpoint per adapter: verified by the adapter with the endpoint's secret, settled as the system. */
|
|
36
|
+
function callbackHooks(adapters) {
|
|
37
|
+
return [...adapters.values()].map((adapter) => ({
|
|
38
|
+
key: adapter.code,
|
|
39
|
+
label: `Payment callbacks (${adapter.code})`,
|
|
40
|
+
verify: (call, secret) => adapter.verifyCallback(call, secret),
|
|
41
|
+
handle: async (env, call) => {
|
|
42
|
+
const result = adapter.readCallback(call);
|
|
43
|
+
if (result === null)
|
|
44
|
+
return 'Not a payment event.';
|
|
45
|
+
const settled = await settle(typed(env), result);
|
|
46
|
+
return settled === null
|
|
47
|
+
? `No transaction ${result.reference} in this company.`
|
|
48
|
+
: `${settled.reference}: ${settled.state}${settled.changed ? '' : ' (already)'}`;
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function registerPaymentRoutes(context, adapters, urls) {
|
|
53
|
+
const { app } = context;
|
|
54
|
+
const documents = portalDocuments(context.registry);
|
|
55
|
+
const portalOnly = (env) => {
|
|
56
|
+
if (!env.groups.has('portal.user') || env.groups.has('base.user'))
|
|
57
|
+
throw new HttpProblem(403, 'portal-only', 'Portal users only', 'This is the portal’s API.');
|
|
58
|
+
};
|
|
59
|
+
const adapterOf = (code) => {
|
|
60
|
+
const adapter = adapters.get(code);
|
|
61
|
+
if (adapter === undefined)
|
|
62
|
+
throw new HttpProblem(409, 'no-adapter', 'Provider unavailable', `The ${code} adapter is not installed.`);
|
|
63
|
+
return adapter;
|
|
64
|
+
};
|
|
65
|
+
/** The provider's callback endpoint (made on first use), with its token. */
|
|
66
|
+
const endpointOf = async (su, provider) => {
|
|
67
|
+
const [p] = await su
|
|
68
|
+
.model('payment.provider')
|
|
69
|
+
.browse([provider.id])
|
|
70
|
+
.load({ endpoint: true });
|
|
71
|
+
if (p?.endpoint !== null && p?.endpoint !== undefined)
|
|
72
|
+
return p.endpoint.token;
|
|
73
|
+
const made = await su
|
|
74
|
+
.model('webhook.endpoint')
|
|
75
|
+
.create([{ name: `${provider.name} callbacks`, hook: `payment.${provider.code}` }]);
|
|
76
|
+
await su
|
|
77
|
+
.model('payment.provider')
|
|
78
|
+
.browse([provider.id])
|
|
79
|
+
.write({ endpoint: made.ids[0] });
|
|
80
|
+
const [e] = await made.load({});
|
|
81
|
+
return e?.token ?? '';
|
|
82
|
+
};
|
|
83
|
+
app.get('/api/portal/v1/payments/providers', async (request) => request.env((env) => {
|
|
84
|
+
portalOnly(env);
|
|
85
|
+
return env.asSuperuser({ reason: 'portal: payment providers' }, async (su) => {
|
|
86
|
+
const found = await typed(su)
|
|
87
|
+
.model('payment.provider')
|
|
88
|
+
.search({
|
|
89
|
+
where: {
|
|
90
|
+
state: { in: ['test', 'live'] },
|
|
91
|
+
company: orm.asId(env.companyId, 'res.company'),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
providers: [...(await found.load({}))]
|
|
96
|
+
.filter((p) => adapters.get(p.code)?.configured(p.state === 'live' ? 'live' : 'test') === true)
|
|
97
|
+
.map((p) => ({ id: p.id, name: p.name, test: p.state === 'test' })),
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}, { write: true }));
|
|
101
|
+
app.post('/api/portal/v1/payments', async (request, reply) => {
|
|
102
|
+
const body = (request.body ?? {});
|
|
103
|
+
const key = String(body['document'] ?? '');
|
|
104
|
+
const id = Number(body['id']);
|
|
105
|
+
const providerId = Number(body['provider']);
|
|
106
|
+
const d = documents.get(key);
|
|
107
|
+
if (d === undefined || d.spec.payment === undefined || d.model.name !== 'account.move')
|
|
108
|
+
throw new HttpProblem(404, 'not-found', 'Not payable', `Documents '${key}' are not paid online.`);
|
|
109
|
+
const company = await request.env(async (env) => {
|
|
110
|
+
portalOnly(env);
|
|
111
|
+
// Theirs (the record rule) and payable now (the declaration), searched as the portal user.
|
|
112
|
+
const theirs = await env.model(d.model.name).search({
|
|
113
|
+
where: { and: [d.spec.domain ?? {}, { id }, d.spec.payment?.when ?? {}] },
|
|
114
|
+
limit: 1,
|
|
115
|
+
});
|
|
116
|
+
if (theirs.length === 0)
|
|
117
|
+
throw new HttpProblem(409, 'not-payable', 'Nothing to pay', 'This document is not open for payment.');
|
|
118
|
+
return env.companyId;
|
|
119
|
+
}, { write: true });
|
|
120
|
+
// Started by the system once the document is known to be the customer's (as the portal's own actions are).
|
|
121
|
+
const started = await context.shutro.bootstrap({ reason: 'portal: start a payment', company }, async (raw) => {
|
|
122
|
+
const su = typed(raw);
|
|
123
|
+
const [provider] = await (await su.model('payment.provider').search({
|
|
124
|
+
where: {
|
|
125
|
+
id: orm.asId(providerId, 'payment.provider'),
|
|
126
|
+
state: { in: ['test', 'live'] },
|
|
127
|
+
company: orm.asId(company, 'res.company'),
|
|
128
|
+
},
|
|
129
|
+
limit: 1,
|
|
130
|
+
})).load({});
|
|
131
|
+
if (provider === undefined)
|
|
132
|
+
throw new HttpProblem(422, 'invalid-provider', 'No such provider', 'Choose one of the providers offered.');
|
|
133
|
+
const adapter = adapterOf(provider.code);
|
|
134
|
+
if (provider.code === 'fake' && provider.state === 'live')
|
|
135
|
+
throw new HttpProblem(409, 'fake-live', 'Test provider', 'The test provider moves no money: it cannot be live.');
|
|
136
|
+
if (!adapter.configured(provider.state === 'live' ? 'live' : 'test'))
|
|
137
|
+
throw new HttpProblem(409, 'not-configured', 'Provider not set up', `The ${provider.name} provider has no credentials for ${provider.state} payments on this server.`);
|
|
138
|
+
const [move] = await su
|
|
139
|
+
.model('account.move')
|
|
140
|
+
.browse([id])
|
|
141
|
+
.load({ partner: true, currency: true });
|
|
142
|
+
if (move === undefined)
|
|
143
|
+
throw new HttpProblem(404, 'not-found', 'No such document', 'No such document.');
|
|
144
|
+
const due = move.amountResidual?.amount;
|
|
145
|
+
if (due === undefined || !due.gt(0))
|
|
146
|
+
throw new HttpProblem(409, 'not-payable', 'Nothing to pay', 'Nothing is due on this document.');
|
|
147
|
+
const token = await endpointOf(su, provider);
|
|
148
|
+
const created = await su.model('payment.transaction').create([
|
|
149
|
+
{
|
|
150
|
+
provider: provider.id,
|
|
151
|
+
move: move.id,
|
|
152
|
+
partner: (move.partner?.id ?? null),
|
|
153
|
+
currency: move.currency.id,
|
|
154
|
+
amount: due.toFixed(),
|
|
155
|
+
company: company,
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
await su.flush(); // company checks run in this superuser scope, not at commit (Shutro request #11)
|
|
159
|
+
const [tx] = await created.load({});
|
|
160
|
+
const reference = tx?.reference ?? '';
|
|
161
|
+
const { redirectUrl } = await adapter.createPayment({
|
|
162
|
+
reference,
|
|
163
|
+
amount: due.toFixed(move.currency.decimalPlaces ?? 2),
|
|
164
|
+
currency: move.currency.name,
|
|
165
|
+
description: `${d.spec.label} ${move.name}`,
|
|
166
|
+
customer: {
|
|
167
|
+
name: move.partner?.name ?? '',
|
|
168
|
+
email: move.partner?.email ?? null,
|
|
169
|
+
phone: move.partner?.phone ?? move.partner?.mobile ?? null,
|
|
170
|
+
street: move.partner?.street ?? null,
|
|
171
|
+
city: move.partner?.city ?? null,
|
|
172
|
+
zip: move.partner?.zip ?? null,
|
|
173
|
+
country: move.partner?.country ?? null,
|
|
174
|
+
},
|
|
175
|
+
returnUrl: `${urls.api}/api/v1/payment/return/${encodeURIComponent(reference)}`,
|
|
176
|
+
callbackUrl: `${urls.api}/api/v1/hooks/${token}`,
|
|
177
|
+
mode: provider.state === 'live' ? 'live' : 'test',
|
|
178
|
+
});
|
|
179
|
+
return { reference, redirectUrl };
|
|
180
|
+
});
|
|
181
|
+
return reply.status(201).send(started);
|
|
182
|
+
});
|
|
183
|
+
app.get('/api/portal/v1/payments/:reference', async (request) => {
|
|
184
|
+
const reference = String(request.params['reference'] ?? '');
|
|
185
|
+
const found = await request.env(async (env) => {
|
|
186
|
+
portalOnly(env);
|
|
187
|
+
const tx = await env.asSuperuser({ reason: 'portal: a payment' }, async (raw) => {
|
|
188
|
+
const [t] = await (await typed(raw)
|
|
189
|
+
.model('payment.transaction')
|
|
190
|
+
.search({
|
|
191
|
+
where: { reference, company: orm.asId(env.companyId, 'res.company') },
|
|
192
|
+
limit: 1,
|
|
193
|
+
})).load({ provider: true, currency: true });
|
|
194
|
+
return t === undefined
|
|
195
|
+
? null
|
|
196
|
+
: {
|
|
197
|
+
move: t.move.id,
|
|
198
|
+
state: t.state,
|
|
199
|
+
amount: t.amount.amount.toFixed(t.currency.decimalPlaces ?? 2),
|
|
200
|
+
currency: t.currency.name,
|
|
201
|
+
code: t.provider.code,
|
|
202
|
+
mode: t.provider.state === 'live' ? 'live' : 'test',
|
|
203
|
+
error: t.error,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
// Theirs: the invoice is one they may read.
|
|
207
|
+
if (tx === null ||
|
|
208
|
+
(await env
|
|
209
|
+
.model('account.move')
|
|
210
|
+
.search({ where: { id: tx.move }, limit: 1 })).length === 0)
|
|
211
|
+
throw new HttpProblem(404, 'not-found', 'No such payment', `No payment ${reference}.`);
|
|
212
|
+
return { ...tx, company: orm.asId(env.companyId, 'res.company') };
|
|
213
|
+
}, { write: true });
|
|
214
|
+
let state = found.state;
|
|
215
|
+
let error = found.error;
|
|
216
|
+
// Back before the callback: the provider's word settles it now (once; the callback then finds it settled).
|
|
217
|
+
if (state === 'pending') {
|
|
218
|
+
const result = await adapters.get(found.code)?.status(reference, found.mode);
|
|
219
|
+
if (result !== null && result !== undefined && result.status !== 'pending') {
|
|
220
|
+
const settled = await context.shutro.bootstrap({ reason: `payment ${reference}: the customer returned`, company: found.company }, (env) => settle(typed(env), result));
|
|
221
|
+
state = settled?.state ?? state;
|
|
222
|
+
error =
|
|
223
|
+
settled?.state === 'failed'
|
|
224
|
+
? (result.error ?? 'The provider refused the payment.')
|
|
225
|
+
: error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { reference, state, amount: found.amount, currency: found.currency, error };
|
|
229
|
+
});
|
|
230
|
+
// The fake provider's checkout: no sign-in (the customer is on the provider's page), test transactions only.
|
|
231
|
+
const fakeTx = (reference) => context.shutro.bootstrap({ reason: 'fake payment checkout' }, (raw) => raw.asSuperuser({ reason: 'fake payment checkout' }, async (su) => {
|
|
232
|
+
const [t] = await (await typed(su.withFlags({ checkCompany: false }))
|
|
233
|
+
.model('payment.transaction')
|
|
234
|
+
.search({ where: { reference, state: 'pending' }, limit: 1 })).load({ provider: { endpoint: true } });
|
|
235
|
+
return t === undefined || t.provider.code !== 'fake' || t.provider.endpoint === null
|
|
236
|
+
? null
|
|
237
|
+
: { token: t.provider.endpoint.token, secret: t.provider.endpoint.secret };
|
|
238
|
+
}));
|
|
239
|
+
app.get('/api/v1/payment/fake/checkout/:reference', { config: { auth: false } }, async (request, reply) => {
|
|
240
|
+
const reference = String(request.params['reference'] ?? '');
|
|
241
|
+
const pending = fakeAdapter.pending(reference);
|
|
242
|
+
if (pending === null || (await fakeTx(reference)) === null)
|
|
243
|
+
throw new HttpProblem(404, 'not-found', 'No such payment', 'This payment is not waiting.');
|
|
244
|
+
const action = (outcome) => `/api/v1/payment/fake/checkout/${encodeURIComponent(reference)}?outcome=${outcome}`;
|
|
245
|
+
return reply
|
|
246
|
+
.type('text/html; charset=utf-8')
|
|
247
|
+
.header('content-security-policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
|
|
248
|
+
.send(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
249
|
+
<title>Test payment</title><style>body{font:16px system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;color:#111;background:#fff}
|
|
250
|
+
@media (prefers-color-scheme:dark){body{color:#eee;background:#111}}button{font:inherit;padding:.6rem 1rem;margin:.25rem .5rem 0 0;border-radius:.5rem;border:1px solid #888;cursor:pointer}
|
|
251
|
+
.pay{background:#16a34a;color:#fff;border-color:#16a34a}.note{font-size:.85rem;opacity:.75}</style></head>
|
|
252
|
+
<body><h1>Test payment</h1><p class="note">Test provider: no money moves.</p>
|
|
253
|
+
<p>${escapeHtml(pending.description)}</p><p><strong>${escapeHtml(pending.amount)} ${escapeHtml(pending.currency)}</strong></p>
|
|
254
|
+
<form method="post" action="${escapeHtml(action('paid'))}"><button class="pay" type="submit">Pay</button></form>
|
|
255
|
+
<form method="post" action="${escapeHtml(action('declined'))}"><button type="submit">Decline</button></form></body></html>`);
|
|
256
|
+
});
|
|
257
|
+
// Browsers come back from providers (and the fake checkout's forms) with form posts: any body is accepted and
|
|
258
|
+
// ignored — the provider's word comes from its callback or its API, never from the customer's browser.
|
|
259
|
+
app.register(async (scope) => {
|
|
260
|
+
scope.removeAllContentTypeParsers();
|
|
261
|
+
scope.addContentTypeParser('*', { parseAs: 'string', bodyLimit: 64 * 1024 }, (_req, _body, done) => done(null, null));
|
|
262
|
+
// Back from the provider (GET or POST): on to the document in the portal, which shows the payment's state.
|
|
263
|
+
const payable = [...documents.values()].find((d) => d.model.name === 'account.move' && d.spec.payment !== undefined)?.spec.key ?? 'invoices';
|
|
264
|
+
scope.route({
|
|
265
|
+
method: ['GET', 'POST'],
|
|
266
|
+
url: '/api/v1/payment/return/:reference',
|
|
267
|
+
config: { auth: false },
|
|
268
|
+
handler: async (request, reply) => {
|
|
269
|
+
const reference = String(request.params['reference'] ?? '');
|
|
270
|
+
const move = await context.shutro.bootstrap({ reason: 'payment return' }, (raw) => raw.asSuperuser({ reason: 'payment return: which invoice' }, async (su) => {
|
|
271
|
+
const [t] = await (await typed(su.withFlags({ checkCompany: false }))
|
|
272
|
+
.model('payment.transaction')
|
|
273
|
+
.search({ where: { reference }, limit: 1 })).load({});
|
|
274
|
+
return t?.move.id ?? null;
|
|
275
|
+
}));
|
|
276
|
+
return reply.redirect(move === null
|
|
277
|
+
? `${urls.portal}/`
|
|
278
|
+
: `${urls.portal}/d/${encodeURIComponent(payable)}/${move}?payment=${encodeURIComponent(reference)}`, 303);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
scope.post('/api/v1/payment/fake/checkout/:reference', { config: { auth: false } }, async (request, reply) => {
|
|
282
|
+
const reference = String(request.params['reference'] ?? '');
|
|
283
|
+
const paid = request.query['outcome'] === 'paid';
|
|
284
|
+
const pending = fakeAdapter.pending(reference);
|
|
285
|
+
const tx = await fakeTx(reference);
|
|
286
|
+
if (pending === null || tx === null)
|
|
287
|
+
throw new HttpProblem(404, 'not-found', 'No such payment', 'This payment is not waiting.');
|
|
288
|
+
const callback = fakeAdapter.complete(reference, paid, tx.secret);
|
|
289
|
+
if (callback !== null)
|
|
290
|
+
// The provider calls back (in process: the fake has no network of its own).
|
|
291
|
+
await receive(context, tx.token, {
|
|
292
|
+
headers: { 'content-type': 'application/json', [FAKE_SIGNATURE]: callback.signature },
|
|
293
|
+
query: {},
|
|
294
|
+
body: callback.body,
|
|
295
|
+
json: JSON.parse(callback.body),
|
|
296
|
+
});
|
|
297
|
+
return reply.redirect(pending.returnUrl, 303);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** Online payments with explicit options (tests, installations wiring their own adapters). */
|
|
302
|
+
export function paymentModule(options = {}) {
|
|
303
|
+
const adapters = options.adapters ?? new Map([...ADAPTERS, ['sslcommerz', sslcommerzFromEnvironment()]]);
|
|
304
|
+
return defineModule({
|
|
305
|
+
shutro: paymentShutro,
|
|
306
|
+
kosh: '^0.1.0',
|
|
307
|
+
depends: [accounting, accountPortal, integrations],
|
|
308
|
+
accessLevels: {
|
|
309
|
+
manager: { label: 'Payment providers', groups: ['account.manager'] },
|
|
310
|
+
},
|
|
311
|
+
inboundWebhooks: callbackHooks(adapters),
|
|
312
|
+
actions: {
|
|
313
|
+
'payment.action_providers': action(PaymentProvider, {
|
|
314
|
+
label: 'Payment providers',
|
|
315
|
+
views: ['list', 'form'],
|
|
316
|
+
}),
|
|
317
|
+
'payment.action_transactions': action(PaymentTransaction, {
|
|
318
|
+
label: 'Online payments',
|
|
319
|
+
views: ['list', 'form'],
|
|
320
|
+
}),
|
|
321
|
+
},
|
|
322
|
+
menus: [
|
|
323
|
+
{
|
|
324
|
+
id: 'payment.transactions',
|
|
325
|
+
parent: 'accounting.root',
|
|
326
|
+
label: 'Online payments',
|
|
327
|
+
action: 'payment.action_transactions',
|
|
328
|
+
sequence: 3.5,
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'payment.providers',
|
|
332
|
+
parent: 'accounting.configuration',
|
|
333
|
+
label: 'Payment providers',
|
|
334
|
+
action: 'payment.action_providers',
|
|
335
|
+
sequence: 3.7,
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
views: {
|
|
339
|
+
'payment.provider.list': list(PaymentProvider, {
|
|
340
|
+
columns: ['name', 'code', 'state', 'journal'],
|
|
341
|
+
}),
|
|
342
|
+
'payment.provider.form': form(PaymentProvider, (k) => ({
|
|
343
|
+
sheet: [
|
|
344
|
+
k.group('main', ['name', 'code', 'state']),
|
|
345
|
+
k.group('accounting', ['journal', 'sequence', 'endpoint']),
|
|
346
|
+
],
|
|
347
|
+
})),
|
|
348
|
+
'payment.transaction.list': list(PaymentTransaction, {
|
|
349
|
+
columns: ['reference', 'move', 'partner', 'provider', 'amount', 'state', 'doneAt'],
|
|
350
|
+
}),
|
|
351
|
+
'payment.transaction.form': form(PaymentTransaction, (k) => ({
|
|
352
|
+
sheet: [
|
|
353
|
+
k.group('main', ['reference', 'move', 'partner', 'provider']),
|
|
354
|
+
k.group('outcome', [
|
|
355
|
+
'amount',
|
|
356
|
+
'state',
|
|
357
|
+
'providerReference',
|
|
358
|
+
'payment',
|
|
359
|
+
'doneAt',
|
|
360
|
+
'error',
|
|
361
|
+
]),
|
|
362
|
+
],
|
|
363
|
+
readonlyWhen: { id: { ne: 0 } },
|
|
364
|
+
})),
|
|
365
|
+
},
|
|
366
|
+
routes: (context) => registerPaymentRoutes(context, adapters, {
|
|
367
|
+
portal: (options.portalUrl ??
|
|
368
|
+
process.env['KOSH_PORTAL_URL'] ??
|
|
369
|
+
'http://localhost:5174').replace(/\/$/, ''),
|
|
370
|
+
api: (options.apiUrl ?? process.env['KOSH_API_URL'] ?? 'http://localhost:3000').replace(/\/$/, ''),
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
/** Online payments (the fake adapter; addresses from the environment). */
|
|
375
|
+
export const payment = paymentModule();
|