shutro-erp 0.3.1 → 0.3.3

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