shutro-erp 0.3.1 → 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.
Files changed (48) hide show
  1. package/dist/account_portal/index.js +3 -2
  2. package/dist/accounting/index.js +4 -0
  3. package/dist/cli/init.js +2 -0
  4. package/dist/cli/launch.js +8 -5
  5. package/dist/cli/templates.js +4 -1
  6. package/dist/cli/ui.js +19 -1
  7. package/dist/integrations/inbound.d.ts +36 -0
  8. package/dist/integrations/inbound.js +171 -0
  9. package/dist/integrations/index.d.ts +18 -0
  10. package/dist/integrations/index.js +400 -0
  11. package/dist/integrations/models.d.ts +6 -0
  12. package/dist/integrations/models.js +119 -0
  13. package/dist/integrations/web/DeliveryTools.d.ts +4 -0
  14. package/dist/integrations/web/DeliveryTools.js +15 -0
  15. package/dist/integrations/web/EndpointTools.d.ts +7 -0
  16. package/dist/integrations/web/EndpointTools.js +25 -0
  17. package/dist/integrations/web/InboundTools.d.ts +4 -0
  18. package/dist/integrations/web/InboundTools.js +15 -0
  19. package/dist/integrations/web/WebhookTools.d.ts +7 -0
  20. package/dist/integrations/web/WebhookTools.js +25 -0
  21. package/dist/integrations/web/index.d.ts +16 -0
  22. package/dist/integrations/web/index.js +27 -0
  23. package/dist/integrations/web/messages.d.ts +2 -0
  24. package/dist/integrations/web/messages.js +53 -0
  25. package/dist/integrations/webhooks.d.ts +63 -0
  26. package/dist/integrations/webhooks.js +217 -0
  27. package/dist/modules-sdk/index.d.ts +1 -1
  28. package/dist/modules-sdk/manifest.d.ts +48 -0
  29. package/dist/payment/.kosh/bindings.d.ts +22 -0
  30. package/dist/payment/.kosh/bindings.js +15 -0
  31. package/dist/payment/.kosh/schema.d.ts +1553 -0
  32. package/dist/payment/index.d.ts +34 -0
  33. package/dist/payment/index.js +375 -0
  34. package/dist/payment/module.d.ts +6853 -0
  35. package/dist/payment/module.js +74 -0
  36. package/dist/payment/providers.d.ts +64 -0
  37. package/dist/payment/providers.js +77 -0
  38. package/dist/payment/settle.d.ts +11 -0
  39. package/dist/payment/settle.js +59 -0
  40. package/dist/payment/sslcommerz.d.ts +12 -0
  41. package/dist/payment/sslcommerz.js +185 -0
  42. package/dist/portal/routes-documents.js +5 -0
  43. package/dist/sales/index.js +2 -0
  44. package/dist/server/api/rest.d.ts +6 -1
  45. package/dist/server/api/rest.js +11 -0
  46. package/dist/server/index.d.ts +1 -1
  47. package/dist/server/index.js +1 -1
  48. package/package.json +13 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The data layer of online payments (RFC 0017 §5.5): a company's payment providers (the adapter, its mode, the
