shutro-erp 0.3.2 → 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 (130) 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/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +29 -1
  5. package/dist/assistant/index.js +1 -1
  6. package/dist/contacts/.kosh/bindings.js +1 -1
  7. package/dist/contacts/.kosh/schema.d.ts +29 -1
  8. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  9. package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
  10. package/dist/l10n_in/.kosh/bindings.js +1 -1
  11. package/dist/l10n_in/.kosh/schema.d.ts +29 -4
  12. package/dist/l10n_in/module.d.ts +0 -62
  13. package/dist/l10n_in/module.js +2 -4
  14. package/dist/payment/.kosh/bindings.js +1 -1
  15. package/dist/payment/.kosh/schema.d.ts +30 -2
  16. package/dist/payment/bkash.d.ts +28 -0
  17. package/dist/payment/bkash.js +169 -0
  18. package/dist/payment/index.d.ts +4 -0
  19. package/dist/payment/index.js +80 -24
  20. package/dist/payment/module.d.ts +7 -4
  21. package/dist/payment/module.js +3 -0
  22. package/dist/payment/money.d.ts +7 -0
  23. package/dist/payment/money.js +37 -0
  24. package/dist/payment/providers.d.ts +13 -2
  25. package/dist/payment/razorpay.d.ts +12 -0
  26. package/dist/payment/razorpay.js +149 -0
  27. package/dist/payment/sslcommerz.js +1 -1
  28. package/dist/payment/stripe.d.ts +15 -0
  29. package/dist/payment/stripe.js +182 -0
  30. package/dist/permissions/admin.d.ts +3 -0
  31. package/dist/permissions/admin.js +108 -10
  32. package/dist/permissions/index.d.ts +1 -0
  33. package/dist/permissions/index.js +3 -0
  34. package/dist/permissions/roles.d.ts +2 -0
  35. package/dist/permissions/roles.js +236 -0
  36. package/dist/portal/.kosh/bindings.js +1 -1
  37. package/dist/portal/.kosh/schema.d.ts +29 -1
  38. package/dist/product/.kosh/bindings.js +1 -1
  39. package/dist/product/.kosh/schema.d.ts +29 -1
  40. package/dist/project/.kosh/bindings.js +1 -1
  41. package/dist/project/.kosh/schema.d.ts +29 -1
  42. package/dist/project_portal/.kosh/bindings.js +1 -1
  43. package/dist/project_portal/.kosh/schema.d.ts +29 -1
  44. package/dist/purchase/.kosh/bindings.js +1 -1
  45. package/dist/purchase/.kosh/schema.d.ts +29 -1
  46. package/dist/purchase_account/.kosh/bindings.js +1 -1
  47. package/dist/purchase_account/.kosh/schema.d.ts +29 -1
  48. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  49. package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
  50. package/dist/sale_account/.kosh/bindings.js +1 -1
  51. package/dist/sale_account/.kosh/schema.d.ts +29 -1
  52. package/dist/sale_portal/.kosh/bindings.js +1 -1
  53. package/dist/sale_portal/.kosh/schema.d.ts +29 -1
  54. package/dist/sale_project/.kosh/bindings.js +1 -1
  55. package/dist/sale_project/.kosh/schema.d.ts +29 -1
  56. package/dist/sales/.kosh/bindings.js +1 -1
  57. package/dist/sales/.kosh/schema.d.ts +29 -1
  58. package/dist/server/auth/base.js +22 -0
  59. package/dist/server/auth/routes.d.ts +3 -0
  60. package/dist/server/auth/routes.js +29 -0
  61. package/dist/server/auth/service.d.ts +52 -0
  62. package/dist/server/auth/service.js +168 -3
  63. package/dist/server/identity.d.ts +2 -0
  64. package/dist/server/index.d.ts +1 -1
  65. package/dist/server/server.js +11 -3
  66. package/dist/settings/company.d.ts +34 -0
  67. package/dist/settings/company.js +187 -0
  68. package/dist/settings/index.d.ts +10 -10
  69. package/dist/settings/index.js +159 -30
  70. package/dist/settings/people.d.ts +16 -0
  71. package/dist/settings/people.js +505 -0
  72. package/dist/settings/security.d.ts +13 -0
  73. package/dist/settings/security.js +158 -0
  74. package/dist/settings/web/AuditPage.d.ts +8 -0
  75. package/dist/settings/web/AuditPage.js +74 -0
  76. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  77. package/dist/settings/web/CompaniesPage.js +52 -0
  78. package/dist/settings/web/CompanyPage.d.ts +9 -0
  79. package/dist/settings/web/CompanyPage.js +94 -0
  80. package/dist/settings/web/OverviewPage.d.ts +9 -0
  81. package/dist/settings/web/OverviewPage.js +66 -0
  82. package/dist/settings/web/PeoplePage.d.ts +9 -0
  83. package/dist/settings/web/PeoplePage.js +163 -0
  84. package/dist/settings/web/ProfilePage.d.ts +14 -0
  85. package/dist/settings/web/ProfilePage.js +194 -0
  86. package/dist/settings/web/RolesEditor.d.ts +35 -0
  87. package/dist/settings/web/RolesEditor.js +49 -0
  88. package/dist/settings/web/RolesPage.d.ts +9 -0
  89. package/dist/settings/web/RolesPage.js +296 -0
  90. package/dist/settings/web/SecurityPage.d.ts +8 -0
  91. package/dist/settings/web/SecurityPage.js +81 -0
  92. package/dist/settings/web/index.d.ts +9 -0
  93. package/dist/settings/web/index.js +26 -1
  94. package/dist/settings/web/messages.js +566 -0
  95. package/dist/ui/api.d.ts +4 -0
  96. package/dist/ui/app.d.ts +5 -0
  97. package/dist/ui/app.js +108 -31
  98. package/dist/ui/components/primitives.d.ts +9 -1
  99. package/dist/ui/components/primitives.js +12 -4
  100. package/dist/ui/context.d.ts +10 -1
  101. package/dist/ui/context.js +60 -2
  102. package/dist/ui/i18n/messages.d.ts +46 -0
  103. package/dist/ui/i18n/messages.js +92 -0
  104. package/dist/ui/index.d.ts +2 -1
  105. package/dist/ui/index.js +2 -1
  106. package/dist/ui/lib/accent.d.ts +13 -0
  107. package/dist/ui/lib/accent.js +36 -0
  108. package/dist/ui/shell/Activate.d.ts +6 -0
  109. package/dist/ui/shell/Activate.js +39 -0
  110. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  111. package/dist/ui/shell/CommandPalette.js +16 -2
  112. package/dist/ui/shell/Sidebar.d.ts +27 -0
  113. package/dist/ui/shell/Sidebar.js +84 -0
  114. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  115. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  116. package/dist/ui/shell/shortcuts.d.ts +3 -0
  117. package/dist/ui/shell/shortcuts.js +13 -4
  118. package/dist/ui/styles.css +70 -28
  119. package/dist/ui/views/DateField.d.ts +10 -0
  120. package/dist/ui/views/DateField.js +129 -0
  121. package/dist/ui/views/FormView.js +161 -21
  122. package/dist/ui/views/ListView.d.ts +2 -0
  123. package/dist/ui/views/ListView.js +8 -3
  124. package/dist/ui/views/StatusChip.d.ts +10 -0
  125. package/dist/ui/views/StatusChip.js +22 -0
  126. package/dist/ui/views/widgets.d.ts +3 -0
  127. package/dist/ui/views/widgets.js +26 -8
  128. package/dist/views/types.d.ts +7 -0
  129. package/dist/views/validate.js +4 -0
  130. package/package.json +2 -2
