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.
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +29 -1
- package/dist/account_portal/index.js +3 -2
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +29 -1
- package/dist/accounting/index.js +4 -0
- package/dist/assistant/index.js +1 -1
- package/dist/cli/init.js +2 -0
- package/dist/cli/launch.js +8 -5
- package/dist/cli/templates.js +4 -1
- package/dist/cli/ui.js +19 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +29 -1
- package/dist/integrations/inbound.d.ts +36 -0
- package/dist/integrations/inbound.js +171 -0
- package/dist/integrations/index.d.ts +18 -0
- package/dist/integrations/index.js +400 -0
- package/dist/integrations/models.d.ts +6 -0
- package/dist/integrations/models.js +119 -0
- package/dist/integrations/web/DeliveryTools.d.ts +4 -0
- package/dist/integrations/web/DeliveryTools.js +15 -0
- package/dist/integrations/web/EndpointTools.d.ts +7 -0
- package/dist/integrations/web/EndpointTools.js +25 -0
- package/dist/integrations/web/InboundTools.d.ts +4 -0
- package/dist/integrations/web/InboundTools.js +15 -0
- package/dist/integrations/web/WebhookTools.d.ts +7 -0
- package/dist/integrations/web/WebhookTools.js +25 -0
- package/dist/integrations/web/index.d.ts +16 -0
- package/dist/integrations/web/index.js +27 -0
- package/dist/integrations/web/messages.d.ts +2 -0
- package/dist/integrations/web/messages.js +53 -0
- package/dist/integrations/webhooks.d.ts +63 -0
- package/dist/integrations/webhooks.js +217 -0
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +29 -4
- package/dist/l10n_in/module.d.ts +0 -62
- package/dist/l10n_in/module.js +2 -4
- package/dist/modules-sdk/index.d.ts +1 -1
- package/dist/modules-sdk/manifest.d.ts +48 -0
- package/dist/payment/.kosh/bindings.d.ts +22 -0
- package/dist/payment/.kosh/bindings.js +15 -0
- package/dist/payment/.kosh/schema.d.ts +1581 -0
- package/dist/payment/bkash.d.ts +28 -0
- package/dist/payment/bkash.js +169 -0
- package/dist/payment/index.d.ts +38 -0
- package/dist/payment/index.js +431 -0
- package/dist/payment/module.d.ts +6856 -0
- package/dist/payment/module.js +77 -0
- package/dist/payment/money.d.ts +7 -0
- package/dist/payment/money.js +37 -0
- package/dist/payment/providers.d.ts +75 -0
- package/dist/payment/providers.js +77 -0
- package/dist/payment/razorpay.d.ts +12 -0
- package/dist/payment/razorpay.js +149 -0
- package/dist/payment/settle.d.ts +11 -0
- package/dist/payment/settle.js +59 -0
- package/dist/payment/sslcommerz.d.ts +12 -0
- package/dist/payment/sslcommerz.js +185 -0
- package/dist/payment/stripe.d.ts +15 -0
- package/dist/payment/stripe.js +182 -0
- package/dist/permissions/admin.d.ts +3 -0
- package/dist/permissions/admin.js +108 -10
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.js +3 -0
- package/dist/permissions/roles.d.ts +2 -0
- package/dist/permissions/roles.js +236 -0
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +29 -1
- package/dist/portal/routes-documents.js +5 -0
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +29 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +29 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +29 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +29 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +29 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +29 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +29 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +29 -1
- package/dist/sales/index.js +2 -0
- package/dist/server/api/rest.d.ts +6 -1
- package/dist/server/api/rest.js +11 -0
- package/dist/server/auth/base.js +22 -0
- package/dist/server/auth/routes.d.ts +3 -0
- package/dist/server/auth/routes.js +29 -0
- package/dist/server/auth/service.d.ts +52 -0
- package/dist/server/auth/service.js +168 -3
- package/dist/server/identity.d.ts +2 -0
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/dist/server/server.js +11 -3
- package/dist/settings/company.d.ts +34 -0
- package/dist/settings/company.js +187 -0
- package/dist/settings/index.d.ts +10 -10
- package/dist/settings/index.js +159 -30
- package/dist/settings/people.d.ts +16 -0
- package/dist/settings/people.js +505 -0
- package/dist/settings/security.d.ts +13 -0
- package/dist/settings/security.js +158 -0
- package/dist/settings/web/AuditPage.d.ts +8 -0
- package/dist/settings/web/AuditPage.js +74 -0
- package/dist/settings/web/CompaniesPage.d.ts +8 -0
- package/dist/settings/web/CompaniesPage.js +52 -0
- package/dist/settings/web/CompanyPage.d.ts +9 -0
- package/dist/settings/web/CompanyPage.js +94 -0
- package/dist/settings/web/OverviewPage.d.ts +9 -0
- package/dist/settings/web/OverviewPage.js +66 -0
- package/dist/settings/web/PeoplePage.d.ts +9 -0
- package/dist/settings/web/PeoplePage.js +163 -0
- package/dist/settings/web/ProfilePage.d.ts +14 -0
- package/dist/settings/web/ProfilePage.js +194 -0
- package/dist/settings/web/RolesEditor.d.ts +35 -0
- package/dist/settings/web/RolesEditor.js +49 -0
- package/dist/settings/web/RolesPage.d.ts +9 -0
- package/dist/settings/web/RolesPage.js +296 -0
- package/dist/settings/web/SecurityPage.d.ts +8 -0
- package/dist/settings/web/SecurityPage.js +81 -0
- package/dist/settings/web/index.d.ts +9 -0
- package/dist/settings/web/index.js +26 -1
- package/dist/settings/web/messages.js +566 -0
- package/dist/ui/api.d.ts +4 -0
- package/dist/ui/app.d.ts +5 -0
- package/dist/ui/app.js +108 -31
- package/dist/ui/components/primitives.d.ts +9 -1
- package/dist/ui/components/primitives.js +12 -4
- package/dist/ui/context.d.ts +10 -1
- package/dist/ui/context.js +60 -2
- package/dist/ui/i18n/messages.d.ts +46 -0
- package/dist/ui/i18n/messages.js +92 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.js +2 -1
- package/dist/ui/lib/accent.d.ts +13 -0
- package/dist/ui/lib/accent.js +36 -0
- package/dist/ui/shell/Activate.d.ts +6 -0
- package/dist/ui/shell/Activate.js +39 -0
- package/dist/ui/shell/CommandPalette.d.ts +7 -1
- package/dist/ui/shell/CommandPalette.js +16 -2
- package/dist/ui/shell/Sidebar.d.ts +27 -0
- package/dist/ui/shell/Sidebar.js +84 -0
- package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
- package/dist/ui/shell/TwoFactorSetup.js +32 -0
- package/dist/ui/shell/shortcuts.d.ts +3 -0
- package/dist/ui/shell/shortcuts.js +13 -4
- package/dist/ui/styles.css +70 -28
- package/dist/ui/views/DateField.d.ts +10 -0
- package/dist/ui/views/DateField.js +129 -0
- package/dist/ui/views/FormView.js +161 -21
- package/dist/ui/views/ListView.d.ts +2 -0
- package/dist/ui/views/ListView.js +8 -3
- package/dist/ui/views/StatusChip.d.ts +10 -0
- package/dist/ui/views/StatusChip.js +22 -0
- package/dist/ui/views/widgets.d.ts +3 -0
- package/dist/ui/views/widgets.js +26 -8
- package/dist/views/types.d.ts +7 -0
- package/dist/views/validate.js +4 -0
- package/package.json +14 -2
|
@@ -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, providerReference) => real().status(reference, mode, providerReference),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PaymentAdapter } from './providers.js';
|
|
2
|
+
export interface StripeOptions {
|
|
3
|
+
readonly secretKey?: string | undefined;
|
|
4
|
+
/** The API's base (default `https://api.stripe.com/v1`). */
|
|
5
|
+
readonly baseUrl?: string | undefined;
|
|
6
|
+
/** How old a signed event may be, in seconds (default 300). */
|
|
7
|
+
readonly tolerance?: number;
|
|
8
|
+
/** The HTTP client (tests). */
|
|
9
|
+
readonly fetch?: typeof fetch;
|
|
10
|
+
/** The clock (tests). */
|
|
11
|
+
readonly now?: () => number;
|
|
12
|
+
}
|
|
13
|
+
export declare function stripeAdapter(options?: StripeOptions): PaymentAdapter;
|
|
14
|
+
/** Stripe with its key from the environment, read on first use. */
|
|
15
|
+
export declare function stripeFromEnvironment(env?: NodeJS.ProcessEnv): PaymentAdapter;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stripe adapter (RFC 0017 §5.5), on Checkout Sessions: a session per transaction (`client_reference_id` and
|
|
3
|
+
* `metadata.reference` = its reference, one line of the invoice's amount in subunits) whose page the customer pays
|
|
4
|
+
* on, back to the return route. Stripe's webhook is set up in its dashboard with the provider's endpoint URL; Stripe
|
|
5
|
+
* gives the signing secret (`whsec_…`), which goes into **the endpoint's Secret**. `Stripe-Signature`
|
|
6
|
+
* (`t=…,v1=…`: HMAC-SHA256 hex of `"<t>.<raw body>"`) is checked with it, and old timestamps are refused (5 minutes)
|
|
7
|
+
* against replays; the event's `id` makes deliveries idempotent. Returns fetch the session from Stripe.
|
|
8
|
+
*
|
|
9
|
+
* Credentials come from the environment, never from records: `SHUTRO_ERP_STRIPE_SECRET_KEY`. Test keys
|
|
10
|
+
* (`sk_test_…`, `rk_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 Checkout Session says of its payment. */
|
|
16
|
+
function sessionResult(reference, session, kind) {
|
|
17
|
+
const theirs = text(session['client_reference_id']) ||
|
|
18
|
+
text(session['metadata']?.['reference']);
|
|
19
|
+
if (theirs !== reference)
|
|
20
|
+
return null;
|
|
21
|
+
const currency = text(session['currency']).toUpperCase();
|
|
22
|
+
const intent = session['payment_intent'];
|
|
23
|
+
const providerReference = (typeof intent === 'string' ? intent : text(intent?.['id'])) ||
|
|
24
|
+
text(session['id']) ||
|
|
25
|
+
null;
|
|
26
|
+
// A delayed method (a bank debit) that did not go through.
|
|
27
|
+
if (kind === 'checkout.session.async_payment_failed')
|
|
28
|
+
return {
|
|
29
|
+
reference,
|
|
30
|
+
status: 'failed',
|
|
31
|
+
providerReference,
|
|
32
|
+
error: 'The payment failed at Stripe.',
|
|
33
|
+
};
|
|
34
|
+
if (text(session['payment_status']) === 'paid')
|
|
35
|
+
return {
|
|
36
|
+
reference,
|
|
37
|
+
status: 'done',
|
|
38
|
+
providerReference,
|
|
39
|
+
amount: fromSubunits(Number(session['amount_total'] ?? 0), currency),
|
|
40
|
+
currency,
|
|
41
|
+
};
|
|
42
|
+
if (text(session['status']) === 'expired' || kind === 'checkout.session.expired')
|
|
43
|
+
return { reference, status: 'cancelled', providerReference };
|
|
44
|
+
return { reference, status: 'pending', providerReference };
|
|
45
|
+
}
|
|
46
|
+
/** Stripe's form encoding of nested parameters (`line_items[0][price_data][currency]=bdt`). */
|
|
47
|
+
function form(params, prefix = '', out = new URLSearchParams()) {
|
|
48
|
+
for (const [key, value] of Object.entries(params)) {
|
|
49
|
+
const name = prefix === '' ? key : `${prefix}[${key}]`;
|
|
50
|
+
if (value === undefined || value === null)
|
|
51
|
+
continue;
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
value.forEach((v, i) => {
|
|
54
|
+
if (typeof v === 'object' && v !== null)
|
|
55
|
+
form(v, `${name}[${i}]`, out);
|
|
56
|
+
else
|
|
57
|
+
out.append(`${name}[${i}]`, String(v));
|
|
58
|
+
});
|
|
59
|
+
else if (typeof value === 'object')
|
|
60
|
+
form(value, name, out);
|
|
61
|
+
else
|
|
62
|
+
out.append(name, String(value));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
export function stripeAdapter(options = {}) {
|
|
67
|
+
const key = options.secretKey ?? '';
|
|
68
|
+
const base = (options.baseUrl || 'https://api.stripe.com/v1').replace(/\/$/, '');
|
|
69
|
+
const http = options.fetch ?? fetch;
|
|
70
|
+
const tolerance = options.tolerance ?? 300;
|
|
71
|
+
const now = options.now ?? (() => Date.now());
|
|
72
|
+
const ready = /^(sk|rk)_(test|live)_/.test(key);
|
|
73
|
+
const call = async (method, path, params) => {
|
|
74
|
+
if (!ready)
|
|
75
|
+
throw new Error('Stripe is not configured: set SHUTRO_ERP_STRIPE_SECRET_KEY.');
|
|
76
|
+
const res = await http(`${base}${path}`, {
|
|
77
|
+
method,
|
|
78
|
+
headers: {
|
|
79
|
+
authorization: `Bearer ${key}`,
|
|
80
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
81
|
+
},
|
|
82
|
+
...(params === undefined ? {} : { body: form(params) }),
|
|
83
|
+
signal: AbortSignal.timeout(30_000),
|
|
84
|
+
});
|
|
85
|
+
const answer = (await res.json().catch(() => ({})));
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const error = (answer['error'] ?? {});
|
|
88
|
+
throw new Error(`Stripe answered ${res.status}: ${text(error['message']) || 'no detail'}.`);
|
|
89
|
+
}
|
|
90
|
+
return answer;
|
|
91
|
+
};
|
|
92
|
+
const session = (id) => call('GET', `/checkout/sessions/${encodeURIComponent(id)}`);
|
|
93
|
+
return {
|
|
94
|
+
code: 'stripe',
|
|
95
|
+
configured: (mode) => ready && key.includes(mode === 'test' ? '_test_' : '_live_'),
|
|
96
|
+
async createPayment(request) {
|
|
97
|
+
const made = await call('POST', '/checkout/sessions', {
|
|
98
|
+
mode: 'payment',
|
|
99
|
+
client_reference_id: request.reference,
|
|
100
|
+
success_url: request.returnUrl,
|
|
101
|
+
cancel_url: request.returnUrl,
|
|
102
|
+
...(request.customer.email === null ? {} : { customer_email: request.customer.email }),
|
|
103
|
+
line_items: [
|
|
104
|
+
{
|
|
105
|
+
quantity: 1,
|
|
106
|
+
price_data: {
|
|
107
|
+
currency: request.currency.toLowerCase(),
|
|
108
|
+
unit_amount: toSubunits(request.amount, request.currency),
|
|
109
|
+
product_data: { name: request.description },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
metadata: { reference: request.reference },
|
|
114
|
+
payment_intent_data: { metadata: { reference: request.reference } },
|
|
115
|
+
});
|
|
116
|
+
const id = text(made['id']);
|
|
117
|
+
const page = text(made['url']);
|
|
118
|
+
if (id === '' || page === '')
|
|
119
|
+
throw new Error('Stripe made no Checkout Session.');
|
|
120
|
+
return { redirectUrl: page, providerReference: id };
|
|
121
|
+
},
|
|
122
|
+
verifyCallback(call, secret) {
|
|
123
|
+
const header = call.headers['stripe-signature'] ?? '';
|
|
124
|
+
const parts = header.split(',').map((p) => p.split('='));
|
|
125
|
+
const t = Number(parts.find(([k]) => k === 't')?.[1]);
|
|
126
|
+
if (!Number.isFinite(t) || Math.abs(now() / 1000 - t) > tolerance)
|
|
127
|
+
return null;
|
|
128
|
+
const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${call.body}`).digest('hex'));
|
|
129
|
+
const genuine = parts
|
|
130
|
+
.filter(([k]) => k === 'v1')
|
|
131
|
+
.some(([, v]) => {
|
|
132
|
+
const given = Buffer.from(v ?? '');
|
|
133
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
134
|
+
});
|
|
135
|
+
if (!genuine)
|
|
136
|
+
return null;
|
|
137
|
+
const id = text(call.json?.['id']);
|
|
138
|
+
return id !== '' ? id : null;
|
|
139
|
+
},
|
|
140
|
+
readCallback(call) {
|
|
141
|
+
const event = call.json;
|
|
142
|
+
const kind = text(event?.['type']);
|
|
143
|
+
if (!kind.startsWith('checkout.session.'))
|
|
144
|
+
return null;
|
|
145
|
+
const object = (event?.['data']?.['object'] ?? null);
|
|
146
|
+
if (object === null)
|
|
147
|
+
return null;
|
|
148
|
+
const reference = text(object['client_reference_id']) ||
|
|
149
|
+
text(object['metadata']?.['reference']);
|
|
150
|
+
const result = sessionResult(reference, object, kind);
|
|
151
|
+
return result === null || result.status === 'pending' ? null : result;
|
|
152
|
+
},
|
|
153
|
+
async returned({ reference, providerReference }) {
|
|
154
|
+
if (providerReference === null || providerReference === '')
|
|
155
|
+
return null;
|
|
156
|
+
const result = sessionResult(reference, await session(providerReference));
|
|
157
|
+
return result?.status === 'pending' ? null : result;
|
|
158
|
+
},
|
|
159
|
+
async status(reference, _mode, providerReference) {
|
|
160
|
+
if (!ready || providerReference === null || providerReference === '')
|
|
161
|
+
return null;
|
|
162
|
+
return sessionResult(reference, await session(providerReference));
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Stripe with its key from the environment, read on first use. */
|
|
167
|
+
export function stripeFromEnvironment(env = process.env) {
|
|
168
|
+
let made;
|
|
169
|
+
const real = () => (made ??= stripeAdapter({
|
|
170
|
+
secretKey: env['KOSH_STRIPE_SECRET_KEY'],
|
|
171
|
+
baseUrl: env['KOSH_STRIPE_BASE_URL'],
|
|
172
|
+
}));
|
|
173
|
+
return {
|
|
174
|
+
code: 'stripe',
|
|
175
|
+
configured: (mode) => real().configured(mode),
|
|
176
|
+
createPayment: (request) => real().createPayment(request),
|
|
177
|
+
verifyCallback: (call, secret) => real().verifyCallback(call, secret),
|
|
178
|
+
readCallback: (call) => real().readCallback(call),
|
|
179
|
+
returned: (input) => real().returned?.(input) ?? Promise.resolve(null),
|
|
180
|
+
status: (reference, mode, providerReference) => real().status(reference, mode, providerReference),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
* Approval policies are data: administrators edit `approval.policy` / `approval.level` through the generated API.
|
|
10
10
|
*/
|
|
11
11
|
import { type ModuleContext } from '../server/index.js';
|
|
12
|
+
import type { Env } from '../server/orm.js';
|
|
13
|
+
/** Refuses non-administrators (or, with `permission`, users without it). */
|
|
14
|
+
export declare function requireAdministrator(env: Env, permission?: string): void;
|
|
12
15
|
/** Registers `/api/v1/admin/*`. */
|
|
13
16
|
export declare function registerAdminApi(context: ModuleContext): void;
|
|
@@ -43,7 +43,7 @@ function idParam(request, key = 'id') {
|
|
|
43
43
|
return id;
|
|
44
44
|
}
|
|
45
45
|
/** Refuses non-administrators (or, with `permission`, users without it). */
|
|
46
|
-
function requireAdministrator(env, permission) {
|
|
46
|
+
export function requireAdministrator(env, permission) {
|
|
47
47
|
if (env.administrator)
|
|
48
48
|
return;
|
|
49
49
|
if (permission !== undefined && env.access.has(permission))
|
|
@@ -99,6 +99,10 @@ export function registerAdminApi(context) {
|
|
|
99
99
|
params: [],
|
|
100
100
|
});
|
|
101
101
|
const admins = env.access.administratorGroups();
|
|
102
|
+
// How many people hold each role (administrators read the assignments).
|
|
103
|
+
const holders = new Map();
|
|
104
|
+
for (const a of await find(env, 'auth.user_group', {}, ['user', 'group']))
|
|
105
|
+
holders.set(String(a['group']), (holders.get(String(a['group'])) ?? new Set()).add(a['user']));
|
|
102
106
|
const levelsOf = (implies) => {
|
|
103
107
|
const closure = reg.security.closure(implies.filter((c) => reg.security.groups.has(c)));
|
|
104
108
|
return [...loaded.accessLevels.entries()]
|
|
@@ -114,6 +118,7 @@ export function registerAdminApi(context) {
|
|
|
114
118
|
administrator: admins.has(g.code),
|
|
115
119
|
builtIn: true,
|
|
116
120
|
accessLevels: levelsOf(g.implies),
|
|
121
|
+
holders: holders.get(g.code)?.size ?? 0,
|
|
117
122
|
})),
|
|
118
123
|
...runtime.map((r) => ({
|
|
119
124
|
code: r.code,
|
|
@@ -122,6 +127,7 @@ export function registerAdminApi(context) {
|
|
|
122
127
|
administrator: admins.has(r.code),
|
|
123
128
|
builtIn: false,
|
|
124
129
|
accessLevels: levelsOf(r.implies),
|
|
130
|
+
holders: holders.get(r.code)?.size ?? 0,
|
|
125
131
|
})),
|
|
126
132
|
],
|
|
127
133
|
};
|
|
@@ -342,6 +348,7 @@ export function registerAdminApi(context) {
|
|
|
342
348
|
models: Object.fromEntries(models.map((name) => [
|
|
343
349
|
name,
|
|
344
350
|
{
|
|
351
|
+
label: reg.get(name).model.label ?? name,
|
|
345
352
|
perms: PERMS.filter((p) => env.access.can(name, p)),
|
|
346
353
|
hiddenFields: Object.keys(reg.get(name).model.fields).filter((k) => !env.access.canField(name, k, 'read')),
|
|
347
354
|
readonlyFields: Object.keys(reg.get(name).model.fields).filter((k) => env.access.canField(name, k, 'read') &&
|
|
@@ -351,25 +358,67 @@ export function registerAdminApi(context) {
|
|
|
351
358
|
permissions: [...reg.security.permissions.keys()]
|
|
352
359
|
.filter((code) => env.access.has(code))
|
|
353
360
|
.sort(),
|
|
361
|
+
/** The permissions' names, for screens. */
|
|
362
|
+
permissionLabels: Object.fromEntries([...reg.security.permissions.values()].map((p) => [p.code, p.label ?? p.code])),
|
|
354
363
|
};
|
|
355
364
|
});
|
|
356
365
|
});
|
|
357
366
|
const hidden = new Set(loaded.modules.flatMap((m) => m.privateModels ?? []));
|
|
358
367
|
const privateModel = (name) => hidden.has(name);
|
|
359
368
|
// ------------------------------------------------------------------------------------------- audit log
|
|
360
|
-
|
|
369
|
+
// Filters (RFC 0019 §3.5): `kind` (`auth.` for a family), `actor` (a user id), `model` and `record` (the target),
|
|
370
|
+
// `from` / `to` (ISO dates, `to` inclusive of its day), `q` (words of the summary), `before` (an id: the next page);
|
|
371
|
+
// `format=csv` downloads the page as CSV — an export, itself audited.
|
|
372
|
+
app.get('/api/v1/admin/audit', async (request, reply) => {
|
|
361
373
|
const q = (request.query ?? {});
|
|
362
|
-
const limit = q
|
|
374
|
+
const limit = q['limit'] === undefined ? 100 : Number(q['limit']);
|
|
363
375
|
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
364
376
|
throw bad('limit must be 1–1000.');
|
|
365
|
-
|
|
377
|
+
const where = {};
|
|
378
|
+
const text = (key) => typeof q[key] === 'string' && q[key] !== '' ? q[key] : undefined;
|
|
379
|
+
const id = (key) => {
|
|
380
|
+
const v = text(key);
|
|
381
|
+
if (v === undefined)
|
|
382
|
+
return undefined;
|
|
383
|
+
const n = Number(v);
|
|
384
|
+
if (!Number.isSafeInteger(n) || n <= 0)
|
|
385
|
+
throw bad(`${key} is a positive integer.`);
|
|
386
|
+
return n;
|
|
387
|
+
};
|
|
388
|
+
const date = (key) => {
|
|
389
|
+
const v = text(key);
|
|
390
|
+
if (v === undefined)
|
|
391
|
+
return undefined;
|
|
392
|
+
const d = new Date(v);
|
|
393
|
+
if (Number.isNaN(d.getTime()))
|
|
394
|
+
throw bad(`${key} is an ISO date.`);
|
|
395
|
+
return d;
|
|
396
|
+
};
|
|
397
|
+
const kind = text('kind');
|
|
398
|
+
if (kind !== undefined)
|
|
399
|
+
where['kind'] = kind.endsWith('.') ? { like: `${kind}%` } : kind;
|
|
400
|
+
if (id('actor') !== undefined)
|
|
401
|
+
where['actor'] = id('actor');
|
|
402
|
+
if (text('model') !== undefined)
|
|
403
|
+
where['targetModel'] = text('model');
|
|
404
|
+
if (id('record') !== undefined)
|
|
405
|
+
where['targetId'] = id('record');
|
|
406
|
+
if (text('q') !== undefined)
|
|
407
|
+
where['summary'] = { ilike: `%${text('q')}%` };
|
|
408
|
+
const from = date('from');
|
|
409
|
+
const to = date('to');
|
|
410
|
+
if (from !== undefined || to !== undefined)
|
|
411
|
+
where['createdAt'] = {
|
|
412
|
+
...(from === undefined ? {} : { gte: from }),
|
|
413
|
+
// `to` is a day: everything before the next one.
|
|
414
|
+
...(to === undefined ? {} : { lt: new Date(to.getTime() + 86_400_000) }),
|
|
415
|
+
};
|
|
416
|
+
if (id('before') !== undefined)
|
|
417
|
+
where['id'] = { lt: id('before') };
|
|
418
|
+
const csv = q['format'] === 'csv';
|
|
419
|
+
const events = await request.env(async (env) => {
|
|
366
420
|
requireAdministrator(env, 'permissions.view_audit');
|
|
367
421
|
return env.asSuperuser({ reason: 'read audit log' }, async (su) => {
|
|
368
|
-
const where = {};
|
|
369
|
-
if (typeof q.kind === 'string' && q.kind !== '')
|
|
370
|
-
where['kind'] = q.kind.endsWith('.') ? { like: `${q.kind}%` } : q.kind;
|
|
371
|
-
if (q.before !== undefined)
|
|
372
|
-
where['id'] = { lt: Number(q.before) };
|
|
373
422
|
const set = await ref(su, 'audit.event').search({ where, order: ['id desc'], limit });
|
|
374
423
|
const keys = [
|
|
375
424
|
'kind',
|
|
@@ -382,8 +431,57 @@ export function registerAdminApi(context) {
|
|
|
382
431
|
'ip',
|
|
383
432
|
'createdAt',
|
|
384
433
|
];
|
|
385
|
-
|
|
434
|
+
const found = set.length === 0 ? [] : (await set.load(keys)).read(['id', ...keys]);
|
|
435
|
+
const actors = [
|
|
436
|
+
...new Set(found.map((e) => e['actor']).filter((a) => typeof a === 'number')),
|
|
437
|
+
];
|
|
438
|
+
const names = new Map(actors.length === 0
|
|
439
|
+
? []
|
|
440
|
+
: (await // Deactivated people keep their name in the log.
|
|
441
|
+
(await ref(su.withFlags({ activeTest: false }), 'res.users').search({
|
|
442
|
+
where: { id: { in: actors } },
|
|
443
|
+
})).load(['name']))
|
|
444
|
+
.read(['id', 'name'])
|
|
445
|
+
.map((u) => [u['id'], String(u['name'])]));
|
|
446
|
+
if (csv)
|
|
447
|
+
await audit(env, {
|
|
448
|
+
kind: 'data.export',
|
|
449
|
+
summary: `Exported ${found.length} audit event(s).`,
|
|
450
|
+
targetModel: 'audit.event',
|
|
451
|
+
details: { filters: where },
|
|
452
|
+
ip: request.ip,
|
|
453
|
+
});
|
|
454
|
+
return found.map((e) => ({
|
|
455
|
+
...e,
|
|
456
|
+
actorName: typeof e['actor'] === 'number' ? (names.get(e['actor']) ?? null) : null,
|
|
457
|
+
}));
|
|
386
458
|
});
|
|
387
459
|
}, { write: true }); // the system read of the log is itself audited
|
|
460
|
+
if (!csv)
|
|
461
|
+
return { events };
|
|
462
|
+
const cell = (v) => {
|
|
463
|
+
const s = v === null || v === undefined ? '' : v instanceof Date ? v.toISOString() : String(v);
|
|
464
|
+
// Quoted, and never read as a formula by a spreadsheet.
|
|
465
|
+
return `"${(/^[=+\-@]/.test(s) ? `'${s}` : s).replaceAll('"', '""')}"`;
|
|
466
|
+
};
|
|
467
|
+
const columns = [
|
|
468
|
+
'id',
|
|
469
|
+
'createdAt',
|
|
470
|
+
'kind',
|
|
471
|
+
'actorName',
|
|
472
|
+
'summary',
|
|
473
|
+
'targetModel',
|
|
474
|
+
'targetId',
|
|
475
|
+
'ip',
|
|
476
|
+
];
|
|
477
|
+
const lines = [
|
|
478
|
+
columns.join(','),
|
|
479
|
+
...events.map((e) => columns.map((c) => cell(e[c])).join(',')),
|
|
480
|
+
];
|
|
481
|
+
// A byte-order mark so spreadsheets read UTF-8 (Bangla names).
|
|
482
|
+
return reply
|
|
483
|
+
.header('content-type', 'text/csv; charset=utf-8')
|
|
484
|
+
.header('content-disposition', 'attachment; filename="audit-log.csv"')
|
|
485
|
+
.send(`${lines.join('\r\n')}\r\n`);
|
|
388
486
|
});
|
|
389
487
|
}
|
|
@@ -8,6 +8,7 @@ import { defineModule } from '../modules-sdk/index.js';
|
|
|
8
8
|
export { registerAdminApi } from './admin.js';
|
|
9
9
|
export { type ApprovalRequestInfo, approvalGuard, approvalState, decide, inbox, installApprovalPolicies, requestApprovals, requestsOf, } from './approvals.js';
|
|
10
10
|
export { permissionsShutro } from './models.js';
|
|
11
|
+
export { registerRoleRoutes } from './roles.js';
|
|
11
12
|
export { registerApprovalRoutes } from './routes.js';
|
|
12
13
|
/** Package identity. */
|
|
13
14
|
export declare const PACKAGE: {
|
|
@@ -9,10 +9,12 @@ import { base } from '../server/index.js';
|
|
|
9
9
|
import { registerAdminApi } from './admin.js';
|
|
10
10
|
import { installApprovalPolicies } from './approvals.js';
|
|
11
11
|
import { permissionsShutro } from './models.js';
|
|
12
|
+
import { registerRoleRoutes } from './roles.js';
|
|
12
13
|
import { registerApprovalRoutes } from './routes.js';
|
|
13
14
|
export { registerAdminApi } from './admin.js';
|
|
14
15
|
export { approvalGuard, approvalState, decide, inbox, installApprovalPolicies, requestApprovals, requestsOf, } from './approvals.js';
|
|
15
16
|
export { permissionsShutro } from './models.js';
|
|
17
|
+
export { registerRoleRoutes } from './roles.js';
|
|
16
18
|
export { registerApprovalRoutes } from './routes.js';
|
|
17
19
|
/** Package identity. */
|
|
18
20
|
export const PACKAGE = Object.freeze({
|
|
@@ -36,6 +38,7 @@ export const permissions = defineModule({
|
|
|
36
38
|
},
|
|
37
39
|
routes: (context) => {
|
|
38
40
|
registerAdminApi(context);
|
|
41
|
+
registerRoleRoutes(context);
|
|
39
42
|
registerApprovalRoutes(context);
|
|
40
43
|
},
|
|
41
44
|
});
|