3
+ * journal the money arrives in, the inbound webhook its callbacks come through) and the transactions customers
4
+ * start from the portal (the invoice, the amount, the provider's reference, the state, the `account.payment` that
5
+ * settled it). Credentials are never in records: an adapter reads them from the environment; the callbacks' secret
6
+ * is the endpoint's.
7
+ */
8
+ import { randomBytes } from 'node:crypto';
9
+ import { accountPortalShutro } from '../account_portal/index.js';
10
+ import { integrationsShutro } from '../integrations/index.js';
11
+ import { defineModel, defineModule, f } from './.kosh/bindings.js';
12
+ const ALL = ['read', 'write', 'create', 'unlink', 'export', 'import'];
13
+ /** A transaction's reference: what the provider is told and gives back. */
14
+ export const newReference = () => `PAY-${randomBytes(9).toString('base64url')}`;
15
+ /** The adapters Kosh ships; a module adding one extends this selection. */
16
+ export const PROVIDERS = {
17
+ fake: 'Test provider (no money moves)',
18
+ sslcommerz: 'SSLCommerz',
19
+ };
20
+ export const PaymentProvider = defineModel('payment.provider', {
21
+ label: 'Payment provider',
22
+ recName: 'name',
23
+ companyScoped: { required: true },
24
+ order: ['sequence asc', 'id asc'],
25
+ tracking: ['state', 'journal'],
26
+ access: { 'account.user': ['read'], 'account.manager': ALL },
27
+ fields: {
28
+ name: f.char({ required: true }),
29
+ code: f.selection(PROVIDERS, { required: true, default: 'fake', label: 'Provider' }),
30
+ state: f.selection({ disabled: 'Disabled', test: 'Test', live: 'Live' }, { required: true, default: 'test', label: 'Mode' }),
31
+ /** Where the money arrives: the payments are registered in this bank (or cash) journal. */
32
+ journal: f.many2one('account.journal', { required: true, checkCompany: true }),
33
+ /** The inbound webhook the provider's callbacks come through (made with the provider). */
34
+ endpoint: f.many2one('webhook.endpoint', { label: 'Callback endpoint' }),
35
+ sequence: f.integer({ required: true, default: 10 }),
36
+ },
37
+ });
38
+ export const PaymentTransaction = defineModel('payment.transaction', {
39
+ label: 'Payment transaction',
40
+ recName: 'reference',
41
+ companyScoped: { required: true },
42
+ order: ['id desc'],
43
+ tracking: ['state'],
44
+ access: {
45
+ 'account.invoicing': ['read'],
46
+ 'account.user': ['read', 'export'],
47
+ 'account.manager': ['read', 'export'],
48
+ },
49
+ fields: {
50
+ reference: f.char({ required: true, index: 'unique', default: newReference }),
51
+ provider: f.many2one('payment.provider', { required: true, checkCompany: true }),
52
+ move: f.many2one('account.move', { required: true, label: 'Invoice', checkCompany: true }),
53
+ partner: f.many2one('res.partner', { label: 'Customer' }),
54
+ currency: f.many2one('res.currency', { required: true }),
55
+ amount: f.monetary({ currency: 'currency', required: true }),
56
+ state: f.selection({
57
+ pending: 'Pending',
58
+ done: 'Paid',
59
+ failed: 'Failed',
60
+ cancelled: 'Cancelled',
61
+ }, { required: true, default: 'pending' }),
62
+ providerReference: f.char({ label: 'Provider reference' }),
63
+ /** The payment registered when the provider confirmed. */
64
+ payment: f.many2one('account.payment', { label: 'Payment' }),
65
+ error: f.text(),
66
+ doneAt: f.datetime({ label: 'Paid at' }),
67
+ },
68
+ });
69
+ export const paymentShutro = defineModule({
70
+ name: 'payment',
71
+ version: '0.1.0',
72
+ depends: [accountPortalShutro, integrationsShutro],
73
+ models: [PaymentProvider, PaymentTransaction],
74
+ });
@@ -0,0 +1,64 @@
1
+ import type { InboundCall } from '../modules-sdk/index.js';
2
+ export type PaymentStatus = 'pending' | 'done' | 'failed' | 'cancelled';
3
+ export interface PaymentRequest {
4
+ /** The transaction's reference: what the provider gives back. */
5
+ readonly reference: string;
6
+ /** Decimal string in the currency's unit (`'1250.00'`). */
7
+ readonly amount: string;
8
+ /** ISO 4217 (`BDT`). */
9
+ readonly currency: string;
10
+ /** What is paid (`Invoice INV/2026/0042`). */
11
+ readonly description: string;
12
+ readonly customer: {
13
+ readonly name: string;
14
+ readonly email: string | null;
15
+ readonly phone: string | null;
16
+ readonly street: string | null;
17
+ readonly city: string | null;
18
+ readonly zip: string | null;
19
+ /** ISO 3166 alpha-2. */
20
+ readonly country: string | null;
21
+ };
22
+ /** Where the provider sends the customer back (GET or POST; the server forwards them to the portal). */
23
+ readonly returnUrl: string;
24
+ /** The inbound webhook the provider calls (absolute URL). */
25
+ readonly callbackUrl: string;
26
+ readonly mode: 'test' | 'live';
27
+ }
28
+ export interface PaymentResult {
29
+ readonly reference: string;
30
+ readonly status: PaymentStatus;
31
+ readonly providerReference: string | null;
32
+ /** What the provider says was paid; checked against the transaction. */
33
+ readonly amount?: string;
34
+ readonly currency?: string;
35
+ readonly error?: string;
36
+ }
37
+ export interface PaymentAdapter {
38
+ readonly code: string;
39
+ /** Whether its credentials are set (the portal offers only providers whose adapter can work). */
40
+ configured(mode: 'test' | 'live'): boolean;
41
+ /** Starts a payment; returns the page to send the customer to. */
42
+ createPayment(request: PaymentRequest): Promise<{
43
+ readonly redirectUrl: string;
44
+ }>;
45
+ /** A callback's event id, or `null` when it is not genuine (checked with the endpoint's secret). */
46
+ verifyCallback(call: InboundCall, secret: string): string | null | Promise<string | null>;
47
+ /** What a verified callback says. */
48
+ readCallback(call: InboundCall): PaymentResult | null;
49
+ /** The provider's word on a payment (null: unknown to it). */
50
+ status(reference: string, mode: 'test' | 'live'): Promise<PaymentResult | null>;
51
+ }
52
+ /** The fake adapter's header: HMAC-SHA256 (hex) of the raw body with the endpoint's secret. */
53
+ export declare const FAKE_SIGNATURE = "x-fake-signature";
54
+ export declare const fakeAdapter: PaymentAdapter & {
55
+ /** The payment the fake checkout shows. */
56
+ pending(reference: string): PaymentRequest | null;
57
+ /** The customer paid or declined on the fake checkout: the result and the signed callback body. */
58
+ complete(reference: string, paid: boolean, secret: string): {
59
+ body: string;
60
+ signature: string;
61
+ } | null;
62
+ };
63
+ /** The adapters by code. */
64
+ export declare const ADAPTERS: ReadonlyMap<string, PaymentAdapter>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Payment provider adapters (RFC 0017 §5.5). An adapter starts a payment at the provider (a page to send the customer
3
+ * to), reads the provider's callbacks (verified with the callback endpoint's secret), and asks the provider for a
4
+ * payment's status (when the customer returns before the callback). Credentials come from the environment, never
5
+ * from records. The fake adapter moves no money: its checkout page (served by this module) lets the customer pay or
6
+ * decline, and it calls back like a real provider, signed.
7
+ */
8
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
9
+ /** The fake adapter's header: HMAC-SHA256 (hex) of the raw body with the endpoint's secret. */
10
+ export const FAKE_SIGNATURE = 'x-fake-signature';
11
+ /** What the fake checkout knows (per process: it is for tests and demonstrations). */
12
+ const fakeLedger = new Map();
13
+ export const fakeAdapter = {
14
+ code: 'fake',
15
+ configured: (mode) => mode === 'test',
16
+ async createPayment(request) {
17
+ fakeLedger.set(request.reference, {
18
+ reference: request.reference,
19
+ status: 'pending',
20
+ providerReference: null,
21
+ request,
22
+ });
23
+ return {
24
+ redirectUrl: `/api/v1/payment/fake/checkout/${encodeURIComponent(request.reference)}`,
25
+ };
26
+ },
27
+ verifyCallback(call, secret) {
28
+ const given = Buffer.from(call.headers[FAKE_SIGNATURE] ?? '');
29
+ const expected = Buffer.from(createHmac('sha256', secret).update(call.body).digest('hex'));
30
+ if (given.length !== expected.length || !timingSafeEqual(given, expected))
31
+ return null;
32
+ const id = call.json?.id;
33
+ return typeof id === 'string' && id !== '' ? id : null;
34
+ },
35
+ readCallback(call) {
36
+ const event = call.json;
37
+ if (typeof event?.reference !== 'string')
38
+ return null;
39
+ return {
40
+ reference: event.reference,
41
+ status: event.status ?? 'pending',
42
+ providerReference: event.providerReference ?? null,
43
+ ...(event.amount === undefined ? {} : { amount: event.amount }),
44
+ ...(event.currency === undefined ? {} : { currency: event.currency }),
45
+ };
46
+ },
47
+ async status(reference) {
48
+ const known = fakeLedger.get(reference);
49
+ if (known === undefined)
50
+ return null;
51
+ const { request: _request, ...result } = known;
52
+ return result;
53
+ },
54
+ pending(reference) {
55
+ const known = fakeLedger.get(reference);
56
+ return known?.status === 'pending' ? known.request : null;
57
+ },
58
+ complete(reference, paid, secret) {
59
+ const known = fakeLedger.get(reference);
60
+ if (known === undefined || known.status !== 'pending')
61
+ return null;
62
+ const result = {
63
+ reference,
64
+ status: paid ? 'done' : 'failed',
65
+ providerReference: `FAKE-${randomUUID().slice(0, 8).toUpperCase()}`,
66
+ amount: known.request.amount,
67
+ currency: known.request.currency,
68
+ };
69
+ fakeLedger.set(reference, { ...result, request: known.request });
70
+ const body = JSON.stringify({ id: `evt_${randomUUID()}`, ...result });
71
+ return { body, signature: createHmac('sha256', secret).update(body).digest('hex') };
72
+ },
73
+ };
74
+ /** The adapters by code. */
75
+ export const ADAPTERS = new Map([
76
+ [fakeAdapter.code, fakeAdapter],
77
+ ]);
@@ -0,0 +1,11 @@
1
+ import type { Env } from './.kosh/bindings.js';
2
+ import type { PaymentResult } from './providers.js';
3
+ export interface Settled {
4
+ readonly reference: string;
5
+ readonly state: string;
6
+ /** False when it was settled before (a repeated callback, a return after the callback). */
7
+ readonly changed: boolean;
8
+ readonly payment: number | null;
9
+ }
10
+ /** Applies the provider's result to transaction `result.reference`, as the system in the transaction's company. */
11
+ export declare function settle(env: Env, result: PaymentResult): Promise<Settled | null>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Settling a transaction (RFC 0017 §5.5): the provider's word — by its callback or asked when the customer returns —
3
+ * moves a pending transaction to paid, failed or cancelled. Paid registers an `account.payment` for the invoice in
4
+ * the provider's journal (Accounting posts and reconciles it), **once**: a transaction-scoped advisory lock on the
5
+ * reference serialises a callback and a return arriving together, and a paid transaction is never settled again
6
+ * (a failed or cancelled one becomes paid if the provider confirms it later).
7
+ */
8
+ import { registerPayment } from '../accounting/index.js';
9
+ import { orm } from '../server/orm.js';
10
+ /** Applies the provider's result to transaction `result.reference`, as the system in the transaction's company. */
11
+ export async function settle(env, result) {
12
+ await env.sql `SELECT pg_advisory_xact_lock(hashtext(${`payment:${result.reference}`}))`;
13
+ const found = await env
14
+ .model('payment.transaction')
15
+ .search({ where: { reference: result.reference }, limit: 1 });
16
+ if (found.length === 0)
17
+ return null;
18
+ const [tx] = await found.load({ provider: true, currency: true, move: true });
19
+ if (tx === undefined)
20
+ return null;
21
+ const same = { reference: tx.reference, changed: false };
22
+ // Paid is final. A failure or cancellation is not: the provider may still confirm the payment later (a retry,
23
+ // a failure reported early) — then it is paid. Nothing else changes a settled transaction.
24
+ if (tx.state === 'done' ||
25
+ result.status === 'pending' ||
26
+ (tx.state !== 'pending' && result.status !== 'done'))
27
+ return { ...same, state: tx.state, payment: tx.payment?.id ?? null };
28
+ if (result.status !== 'done') {
29
+ await found.write({
30
+ state: result.status,
31
+ providerReference: result.providerReference,
32
+ error: result.error ?? (result.status === 'failed' ? 'The provider refused the payment.' : null),
33
+ });
34
+ return { ...same, state: result.status, changed: true, payment: null };
35
+ }
36
+ // What the provider says was paid must be what was asked.
37
+ const amount = tx.amount.amount;
38
+ if ((result.amount !== undefined && !orm.toDecimal(result.amount).eq(amount)) ||
39
+ (result.currency !== undefined && result.currency !== tx.currency.name)) {
40
+ const error = `The provider reports ${result.amount ?? '?'} ${result.currency ?? ''}, not ${amount.toFixed()} ${tx.currency.name}.`;
41
+ await found.write({ state: 'failed', providerReference: result.providerReference, error });
42
+ return { ...same, state: 'failed', changed: true, payment: null };
43
+ }
44
+ const payment = await registerPayment(env, {
45
+ moves: [tx.move.id],
46
+ journal: tx.provider.journal.id,
47
+ amount: amount.toFixed(),
48
+ memo: `${tx.provider.name} ${result.providerReference ?? tx.reference}`,
49
+ });
50
+ await found.write({
51
+ state: 'done',
52
+ providerReference: result.providerReference,
53
+ payment: payment.id,
54
+ error: null,
55
+ doneAt: new Date(),
56
+ });
57
+ await env.flush(); // checks run in this system scope, not at commit (Shutro request #11)
58
+ return { ...same, state: 'done', changed: true, payment: payment.id };
59
+ }
@@ -0,0 +1,12 @@
1
+ import type { PaymentAdapter } from './providers.js';
2
+ export interface SslcommerzOptions {
3
+ readonly storeId?: string | undefined;
4
+ readonly storePassword?: string | undefined;
5
+ /** The sandbox (default) or the live system. */
6
+ readonly sandbox?: boolean;
7
+ /** The HTTP client (tests). */
8
+ readonly fetch?: typeof fetch;
9
+ }
10
+ export declare function sslcommerzAdapter(options?: SslcommerzOptions): PaymentAdapter;
11
+ /** SSLCommerz with its credentials from the environment, read on first use (after the server applied its names). */
12
+ export declare function sslcommerzFromEnvironment(env?: NodeJS.ProcessEnv): PaymentAdapter;
@@ -0,0 +1,185 @@
1
+ const HOSTS = {
2
+ sandbox: 'https://sandbox.sslcommerz.com',
3
+ live: 'https://securepay.sslcommerz.com',
4
+ };
5
+ /** The IPN's fields: a form post (or JSON, which some integrations send). */
6
+ function fieldsOf(call) {
7
+ if (call.json !== null && typeof call.json === 'object' && !Array.isArray(call.json))
8
+ return Object.fromEntries(Object.entries(call.json).map(([k, v]) => [k, String(v ?? '')]));
9
+ return Object.fromEntries(new URLSearchParams(call.body));
10
+ }
11
+ /** What was paid, in the transaction's currency (`currency_type` / `currency_amount` when it was not BDT). */
12
+ const paidAmount = (f) => f['currency_amount'] || f['amount'] || '';
13
+ const paidCurrency = (f) => f['currency_type'] || f['currency'] || '';
14
+ const same = (a, b) => a !== '' && b !== '' && Number(a).toFixed(2) === Number(b).toFixed(2);
15
+ const IPN_STATUS = {
16
+ VALID: 'done',
17
+ VALIDATED: 'done',
18
+ FAILED: 'failed',
19
+ CANCELLED: 'cancelled',
20
+ EXPIRED: 'cancelled',
21
+ };
22
+ const countryName = (code) => {
23
+ if (code === null || code === '')
24
+ return 'Bangladesh';
25
+ try {
26
+ return new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase()) ?? code;
27
+ }
28
+ catch {
29
+ return code;
30
+ }
31
+ };
32
+ export function sslcommerzAdapter(options = {}) {
33
+ const storeId = options.storeId ?? '';
34
+ const storePassword = options.storePassword ?? '';
35
+ const sandbox = options.sandbox ?? true;
36
+ const host = sandbox ? HOSTS.sandbox : HOSTS.live;
37
+ const http = options.fetch ?? fetch;
38
+ const ready = storeId !== '' && storePassword !== '';
39
+ const credentials = () => {
40
+ if (!ready)
41
+ throw new Error('SSLCommerz is not configured: set SHUTRO_ERP_SSLCOMMERZ_STORE_ID and SHUTRO_ERP_SSLCOMMERZ_STORE_PASSWORD.');
42
+ return { store_id: storeId, store_passwd: storePassword };
43
+ };
44
+ const getJson = async (path, query) => {
45
+ const url = `${host}${path}?${new URLSearchParams({ ...query, ...credentials(), format: 'json' })}`;
46
+ const res = await http(url, { signal: AbortSignal.timeout(15_000) });
47
+ if (!res.ok)
48
+ throw new Error(`SSLCommerz answered ${res.status}.`);
49
+ return (await res.json());
50
+ };
51
+ /** The attempts SSLCommerz knows for a transaction id. */
52
+ const attempts = async (reference) => {
53
+ const answer = await getJson('/validator/api/merchantTransIDvalidationAPI.php', {
54
+ tran_id: reference,
55
+ });
56
+ if (answer['APIConnect'] !== 'DONE')
57
+ return null;
58
+ const element = Array.isArray(answer['element'])
59
+ ? answer['element']
60
+ : [];
61
+ return element.map((e) => Object.fromEntries(Object.entries(e).map(([k, v]) => [k, String(v ?? '')])));
62
+ };
63
+ return {
64
+ code: 'sslcommerz',
65
+ configured: (mode) => ready && (mode === 'test') === sandbox,
66
+ async createPayment(request) {
67
+ const body = new URLSearchParams({
68
+ ...credentials(),
69
+ total_amount: request.amount,
70
+ currency: request.currency,
71
+ tran_id: request.reference,
72
+ success_url: request.returnUrl,
73
+ fail_url: request.returnUrl,
74
+ cancel_url: request.returnUrl,
75
+ ipn_url: request.callbackUrl,
76
+ cus_name: request.customer.name || 'Customer',
77
+ cus_email: request.customer.email ?? '',
78
+ cus_add1: request.customer.street ?? 'N/A',
79
+ cus_city: request.customer.city ?? 'N/A',
80
+ cus_postcode: request.customer.zip ?? '0000',
81
+ cus_country: countryName(request.customer.country),
82
+ cus_phone: request.customer.phone ?? 'N/A',
83
+ shipping_method: 'NO',
84
+ product_name: request.description,
85
+ product_category: 'Invoice',
86
+ product_profile: 'general',
87
+ emi_option: '0',
88
+ value_a: request.reference,
89
+ });
90
+ const res = await http(`${host}/gwprocess/v4/api.php`, {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
93
+ body,
94
+ signal: AbortSignal.timeout(15_000),
95
+ });
96
+ const answer = (await res.json().catch(() => ({})));
97
+ const page = answer['GatewayPageURL'];
98
+ if (answer['status'] !== 'SUCCESS' || typeof page !== 'string' || page === '')
99
+ throw new Error(`SSLCommerz refused the payment: ${String(answer['failedreason'] ?? res.status)}.`);
100
+ return { redirectUrl: page };
101
+ },
102
+ async verifyCallback(call) {
103
+ const f = fieldsOf(call);
104
+ const reference = f['tran_id'] ?? '';
105
+ const status = IPN_STATUS[f['status'] ?? ''];
106
+ if (reference === '' || status === undefined || !ready)
107
+ return null;
108
+ if (status === 'done') {
109
+ // Paid: SSLCommerz confirms this val_id, for this transaction, amount and currency.
110
+ const valId = f['val_id'] ?? '';
111
+ if (valId === '')
112
+ return null;
113
+ const v = await getJson('/validator/api/validationserverAPI.php', { val_id: valId });
114
+ const fv = Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x ?? '')]));
115
+ const confirmed = (fv['status'] === 'VALID' || fv['status'] === 'VALIDATED') &&
116
+ fv['tran_id'] === reference &&
117
+ same(paidAmount(fv), paidAmount(f)) &&
118
+ paidCurrency(fv) === paidCurrency(f);
119
+ return confirmed ? valId : null;
120
+ }
121
+ // Failed or cancelled: SSLCommerz knows the transaction and no attempt of it succeeded.
122
+ const known = await attempts(reference);
123
+ if (known === null || known.length === 0)
124
+ return null;
125
+ if (known.some((a) => a['status'] === 'VALID' || a['status'] === 'VALIDATED'))
126
+ return null;
127
+ return `${reference}:${f['status']}:${f['val_id'] || f['tran_date'] || ''}`;
128
+ },
129
+ readCallback(call) {
130
+ const f = fieldsOf(call);
131
+ const status = IPN_STATUS[f['status'] ?? ''];
132
+ if ((f['tran_id'] ?? '') === '' || status === undefined)
133
+ return null;
134
+ return {
135
+ reference: f['tran_id'],
136
+ status,
137
+ providerReference: f['bank_tran_id'] || f['val_id'] || null,
138
+ ...(status === 'done' ? { amount: paidAmount(f), currency: paidCurrency(f) } : {}),
139
+ ...(f['error'] ? { error: f['error'] } : {}),
140
+ };
141
+ },
142
+ async status(reference) {
143
+ if (!ready)
144
+ return null;
145
+ const known = await attempts(reference);
146
+ if (known === null || known.length === 0)
147
+ return null;
148
+ const paid = known.find((a) => a['status'] === 'VALID' || a['status'] === 'VALIDATED');
149
+ if (paid !== undefined)
150
+ return {
151
+ reference,
152
+ status: 'done',
153
+ providerReference: paid['bank_tran_id'] || paid['val_id'] || null,
154
+ amount: paidAmount(paid),
155
+ currency: paidCurrency(paid),
156
+ };
157
+ if (known.some((a) => a['status'] === 'PENDING'))
158
+ return { reference, status: 'pending', providerReference: null };
159
+ const last = known.at(-1);
160
+ return {
161
+ reference,
162
+ status: 'failed',
163
+ providerReference: last?.['val_id'] || null,
164
+ ...(last?.['error'] ? { error: last['error'] } : {}),
165
+ };
166
+ },
167
+ };
168
+ }
169
+ /** SSLCommerz with its credentials from the environment, read on first use (after the server applied its names). */
170
+ export function sslcommerzFromEnvironment(env = process.env) {
171
+ let made;
172
+ const real = () => (made ??= sslcommerzAdapter({
173
+ storeId: env['KOSH_SSLCOMMERZ_STORE_ID'],
174
+ storePassword: env['KOSH_SSLCOMMERZ_STORE_PASSWORD'],
175
+ sandbox: env['KOSH_SSLCOMMERZ_SANDBOX'] !== 'false',
176
+ }));
177
+ return {
178
+ code: 'sslcommerz',
179
+ configured: (mode) => real().configured(mode),
180
+ createPayment: (request) => real().createPayment(request),
181
+ verifyCallback: (call, secret) => real().verifyCallback(call, secret),
182
+ readCallback: (call) => real().readCallback(call),
183
+ status: (reference, mode) => real().status(reference, mode),
184
+ };
185
+ }
@@ -207,6 +207,11 @@ export function registerPortalDocumentRoutes(context) {
207
207
  ? {}
208
208
  : { lineFields: meta(d.lineModel, d.spec.lines.fields), lines }),
