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,77 @@
|
|
|
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
|
+
bkash: 'bKash',
|
|
20
|
+
razorpay: 'Razorpay',
|
|
21
|
+
stripe: 'Stripe',
|
|
22
|
+
};
|
|
23
|
+
export const PaymentProvider = defineModel('payment.provider', {
|
|
24
|
+
label: 'Payment provider',
|
|
25
|
+
recName: 'name',
|
|
26
|
+
companyScoped: { required: true },
|
|
27
|
+
order: ['sequence asc', 'id asc'],
|
|
28
|
+
tracking: ['state', 'journal'],
|
|
29
|
+
access: { 'account.user': ['read'], 'account.manager': ALL },
|
|
30
|
+
fields: {
|
|
31
|
+
name: f.char({ required: true }),
|
|
32
|
+
code: f.selection(PROVIDERS, { required: true, default: 'fake', label: 'Provider' }),
|
|
33
|
+
state: f.selection({ disabled: 'Disabled', test: 'Test', live: 'Live' }, { required: true, default: 'test', label: 'Mode' }),
|
|
34
|
+
/** Where the money arrives: the payments are registered in this bank (or cash) journal. */
|
|
35
|
+
journal: f.many2one('account.journal', { required: true, checkCompany: true }),
|
|
36
|
+
/** The inbound webhook the provider's callbacks come through (made with the provider). */
|
|
37
|
+
endpoint: f.many2one('webhook.endpoint', { label: 'Callback endpoint' }),
|
|
38
|
+
sequence: f.integer({ required: true, default: 10 }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
export const PaymentTransaction = defineModel('payment.transaction', {
|
|
42
|
+
label: 'Payment transaction',
|
|
43
|
+
recName: 'reference',
|
|
44
|
+
companyScoped: { required: true },
|
|
45
|
+
order: ['id desc'],
|
|
46
|
+
tracking: ['state'],
|
|
47
|
+
access: {
|
|
48
|
+
'account.invoicing': ['read'],
|
|
49
|
+
'account.user': ['read', 'export'],
|
|
50
|
+
'account.manager': ['read', 'export'],
|
|
51
|
+
},
|
|
52
|
+
fields: {
|
|
53
|
+
reference: f.char({ required: true, index: 'unique', default: newReference }),
|
|
54
|
+
provider: f.many2one('payment.provider', { required: true, checkCompany: true }),
|
|
55
|
+
move: f.many2one('account.move', { required: true, label: 'Invoice', checkCompany: true }),
|
|
56
|
+
partner: f.many2one('res.partner', { label: 'Customer' }),
|
|
57
|
+
currency: f.many2one('res.currency', { required: true }),
|
|
58
|
+
amount: f.monetary({ currency: 'currency', required: true }),
|
|
59
|
+
state: f.selection({
|
|
60
|
+
pending: 'Pending',
|
|
61
|
+
done: 'Paid',
|
|
62
|
+
failed: 'Failed',
|
|
63
|
+
cancelled: 'Cancelled',
|
|
64
|
+
}, { required: true, default: 'pending' }),
|
|
65
|
+
providerReference: f.char({ label: 'Provider reference' }),
|
|
66
|
+
/** The payment registered when the provider confirmed. */
|
|
67
|
+
payment: f.many2one('account.payment', { label: 'Payment' }),
|
|
68
|
+
error: f.text(),
|
|
69
|
+
doneAt: f.datetime({ label: 'Paid at' }),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
export const paymentShutro = defineModule({
|
|
73
|
+
name: 'payment',
|
|
74
|
+
version: '0.1.0',
|
|
75
|
+
depends: [accountPortalShutro, integrationsShutro],
|
|
76
|
+
models: [PaymentProvider, PaymentTransaction],
|
|
77
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 (and the provider's id of it, when it has one). */
|
|
42
|
+
createPayment(request: PaymentRequest): Promise<{
|
|
43
|
+
readonly redirectUrl: string;
|
|
44
|
+
readonly providerReference?: string;
|
|
45
|
+
}>;
|
|
46
|
+
/** A callback's event id, or `null` when it is not genuine (checked with the endpoint's secret). */
|
|
47
|
+
verifyCallback(call: InboundCall, secret: string): string | null | Promise<string | null>;
|
|
48
|
+
/** What a verified callback says. */
|
|
49
|
+
readCallback(call: InboundCall): PaymentResult | null;
|
|
50
|
+
/** The provider's word on a payment (null: unknown to it). */
|
|
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>;
|
|
62
|
+
}
|
|
63
|
+
/** The fake adapter's header: HMAC-SHA256 (hex) of the raw body with the endpoint's secret. */
|
|
64
|
+
export declare const FAKE_SIGNATURE = "x-fake-signature";
|
|
65
|
+
export declare const fakeAdapter: PaymentAdapter & {
|
|
66
|
+
/** The payment the fake checkout shows. */
|
|
67
|
+
pending(reference: string): PaymentRequest | null;
|
|
68
|
+
/** The customer paid or declined on the fake checkout: the result and the signed callback body. */
|
|
69
|
+
complete(reference: string, paid: boolean, secret: string): {
|
|
70
|
+
body: string;
|
|
71
|
+
signature: string;
|
|
72
|
+
} | null;
|
|
73
|
+
};
|
|
74
|
+
/** The adapters by code. */
|
|
75
|
+
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,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
|
+
}
|
|
@@ -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;
|