@@ -0,0 +1,169 @@
1
+ const BASES = {
2
+ sandbox: 'https://tokenized.sandbox.bka.sh/v2/tokenized-checkout',
3
+ live: 'https://tokenized.pay.bka.sh/v2/tokenized-checkout',
4
+ };
5
+ /** bKash's code for executing a payment that is already completed. */
6
+ const ALREADY_COMPLETED = '2062';
7
+ const text = (v) => (v === undefined || v === null ? '' : String(v));
8
+ const same = (a, b) => a !== '' && b !== '' && Number(a).toFixed(2) === Number(b).toFixed(2);
9
+ export function bkashAdapter(options = {}) {
10
+ const sandbox = options.sandbox ?? true;
11
+ const base = (options.baseUrl || (sandbox ? BASES.sandbox : BASES.live)).replace(/\/$/, '');
12
+ const http = options.fetch ?? fetch;
13
+ const ready = [options.username, options.password, options.appKey, options.appSecret].every((v) => v !== undefined && v !== '');
14
+ let token = null;
15
+ const grant = async () => {
16
+ if (!ready)
17
+ throw new Error('bKash is not configured: set SHUTRO_ERP_BKASH_USERNAME, _PASSWORD, _APP_KEY and _APP_SECRET.');
18
+ if (token !== null && token.until > Date.now())
19
+ return token.value;
20
+ const res = await http(`${base}/auth/grant-token`, {
21
+ method: 'POST',
22
+ headers: {
23
+ 'content-type': 'application/json',
24
+ accept: 'application/json',
25
+ username: options.username,
26
+ password: options.password,
27
+ },
28
+ body: JSON.stringify({ app_key: options.appKey, app_secret: options.appSecret }),
29
+ signal: AbortSignal.timeout(30_000),
30
+ });
31
+ const answer = (await res.json().catch(() => ({})));
32
+ const value = text(answer['id_token']);
33
+ if (value === '')
34
+ throw new Error(`bKash refused the credentials: ${text(answer['statusMessage']) || res.status}.`);
35
+ // Kept for its lifetime less five minutes (an hour by default).
36
+ const seconds = Number(answer['expires_in'] ?? 3600);
37
+ token = { value, until: Date.now() + Math.max(60, seconds - 300) * 1000 };
38
+ return value;
39
+ };
40
+ const post = async (path, body) => {
41
+ const res = await http(`${base}${path}`, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'content-type': 'application/json',
45
+ accept: 'application/json',
46
+ authorization: `Bearer ${await grant()}`,
47
+ 'x-app-key': options.appKey,
48
+ },
49
+ body: JSON.stringify(body),
50
+ signal: AbortSignal.timeout(30_000),
51
+ });
52
+ return (await res.json().catch(() => ({})));
53
+ };
54
+ /** bKash's word on a payment, checked against the transaction. */
55
+ const outcome = (reference, answer, expected = {}) => {
56
+ const status = text(answer['transactionStatus']);
57
+ const invoice = text(answer['merchantInvoiceNumber'] ?? answer['merchantInvoice']);
58
+ if (status !== 'Completed')
59
+ return null;
60
+ if (invoice !== reference ||
61
+ (expected.amount !== undefined && !same(text(answer['amount']), expected.amount)))
62
+ return {
63
+ reference,
64
+ status: 'failed',
65
+ providerReference: text(answer['trxId']) || null,
66
+ error: `bKash completed payment ${text(answer['paymentId'])} for ${invoice}, not this transaction.`,
67
+ };
68
+ return {
69
+ reference,
70
+ status: 'done',
71
+ providerReference: text(answer['trxId']) || null,
72
+ amount: text(answer['amount']),
73
+ currency: text(answer['currency']) || 'BDT',
74
+ };
75
+ };
76
+ const query = (paymentId) => post('/query/payment', { paymentId });
77
+ return {
78
+ code: 'bkash',
79
+ configured: (mode) => ready && (mode === 'test') === sandbox,
80
+ async createPayment(request) {
81
+ if (request.currency !== 'BDT')
82
+ throw new Error(`bKash takes payments in BDT only, not ${request.currency}.`);
83
+ const answer = await post('/payment/create', {
84
+ payerReference: request.customer.phone ?? request.reference,
85
+ callbackURL: request.returnUrl,
86
+ amount: request.amount,
87
+ currency: 'BDT',
88
+ intent: 'sale',
89
+ merchantInvoiceNumber: request.reference,
90
+ });
91
+ const paymentId = text(answer['paymentId'] ?? answer['paymentID']);
92
+ const page = text(answer['bkashURL'] ?? answer['bKashURL']);
93
+ if (paymentId === '' || page === '')
94
+ throw new Error(`bKash refused the payment: ${text(answer['errorMessageEn'] ?? answer['statusMessage']) || 'no payment id'}.`);
95
+ return { redirectUrl: page, providerReference: paymentId };
96
+ },
97
+ // No server-to-server callbacks: every call to the endpoint is refused.
98
+ verifyCallback: () => null,
99
+ readCallback: () => null,
100
+ async returned({ reference, providerReference, query: hint }) {
101
+ if (providerReference === null || providerReference === '')
102
+ return null;
103
+ if (hint['status'] === 'success') {
104
+ // Execute: the payment completes now, or bKash says why not.
105
+ const executed = await post('/payment/execute', { paymentId: providerReference });
106
+ const done = outcome(reference, executed);
107
+ if (done !== null)
108
+ return done;
109
+ const code = text(executed['externalCode'] ?? executed['statusCode']);
110
+ const known = await query(providerReference);
111
+ const completed = outcome(reference, known);
112
+ if (completed !== null)
113
+ return completed;
114
+ if (code === ALREADY_COMPLETED)
115
+ return null;
116
+ return {
117
+ reference,
118
+ status: 'failed',
119
+ providerReference,
120
+ error: text(executed['errorMessageEn'] ?? executed['statusMessage']) ||
121
+ 'bKash did not complete the payment.',
122
+ };
123
+ }
124
+ // Failed or cancelled on bKash's page — unless bKash says it completed.
125
+ const known = await query(providerReference);
126
+ const completed = outcome(reference, known);
127
+ if (completed !== null)
128
+ return completed;
129
+ return {
130
+ reference,
131
+ status: hint['status'] === 'cancel' ? 'cancelled' : 'failed',
132
+ providerReference,
133
+ ...(hint['status'] === 'cancel'
134
+ ? {}
135
+ : { error: 'The payment was not completed on bKash.' }),
136
+ };
137
+ },
138
+ async status(reference, _mode, providerReference) {
139
+ if (!ready || providerReference === null || providerReference === '')
140
+ return null;
141
+ const known = await query(providerReference);
142
+ return (outcome(reference, known) ??
143
+ (text(known['transactionStatus']) === 'Initiated'
144
+ ? { reference, status: 'pending', providerReference }
145
+ : null));
146
+ },
147
+ };
148
+ }
149
+ /** bKash with its credentials from the environment, read on first use. */
150
+ export function bkashFromEnvironment(env = process.env) {
151
+ let made;
152
+ const real = () => (made ??= bkashAdapter({
153
+ username: env['KOSH_BKASH_USERNAME'],
154
+ password: env['KOSH_BKASH_PASSWORD'],
155
+ appKey: env['KOSH_BKASH_APP_KEY'],
156
+ appSecret: env['KOSH_BKASH_APP_SECRET'],
157
+ sandbox: env['KOSH_BKASH_SANDBOX'] !== 'false',
158
+ baseUrl: env['KOSH_BKASH_BASE_URL'],
159
+ }));
160
+ return {
161
+ code: 'bkash',
162
+ configured: (mode) => real().configured(mode),
163
+ createPayment: (request) => real().createPayment(request),
164
+ verifyCallback: (call, secret) => real().verifyCallback(call, secret),
165
+ readCallback: (call) => real().readCallback(call),
166
+ returned: (input) => real().returned?.(input) ?? Promise.resolve(null),
167
+ status: (reference, mode, providerReference) => real().status(reference, mode, providerReference),
168
+ };
169
+ }
@@ -16,10 +16,14 @@
16
16
  */