209
209
  actions: await available(su, d, id),
210
+ payable: d.spec.payment !== undefined &&
211
+ (await ref(su, d.model.name).search({
212
+ where: { and: [{ id }, d.spec.payment.when] },
213
+ limit: 1,
214
+ })).length > 0,
210
215
  pdf: d.spec.pdf !== undefined,
211
216
  thread: d.spec.thread === true,
212
217
  };
@@ -17,6 +17,8 @@ export const sales = defineModule({
17
17
  kosh: '^0.1.0',
18
18
  hooks: { demo: salesDemo },
19
19
  depends: [contacts, product, messaging],
20
+ // What webhooks may subscribe to besides record changes (RFC 0017 §5.3).
21
+ events: [{ topic: 'sale.order.confirmed', model: 'sale.order', label: 'Sales order confirmed' }],
20
22
  app: {
21
23
  code: 'sales',
22
24
  label: 'Sales',
@@ -17,8 +17,13 @@
17
17
  * - `GET /api/v1/openapi.json` the OpenAPI 3.1 document
18
18
  */
19
19
  import type { FastifyInstance } from 'fastify';
20
- import type { Driver } from '../orm.js';
20
+ import type { Driver, Env } from '../orm.js';
21
21
  import { type ApiModel } from './metadata.js';
22
+ /**
23
+ * A record as `GET /api/v1/models/<model>/<id>` returns it to `env`'s user (their readable default fields), or null
24
+ * when they cannot see it: what webhooks send (RFC 0017 §5.3).
25
+ */
26
+ export declare function readApiRecord(env: Env, model: ApiModel, id: number): Promise<Record<string, unknown> | null>;
22
27
  /** Registers the generated routes for `models`. */
23
28
  export declare function registerRestApi(app: FastifyInstance, options: {
24
29
  readonly models: ReadonlyMap<string, ApiModel>;
@@ -102,6 +102,17 @@ function ifMatch(request) {
102
102
  throw problem(400, 'invalid-if-match', 'Invalid If-Match', 'If-Match must be an ETag returned by this API.');
103
103
  return new Date(Number(m[1]));
104
104
  }
105
+ /**
106
+ * A record as `GET /api/v1/models/<model>/<id>` returns it to `env`'s user (their readable default fields), or null
107
+ * when they cannot see it: what webhooks send (RFC 0017 §5.3).
108
+ */
109
+ export async function readApiRecord(env, model, id) {
110
+ const set = await ref(env, model.name).search({ where: { id }, limit: 1 });
111
+ if (set.length === 0)
112
+ return null;
113
+ const [row] = await rows(set, fieldsOf(model, env, undefined));
114
+ return row ?? null;
115
+ }
105
116
  async function record(env, model, id, fields) {
106
117
  const set = await ref(env, model.name).browse([id]).load(['id', 'updatedAt']);
107
118
  const [row] = await rows(set, fields ?? fieldsOf(model, env, undefined));
@@ -9,7 +9,7 @@ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, type Outcome
9
9
  export { type ApiField, type ApiModel, apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
10
10
  export { type FoundRecord, type MenuNode, moduleVisible, Navigation } from './api/navigation.js';
11
11
  export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
12
- export { registerRestApi } from './api/rest.js';
12
+ export { readApiRecord, registerRestApi } from './api/rest.js';
13
13
  export { type ServedView, type ViewField, ViewRegistry } from './api/views.js';
14
14
  export { APPS_CHANNEL, APPS_LOG_TABLE, APPS_TABLE, type AppLoadContext, type ApplyAppsOptions, type AppsResult, applyApps, appsDigest, appTableStatements, FRAMEWORK_TABLES, installDefaults, installedApps, installedModules, type PrepareAppsOptions, type PreparedApps, prepareApps, readInstalledApps, recordInstalledApps, restoreAppMigrations, } from './apps.js';
15
15
  export { type AuditInput, audit } from './audit.js';
@@ -9,7 +9,7 @@ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, } from './ap
9
9
  export { apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
10
10
  export { moduleVisible, Navigation } from './api/navigation.js';
11
11
  export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
12
- export { registerRestApi } from './api/rest.js';
12
+ export { readApiRecord, registerRestApi } from './api/rest.js';
13
13
  export { ViewRegistry } from './api/views.js';
14
14
  export { APPS_CHANNEL, APPS_LOG_TABLE, APPS_TABLE, applyApps, appsDigest, appTableStatements, FRAMEWORK_TABLES, installDefaults, installedApps, installedModules, prepareApps, readInstalledApps, recordInstalledApps, restoreAppMigrations, } from './apps.js';
15
15
  export { audit } from './audit.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shutro-erp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Shutro ERP (Kosh): a modern ERP framework on TypeScript, PostgreSQL and the Shutro ORM — typed end to end, permissions enforced on the server, metadata-driven views, reporting, documents and a chatter on every record.",
5
5
  "keywords": [
6
6
  "erp",
@@ -121,6 +121,14 @@
121
121
  "types": "./dist/automation/web/index.d.ts",
122
122
  "default": "./dist/automation/web/index.js"
123
123
  },
124
+ "./integrations": {
125
+ "types": "./dist/integrations/index.d.ts",
126
+ "default": "./dist/integrations/index.js"
127
+ },
128
+ "./integrations/web": {
129
+ "types": "./dist/integrations/web/index.d.ts",
130
+ "default": "./dist/integrations/web/index.js"
131
+ },
124
132
  "./studio": {
125
133
  "types": "./dist/studio/index.d.ts",
126
134
  "default": "./dist/studio/index.js"
@@ -209,6 +217,10 @@
209
217
  "types": "./dist/project_portal/index.d.ts",
210
218
  "default": "./dist/project_portal/index.js"
211
219
  },
220
+ "./payment": {
221
+ "types": "./dist/payment/index.d.ts",
222
+ "default": "./dist/payment/index.js"
223
+ },
212
224
  "./l10n_bd": {
213
225
  "types": "./dist/l10n_bd/index.d.ts",
214
226
  "default": "./dist/l10n_bd/index.js"