17
17
  import { type KoshModule } from '../modules-sdk/index.js';
18
18
  import { type PaymentAdapter } from './providers.js';
19
+ export { type BkashOptions, bkashAdapter, bkashFromEnvironment } from './bkash.js';
19
20
  export { newReference, PaymentProvider, PaymentTransaction, PROVIDERS, paymentShutro, } from './module.js';
21
+ export { decimalsOf, fromSubunits, toSubunits } from './money.js';
20
22
  export { ADAPTERS, FAKE_SIGNATURE, fakeAdapter, type PaymentAdapter, type PaymentRequest, type PaymentResult, type PaymentStatus, } from './providers.js';
23
+ export { type RazorpayOptions, razorpayAdapter, razorpayFromEnvironment } from './razorpay.js';
21
24
  export { type Settled, settle } from './settle.js';
22
25
  export { type SslcommerzOptions, sslcommerzAdapter, sslcommerzFromEnvironment, } from './sslcommerz.js';
26
+ export { type StripeOptions, stripeAdapter, stripeFromEnvironment } from './stripe.js';
23
27
  export interface PaymentOptions {
24
28
  /** The adapters by code (tests; default: the fake). */
25
29
  readonly adapters?: ReadonlyMap<string, PaymentAdapter>;
@@ -22,14 +22,21 @@ import { portalDocuments } from '../portal/index.js';
22
22
  import { HttpProblem } from '../server/index.js';
23
23
  import { orm } from '../server/orm.js';
24
24
  import { action, form, list } from '../views/index.js';
25
+ import { bkashFromEnvironment } from './bkash.js';
25
26
  import { PaymentProvider, PaymentTransaction, paymentShutro } from './module.js';
26
27
  import { ADAPTERS, FAKE_SIGNATURE, fakeAdapter } from './providers.js';
28
+ import { razorpayFromEnvironment } from './razorpay.js';
27
29
  import { settle } from './settle.js';
28
30
  import { sslcommerzFromEnvironment } from './sslcommerz.js';
31
+ import { stripeFromEnvironment } from './stripe.js';
32
+ export { bkashAdapter, bkashFromEnvironment } from './bkash.js';
29
33
  export { newReference, PaymentProvider, PaymentTransaction, PROVIDERS, paymentShutro, } from './module.js';
34
+ export { decimalsOf, fromSubunits, toSubunits } from './money.js';
30
35
  export { ADAPTERS, FAKE_SIGNATURE, fakeAdapter, } from './providers.js';
36
+ export { razorpayAdapter, razorpayFromEnvironment } from './razorpay.js';
31
37
  export { settle } from './settle.js';
32
38
  export { sslcommerzAdapter, sslcommerzFromEnvironment, } from './sslcommerz.js';
39
+ export { stripeAdapter, stripeFromEnvironment } from './stripe.js';
33
40
  const typed = (env) => env;
34
41
  const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
35
42
  /** A callback endpoint per adapter: verified by the adapter with the endpoint's secret, settled as the system. */
@@ -158,25 +165,34 @@ function registerPaymentRoutes(context, adapters, urls) {
158
165
  await su.flush(); // company checks run in this superuser scope, not at commit (Shutro request #11)
159
166
  const [tx] = await created.load({});
160
167
  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 };
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 };
180
196
  });
181
197
  return reply.status(201).send(started);
182
198
  });
@@ -200,6 +216,7 @@ function registerPaymentRoutes(context, adapters, urls) {
200
216
  currency: t.currency.name,
201
217
  code: t.provider.code,
202
218
  mode: t.provider.state === 'live' ? 'live' : 'test',
219
+ providerReference: t.providerReference,
203
220
  error: t.error,
204
221
  };
205
222
  });
@@ -215,7 +232,10 @@ function registerPaymentRoutes(context, adapters, urls) {
215
232
  let error = found.error;
216
233
  // Back before the callback: the provider's word settles it now (once; the callback then finds it settled).
217
234
  if (state === 'pending') {
218
- const result = await adapters.get(found.code)?.status(reference, found.mode);
235
+ const result = await adapters
236
+ .get(found.code)
237
+ ?.status(reference, found.mode, found.providerReference)
238
+ .catch(() => null);
219
239
  if (result !== null && result !== undefined && result.status !== 'pending') {
220
240
  const settled = await context.shutro.bootstrap({ reason: `payment ${reference}: the customer returned`, company: found.company }, (env) => settle(typed(env), result));
221
241
  state = settled?.state ?? state;
@@ -267,12 +287,41 @@ function registerPaymentRoutes(context, adapters, urls) {
267
287
  config: { auth: false },
268
288
  handler: async (request, reply) => {
269
289
  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) => {
290
+ const tx = await context.shutro.bootstrap({ reason: 'payment return' }, (raw) => raw.asSuperuser({ reason: 'payment return: which invoice' }, async (su) => {
271
291
  const [t] = await (await typed(su.withFlags({ checkCompany: false }))
272
292
  .model('payment.transaction')
273
- .search({ where: { reference }, limit: 1 })).load({});
274
- return t?.move.id ?? null;
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
+ };
275
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;
276
325
  return reply.redirect(move === null
277
326
  ? `${urls.portal}/`
278
327
  : `${urls.portal}/d/${encodeURIComponent(payable)}/${move}?payment=${encodeURIComponent(reference)}`, 303);
@@ -300,7 +349,14 @@ function registerPaymentRoutes(context, adapters, urls) {
300
349
  }
301
350
  /** Online payments with explicit options (tests, installations wiring their own adapters). */
302
351
  export function paymentModule(options = {}) {
303
- const adapters = options.adapters ?? new Map([...ADAPTERS, ['sslcommerz', sslcommerzFromEnvironment()]]);
352
+ const adapters = options.adapters ??
353
+ new Map([
354
+ ...ADAPTERS,
355
+ ['sslcommerz', sslcommerzFromEnvironment()],
356
+ ['bkash', bkashFromEnvironment()],
357
+ ['razorpay', razorpayFromEnvironment()],
358
+ ['stripe', stripeFromEnvironment()],
359
+ ]);
304
360
  return defineModule({
305
361
  shutro: paymentShutro,
306
362
  kosh: '^0.1.0',
@@ -4,6 +4,9 @@ export declare const newReference: () => string;
4
4
  export declare const PROVIDERS: {
5
5
  readonly fake: 'Test provider (no money moves)';
6
6
  readonly sslcommerz: 'SSLCommerz';
7
+ readonly bkash: 'bKash';
8
+ readonly razorpay: 'Razorpay';
9
+ readonly stripe: 'Stripe';
7
10
  };
8
11
  export declare const PaymentProvider: import("shutro").Model<"payment.provider", {
9
12
  readonly id: import("shutro").IdField<"payment.provider">;
@@ -58,7 +61,7 @@ export declare const PaymentProvider: import("shutro").Model<"payment.provider",
58
61
  readonly hasDefault: false;
59
62
  readonly readonly: false;
60
63
  }, string>;
61
- readonly code: import("shutro").SelectionField<"fake" | "sslcommerz", {
64
+ readonly code: import("shutro").SelectionField<"bkash" | "fake" | "razorpay" | "sslcommerz" | "stripe", {
62
65
  readonly required: true;
63
66
  readonly computed: false;
64
67
  readonly inverse: false;
@@ -119,7 +122,7 @@ export declare const PaymentProvider: import("shutro").Model<"payment.provider",
119
122
  readonly hasDefault: false;
120
123
  readonly readonly: false;
121
124
  }, string>;
122
- readonly code: import("shutro").SelectionField<"fake" | "sslcommerz", {
125
+ readonly code: import("shutro").SelectionField<"bkash" | "fake" | "razorpay" | "sslcommerz" | "stripe", {
123
126
  readonly required: true;
124
127
  readonly computed: false;
125
128
  readonly inverse: false;
@@ -491,7 +494,7 @@ export declare const paymentShutro: import("shutro").Module<readonly [import("sh
491
494
  readonly hasDefault: false;
492
495
  readonly readonly: false;
493
496
  }, string>;
494
- readonly code: import("shutro").SelectionField<"fake" | "sslcommerz", {
497
+ readonly code: import("shutro").SelectionField<"bkash" | "fake" | "razorpay" | "sslcommerz" | "stripe", {
495
498
  readonly required: true;
496
499
  readonly computed: false;
497
500
  readonly inverse: false;
@@ -552,7 +555,7 @@ export declare const paymentShutro: import("shutro").Module<readonly [import("sh
552
555
  readonly hasDefault: false;
553
556
  readonly readonly: false;
554
557
  }, string>;
555
- readonly code: import("shutro").SelectionField<"fake" | "sslcommerz", {
558
+ readonly code: import("shutro").SelectionField<"bkash" | "fake" | "razorpay" | "sslcommerz" | "stripe", {
556
559
  readonly required: true;
557
560
  readonly computed: false;
558
561
  readonly inverse: false;
@@ -16,6 +16,9 @@ export const newReference = () => `PAY-${randomBytes(9).toString('base64url')}`;
16
16
  export const PROVIDERS = {
17
17
  fake: 'Test provider (no money moves)',
18
18
  sslcommerz: 'SSLCommerz',
19
+ bkash: 'bKash',
20
+ razorpay: 'Razorpay',
21
+ stripe: 'Stripe',
19
22
  };
20
23
  export const PaymentProvider = defineModel('payment.provider', {
21
24
  label: 'Payment provider',
@@ -0,0 +1,7 @@
1
+ /** Amounts in a currency's subunits, as payment providers take them (`'1500.00'` INR ↔ 150000). */
2
+ /** Decimal places of a currency's subunit. */
3
+ export declare function decimalsOf(currency: string): number;
4
+ /** `'1500.00'` INR → 150000. */
5
+ export declare function toSubunits(amount: string, currency: string): number;
6
+ /** 150000 INR → `'1500.00'`. */
7
+ export declare function fromSubunits(subunits: number, currency: string): string;
@@ -0,0 +1,37 @@
1
+ /** Amounts in a currency's subunits, as payment providers take them (`'1500.00'` INR ↔ 150000). */
2
+ /** Currencies without subunits, and with three decimals (Stripe's and Razorpay's lists). */
3
+ const ZERO = new Set([
4
+ 'BIF',
5
+ 'CLP',
6
+ 'DJF',
7
+ 'GNF',
8
+ 'JPY',
9
+ 'KMF',
10
+ 'KRW',
11
+ 'MGA',
12
+ 'PYG',
13
+ 'RWF',
14
+ 'UGX',
15
+ 'VND',
16
+ 'VUV',
17
+ 'XAF',
18
+ 'XOF',
19
+ 'XPF',
20
+ ]);
21
+ const THREE = new Set(['BHD', 'JOD', 'KWD', 'OMR', 'TND']);
22
+ /** Decimal places of a currency's subunit. */
23
+ export function decimalsOf(currency) {
24
+ const code = currency.toUpperCase();
25
+ return ZERO.has(code) ? 0 : THREE.has(code) ? 3 : 2;
26
+ }
27
+ /** `'1500.00'` INR → 150000. */
28
+ export function toSubunits(amount, currency) {
29
+ const d = decimalsOf(currency);
30
+ const [whole = '0', fraction = ''] = amount.split('.');
31
+ return Number(`${whole}${fraction.padEnd(d, '0').slice(0, d)}`);
32
+ }
33
+ /** 150000 INR → `'1500.00'`. */
34
+ export function fromSubunits(subunits, currency) {
35
+ const d = decimalsOf(currency);
36
+ return (subunits / 10 ** d).toFixed(d);
37
+ }
@@ -38,16 +38,27 @@ export interface PaymentAdapter {
38
38
  readonly code: string;
39
39
  /** Whether its credentials are set (the portal offers only providers whose adapter can work). */
40
40
  configured(mode: 'test' | 'live'): boolean;
41
- /** Starts a payment; returns the page to send the customer to. */
41
+ /** Starts a payment; returns the page to send the customer to (and the provider's id of it, when it has one). */
42
42
  createPayment(request: PaymentRequest): Promise<{
43
43
  readonly redirectUrl: string;
44
+ readonly providerReference?: string;
44
45
  }>;
45
46
  /** A callback's event id, or `null` when it is not genuine (checked with the endpoint's secret). */
46
47
  verifyCallback(call: InboundCall, secret: string): string | null | Promise<string | null>;
47
48
  /** What a verified callback says. */
48
49
  readCallback(call: InboundCall): PaymentResult | null;
49
50
  /** The provider's word on a payment (null: unknown to it). */
50
- status(reference: string, mode: 'test' | 'live'): Promise<PaymentResult | null>;
51
+ status(reference: string, mode: 'test' | 'live', providerReference: string | null): Promise<PaymentResult | null>;
52
+ /**
53
+ * The customer is back (`query`: what the provider put in the return URL — a hint, never proof). Providers that
54
+ * complete a payment only when the merchant asks (bKash's execute) do it here; the result is settled at once.
55
+ */
56
+ returned?(input: {
57
+ readonly reference: string;
58
+ readonly providerReference: string | null;
59
+ readonly mode: 'test' | 'live';
60
+ readonly query: Readonly<Record<string, string>>;
61
+ }): Promise<PaymentResult | null>;
51
62
  }
52
63
  /** The fake adapter's header: HMAC-SHA256 (hex) of the raw body with the endpoint's secret. */
53
64
  export declare const FAKE_SIGNATURE = "x-fake-signature";
@@ -0,0 +1,12 @@
1
+ import type { PaymentAdapter } from './providers.js';
2
+ export interface RazorpayOptions {
3
+ readonly keyId?: string | undefined;
4
+ readonly keySecret?: string | undefined;
5
+ /** The API's base (default `https://api.razorpay.com/v1`). */
6
+ readonly baseUrl?: string | undefined;
7
+ /** The HTTP client (tests). */
8
+ readonly fetch?: typeof fetch;
9
+ }
10
+ export declare function razorpayAdapter(options?: RazorpayOptions): PaymentAdapter;
11
+ /** Razorpay with its keys from the environment, read on first use. */
12
+ export declare function razorpayFromEnvironment(env?: NodeJS.ProcessEnv): PaymentAdapter;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The Razorpay adapter (India; RFC 0017 §5.5), on Payment Links: a link per transaction (`reference_id` = its
3
+ * reference; amounts in subunits) whose page the customer pays on, back to the return URL. Razorpay's webhook
4
+ * (`payment_link.paid`) is set up in its dashboard with the provider's endpoint URL and **the endpoint's secret**:
5
+ * `X-Razorpay-Signature` (HMAC-SHA256, hex, of the raw body) is checked with it, `x-razorpay-event-id` makes deliveries
6
+ * idempotent. On the customer's return (and when asked) the adapter fetches the link from Razorpay by the id it
7
+ * kept — what the browser brings is not used.
8
+ *
9
+ * Credentials come from the environment, never from records: `SHUTRO_ERP_RAZORPAY_KEY_ID` and
10
+ * `SHUTRO_ERP_RAZORPAY_KEY_SECRET`. Test keys (`rzp_test_…`) serve providers in test mode, live keys live ones.
11
+ */
12
+ import { createHmac, timingSafeEqual } from 'node:crypto';
13
+ import { fromSubunits, toSubunits } from './money.js';
14
+ const text = (v) => (v === undefined || v === null ? '' : String(v));
15
+ /** What a payment link says of its payment. */
16
+ function linkResult(reference, link) {
17
+ if (text(link['reference_id']) !== reference)
18
+ return null;
19
+ const status = text(link['status']);
20
+ const currency = text(link['currency']) || 'INR';
21
+ if (status === 'paid') {
22
+ const payments = Array.isArray(link['payments']) ? link['payments'] : [];
23
+ return {
24
+ reference,
25
+ status: 'done',
26
+ providerReference: text(payments.at(-1)?.['payment_id']) || text(link['id']) || null,
27
+ amount: fromSubunits(Number(link['amount_paid'] ?? 0), currency),
28
+ currency,
29
+ };
30
+ }
31
+ if (status === 'expired' || status === 'cancelled')
32
+ return { reference, status: 'cancelled', providerReference: text(link['id']) || null };
33
+ return { reference, status: 'pending', providerReference: text(link['id']) || null };
34
+ }
35
+ export function razorpayAdapter(options = {}) {
36
+ const keyId = options.keyId ?? '';
37
+ const keySecret = options.keySecret ?? '';
38
+ const base = (options.baseUrl || 'https://api.razorpay.com/v1').replace(/\/$/, '');
39
+ const http = options.fetch ?? fetch;
40
+ const ready = keyId !== '' && keySecret !== '';
41
+ const call = async (method, path, body) => {
42
+ if (!ready)
43
+ throw new Error('Razorpay is not configured: set SHUTRO_ERP_RAZORPAY_KEY_ID and SHUTRO_ERP_RAZORPAY_KEY_SECRET.');
44
+ const res = await http(`${base}${path}`, {
45
+ method,
46
+ headers: {
47
+ authorization: `Basic ${Buffer.from(`${keyId}:${keySecret}`).toString('base64')}`,
48
+ 'content-type': 'application/json',
49
+ },
50
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
51
+ signal: AbortSignal.timeout(30_000),
52
+ });
53
+ const answer = (await res.json().catch(() => ({})));
54
+ if (!res.ok) {
55
+ const error = (answer['error'] ?? {});
56
+ throw new Error(`Razorpay answered ${res.status}: ${text(error['description']) || 'no detail'}.`);
57
+ }
58
+ return answer;
59
+ };
60
+ const fetchLink = (id) => call('GET', `/payment_links/${encodeURIComponent(id)}`);
61
+ return {
62
+ code: 'razorpay',
63
+ configured: (mode) => ready && keyId.startsWith(mode === 'test' ? 'rzp_test_' : 'rzp_live_'),
64
+ async createPayment(request) {
65
+ const phone = (request.customer.phone ?? '').replace(/[^\d+]/g, '');
66
+ const link = await call('POST', '/payment_links', {
67
+ amount: toSubunits(request.amount, request.currency),
68
+ currency: request.currency,
69
+ reference_id: request.reference,
70
+ description: request.description.slice(0, 2048),
71
+ customer: {
72
+ name: request.customer.name,
73
+ ...(request.customer.email === null ? {} : { email: request.customer.email }),
74
+ ...(phone.length >= 8 && phone.length <= 14 ? { contact: phone } : {}),
75
+ },
76
+ notify: { sms: false, email: false },
77
+ reminder_enable: false,
78
+ callback_url: request.returnUrl,
79
+ callback_method: 'get',
80
+ });
81
+ const id = text(link['id']);
82
+ const page = text(link['short_url']);
83
+ if (id === '' || page === '')
84
+ throw new Error('Razorpay made no payment link.');
85
+ return { redirectUrl: page, providerReference: id };
86
+ },
87
+ verifyCallback(call, secret) {
88
+ const given = Buffer.from(call.headers['x-razorpay-signature'] ?? '');
89
+ const expected = Buffer.from(createHmac('sha256', secret).update(call.body).digest('hex'));
90
+ if (given.length !== expected.length || !timingSafeEqual(given, expected))
91
+ return null;
92
+ const id = call.headers['x-razorpay-event-id'] ?? '';
93
+ return id !== '' ? id : null;
94
+ },
95
+ readCallback(call) {
96
+ const event = call.json;
97
+ const payload = (event?.['payload'] ?? {});
98
+ const link = (payload['payment_link']?.['entity'] ??
99
+ null);
100
+ if (link === null)
101
+ return null;
102
+ const reference = text(link['reference_id']);
103
+ const kind = text(event?.['event']);
104
+ if (kind === 'payment_link.paid') {
105
+ const payment = (payload['payment']?.['entity'] ?? {});
106
+ const currency = text(link['currency']) || 'INR';
107
+ return {
108
+ reference,
109
+ status: 'done',
110
+ providerReference: text(payment['id']) || text(link['id']) || null,
111
+ amount: fromSubunits(Number(link['amount_paid'] ?? payment['amount'] ?? 0), currency),
112
+ currency,
113
+ };
114
+ }
115
+ if (kind === 'payment_link.expired' || kind === 'payment_link.cancelled')
116
+ return { reference, status: 'cancelled', providerReference: text(link['id']) || null };
117
+ return null;
118
+ },
119
+ async returned({ reference, providerReference }) {
120
+ if (providerReference === null || providerReference === '')
121
+ return null;
122
+ const result = linkResult(reference, await fetchLink(providerReference));
123
+ return result?.status === 'pending' ? null : result;
124
+ },
125
+ async status(reference, _mode, providerReference) {
126
+ if (!ready || providerReference === null || providerReference === '')
127
+ return null;
128
+ return linkResult(reference, await fetchLink(providerReference));
129
+ },
130
+ };
131
+ }
132
+ /** Razorpay with its keys from the environment, read on first use. */
133
+ export function razorpayFromEnvironment(env = process.env) {
134
+ let made;
135
+ const real = () => (made ??= razorpayAdapter({
136
+ keyId: env['KOSH_RAZORPAY_KEY_ID'],
137
+ keySecret: env['KOSH_RAZORPAY_KEY_SECRET'],
138
+ baseUrl: env['KOSH_RAZORPAY_BASE_URL'],
139
+ }));
140
+ return {
141
+ code: 'razorpay',
142
+ configured: (mode) => real().configured(mode),
143
+ createPayment: (request) => real().createPayment(request),
144
+ verifyCallback: (call, secret) => real().verifyCallback(call, secret),
145
+ readCallback: (call) => real().readCallback(call),
146
+ returned: (input) => real().returned?.(input) ?? Promise.resolve(null),
147
+ status: (reference, mode, providerReference) => real().status(reference, mode, providerReference),
148
+ };
149
+ }
@@ -180,6 +180,6 @@ export function sslcommerzFromEnvironment(env = process.env) {
180
180
  createPayment: (request) => real().createPayment(request),
181
181
  verifyCallback: (call, secret) => real().verifyCallback(call, secret),
182
182
  readCallback: (call) => real().readCallback(call),
183
- status: (reference, mode) => real().status(reference, mode),
183
+ status: (reference, mode, providerReference) => real().status(reference, mode, providerReference),
184
184
  };
185
185
  }