shutro-erp 0.2.0 → 0.3.0
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/README.md +17 -10
- package/dist/account_portal/.kosh/bindings.d.ts +22 -0
- package/dist/account_portal/.kosh/bindings.js +15 -0
- package/dist/account_portal/.kosh/schema.d.ts +1350 -0
- package/dist/account_portal/index.d.ts +3 -0
- package/dist/account_portal/index.js +73 -0
- package/dist/account_portal/module.d.ts +5983 -0
- package/dist/account_portal/module.js +18 -0
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +142 -9
- package/dist/accounting/chart.d.ts +40 -8
- package/dist/accounting/chart.js +124 -30
- package/dist/accounting/closing.d.ts +6 -1
- package/dist/accounting/closing.js +5 -1
- package/dist/accounting/demo.js +6 -5
- package/dist/accounting/index.d.ts +3 -3
- package/dist/accounting/index.js +200 -23
- package/dist/accounting/module.d.ts +3326 -1086
- package/dist/accounting/module.js +408 -66
- package/dist/accounting/payments.d.ts +25 -0
- package/dist/accounting/payments.js +96 -4
- package/dist/accounting/web/RegisterPayment.d.ts +3 -2
- package/dist/accounting/web/RegisterPayment.js +39 -6
- package/dist/accounting/web/SettingsPage.js +5 -1
- package/dist/accounting/web/messages.js +10 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +195 -53
- package/dist/cli/launch.d.ts +16 -0
- package/dist/cli/launch.js +138 -0
- package/dist/cli/prompt.d.ts +35 -0
- package/dist/cli/prompt.js +46 -0
- package/dist/cli/templates.d.ts +11 -3
- package/dist/cli/templates.js +32 -7
- package/dist/cli/tui.d.ts +7 -0
- package/dist/cli/tui.js +254 -0
- package/dist/cli/ui.d.ts +25 -5
- package/dist/cli/ui.js +49 -9
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +3 -1
- package/dist/contacts/index.js +24 -1
- package/dist/contacts/module.d.ts +42 -0
- package/dist/contacts/module.js +3 -1
- package/dist/import/convert.d.ts +55 -0
- package/dist/import/convert.js +224 -0
- package/dist/import/csv.d.ts +28 -0
- package/dist/import/csv.js +115 -0
- package/dist/import/index.d.ts +33 -0
- package/dist/import/index.js +60 -0
- package/dist/import/mapping.d.ts +15 -0
- package/dist/import/mapping.js +33 -0
- package/dist/import/match.d.ts +24 -0
- package/dist/import/match.js +60 -0
- package/dist/import/models.d.ts +2 -0
- package/dist/import/models.js +102 -0
- package/dist/import/routes.d.ts +13 -0
- package/dist/import/routes.js +392 -0
- package/dist/import/run.d.ts +131 -0
- package/dist/import/run.js +568 -0
- package/dist/import/service.d.ts +153 -0
- package/dist/import/service.js +367 -0
- package/dist/import/web/ImportButton.d.ts +7 -0
- package/dist/import/web/ImportButton.js +31 -0
- package/dist/import/web/ImportWizard.d.ts +75 -0
- package/dist/import/web/ImportWizard.js +217 -0
- package/dist/import/web/index.d.ts +13 -0
- package/dist/import/web/index.js +18 -0
- package/dist/import/web/messages.d.ts +2 -0
- package/dist/import/web/messages.js +139 -0
- package/dist/import/worker.d.ts +3 -0
- package/dist/import/worker.js +52 -0
- package/dist/import/xlsx.d.ts +20 -0
- package/dist/import/xlsx.js +192 -0
- package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
- package/dist/l10n_bd/.kosh/bindings.js +15 -0
- package/dist/l10n_bd/.kosh/schema.d.ts +1281 -0
- package/dist/l10n_bd/documents.d.ts +8 -0
- package/dist/l10n_bd/documents.js +183 -0
- package/dist/l10n_bd/index.d.ts +8 -0
- package/dist/l10n_bd/index.js +156 -0
- package/dist/l10n_bd/module.d.ts +5705 -0
- package/dist/l10n_bd/module.js +163 -0
- package/dist/l10n_bd/mushak.d.ts +67 -0
- package/dist/l10n_bd/mushak.js +139 -0
- package/dist/l10n_bd/routes.d.ts +3 -0
- package/dist/l10n_bd/routes.js +205 -0
- package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
- package/dist/l10n_bd/web/MushakPage.js +53 -0
- package/dist/l10n_bd/web/index.d.ts +13 -0
- package/dist/l10n_bd/web/index.js +18 -0
- package/dist/l10n_bd/web/messages.d.ts +2 -0
- package/dist/l10n_bd/web/messages.js +101 -0
- package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
- package/dist/l10n_in/.kosh/bindings.js +15 -0
- package/dist/l10n_in/.kosh/schema.d.ts +1343 -0
- package/dist/l10n_in/documents.d.ts +8 -0
- package/dist/l10n_in/documents.js +114 -0
- package/dist/l10n_in/einvoice.d.ts +253 -0
- package/dist/l10n_in/einvoice.js +485 -0
- package/dist/l10n_in/gstr.d.ts +109 -0
- package/dist/l10n_in/gstr.js +430 -0
- package/dist/l10n_in/index.d.ts +20 -0
- package/dist/l10n_in/index.js +232 -0
- package/dist/l10n_in/module.d.ts +7823 -0
- package/dist/l10n_in/module.js +427 -0
- package/dist/l10n_in/routes-einvoice.d.ts +15 -0
- package/dist/l10n_in/routes-einvoice.js +124 -0
- package/dist/l10n_in/routes.d.ts +17 -0
- package/dist/l10n_in/routes.js +282 -0
- package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
- package/dist/l10n_in/web/EInvoicePanel.js +63 -0
- package/dist/l10n_in/web/GstPage.d.ts +7 -0
- package/dist/l10n_in/web/GstPage.js +57 -0
- package/dist/l10n_in/web/GstSettings.d.ts +10 -0
- package/dist/l10n_in/web/GstSettings.js +36 -0
- package/dist/l10n_in/web/index.d.ts +15 -0
- package/dist/l10n_in/web/index.js +22 -0
- package/dist/l10n_in/web/messages.d.ts +2 -0
- package/dist/l10n_in/web/messages.js +166 -0
- package/dist/modules-sdk/demo.d.ts +2 -0
- package/dist/modules-sdk/demo.js +7 -3
- package/dist/modules-sdk/index.d.ts +2 -2
- package/dist/modules-sdk/index.js +1 -1
- package/dist/modules-sdk/manifest.d.ts +132 -0
- package/dist/modules-sdk/manifest.js +22 -0
- package/dist/notify/inbox.js +21 -0
- package/dist/notify/messages.js +4 -0
- package/dist/notify/models.d.ts +1 -1
- package/dist/notify/models.js +2 -0
- package/dist/notify/web/messages.js +4 -0
- package/dist/portal/.kosh/bindings.d.ts +22 -0
- package/dist/portal/.kosh/bindings.js +15 -0
- package/dist/portal/.kosh/schema.d.ts +346 -0
- package/dist/portal/announce.d.ts +16 -0
- package/dist/portal/announce.js +117 -0
- package/dist/portal/documents.d.ts +17 -0
- package/dist/portal/documents.js +61 -0
- package/dist/portal/index.d.ts +20 -0
- package/dist/portal/index.js +65 -0
- package/dist/portal/module.d.ts +1405 -0
- package/dist/portal/module.js +84 -0
- package/dist/portal/routes-documents.d.ts +2 -0
- package/dist/portal/routes-documents.js +391 -0
- package/dist/portal/routes.d.ts +13 -0
- package/dist/portal/routes.js +313 -0
- package/dist/portal/rules.d.ts +11 -0
- package/dist/portal/rules.js +25 -0
- package/dist/portal/web/PortalAccess.d.ts +8 -0
- package/dist/portal/web/PortalAccess.js +66 -0
- package/dist/portal/web/index.d.ts +12 -0
- package/dist/portal/web/index.js +17 -0
- package/dist/portal/web/messages.d.ts +2 -0
- package/dist/portal/web/messages.js +33 -0
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +12 -3
- package/dist/product/demo.js +6 -5
- package/dist/product/index.d.ts +1 -0
- package/dist/product/index.js +17 -4
- package/dist/product/module.d.ts +346 -14
- package/dist/product/module.js +48 -18
- package/dist/product/taxes.d.ts +59 -0
- package/dist/product/taxes.js +61 -0
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +3 -1
- package/dist/project/module.d.ts +20 -0
- package/dist/project_portal/.kosh/bindings.d.ts +22 -0
- package/dist/project_portal/.kosh/bindings.js +15 -0
- package/dist/project_portal/.kosh/schema.d.ts +671 -0
- package/dist/project_portal/index.d.ts +9 -0
- package/dist/project_portal/index.js +42 -0
- package/dist/project_portal/module.d.ts +2142 -0
- package/dist/project_portal/module.js +22 -0
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +15 -4
- package/dist/purchase/demo.js +3 -3
- package/dist/purchase/index.js +1 -1
- package/dist/purchase/module.d.ts +232 -8
- package/dist/purchase/module.js +51 -9
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +144 -10
- package/dist/purchase_account/module.d.ts +2008 -641
- package/dist/purchase_account/module.js +2 -2
- package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
- package/dist/purchase_portal/.kosh/bindings.js +15 -0
- package/dist/purchase_portal/.kosh/schema.d.ts +822 -0
- package/dist/purchase_portal/index.d.ts +9 -0
- package/dist/purchase_portal/index.js +53 -0
- package/dist/purchase_portal/module.d.ts +2758 -0
- package/dist/purchase_portal/module.js +18 -0
- package/dist/reporting/documents.js +20 -3
- package/dist/reporting/index.d.ts +1 -0
- package/dist/reporting/index.js +1 -0
- package/dist/reporting/routes-documents.js +9 -2
- package/dist/reporting/words.d.ts +11 -0
- package/dist/reporting/words.js +229 -0
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +144 -10
- package/dist/sale_account/module.d.ts +2008 -641
- package/dist/sale_account/module.js +3 -3
- package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
- package/dist/sale_portal/.kosh/bindings.js +15 -0
- package/dist/sale_portal/.kosh/schema.d.ts +736 -0
- package/dist/sale_portal/index.d.ts +9 -0
- package/dist/sale_portal/index.js +71 -0
- package/dist/sale_portal/module.d.ts +2718 -0
- package/dist/sale_portal/module.js +19 -0
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +15 -4
- package/dist/sale_project/module.d.ts +390 -10
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +15 -4
- package/dist/sales/demo.js +3 -3
- package/dist/sales/index.js +67 -1
- package/dist/sales/module.d.ts +232 -8
- package/dist/sales/module.js +51 -12
- package/dist/server/auth/base.js +2 -0
- package/dist/server/auth/service.d.ts +2 -0
- package/dist/server/auth/service.js +7 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/server.js +26 -1
- package/dist/ui/api.d.ts +2 -0
- package/dist/ui/app.js +1 -1
- package/dist/ui/context.d.ts +3 -1
- package/dist/ui/context.js +2 -2
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +1 -1
- package/dist/ui/lib/format.d.ts +2 -0
- package/dist/ui/lib/format.js +3 -1
- package/dist/ui/views/FormView.js +19 -6
- package/package.json +50 -2
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal access (RFC 0015 §5.1–5.2).
|
|
3
|
+
*
|
|
4
|
+
* In the back-office, for holders of `portal.manager` (and administrators):
|
|
5
|
+
* - `GET /api/v1/portal/access?partner=…` the contact's portal users (active or not, activated or not)
|
|
6
|
+
* - `POST /api/v1/portal/access` `{ partner }`: a portal user for the contact, and an invitation
|
|
7
|
+
* - `POST /api/v1/portal/access/:user/invitation` a new invitation (the earlier ones stop working)
|
|
8
|
+
* - `DELETE /api/v1/portal/access/:user` revoke: the user is archived and their sessions end
|
|
9
|
+
*
|
|
10
|
+
* In the portal:
|
|
11
|
+
* - `POST /api/portal/v1/activate` `{ token, password }`: the invited contact chooses a password;
|
|
12
|
+
* their login comes back
|
|
13
|
+
* - `GET /api/portal/v1/me` who is signed in, for which contact and company
|
|
14
|
+
*
|
|
15
|
+
* Invitations are single use, valid 7 days and stored as SHA-256 digests; the link goes by email when a mailer is
|
|
16
|
+
* configured, and back to the inviter either way. Every step is audited.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
19
|
+
import { layout } from '../mail/index.js';
|
|
20
|
+
import { audit, HttpProblem } from '../server/index.js';
|
|
21
|
+
const ref = (env, name) => env.model(name);
|
|
22
|
+
/** How long an invitation works. */
|
|
23
|
+
export const INVITATION_DAYS = 7;
|
|
24
|
+
/** The group of portal users. */
|
|
25
|
+
export const PORTAL_USER = 'portal.user';
|
|
26
|
+
const digest = (token) => createHash('sha256').update(token).digest('hex');
|
|
27
|
+
const idOf = (value, what) => {
|
|
28
|
+
const id = Number(value);
|
|
29
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
30
|
+
throw new HttpProblem(400, 'invalid-id', 'Invalid id', `${what} is a positive integer.`);
|
|
31
|
+
return id;
|
|
32
|
+
};
|
|
33
|
+
export function registerPortalRoutes(context, options) {
|
|
34
|
+
const { app } = context;
|
|
35
|
+
const auth = context.auth;
|
|
36
|
+
if (auth === undefined)
|
|
37
|
+
return; // no sign-in without base
|
|
38
|
+
const manager = (env) => {
|
|
39
|
+
if (!env.administrator && !env.groups.has('portal.manager'))
|
|
40
|
+
throw new HttpProblem(403, 'access', 'Portal access managers only', 'Granting portal access needs the Portal access level.');
|
|
41
|
+
};
|
|
42
|
+
const portalOnly = (env) => {
|
|
43
|
+
if (!env.groups.has(PORTAL_USER) || env.groups.has('base.user'))
|
|
44
|
+
throw new HttpProblem(403, 'portal-only', 'Portal users only', 'This is the portal’s API.');
|
|
45
|
+
};
|
|
46
|
+
/** A new invitation for `user` (earlier unused ones are expired); its link. */
|
|
47
|
+
const invite = async (su, user, by) => {
|
|
48
|
+
const pending = await ref(su, 'portal.invitation').search({
|
|
49
|
+
where: { user, usedAt: null },
|
|
50
|
+
});
|
|
51
|
+
if (pending.length > 0)
|
|
52
|
+
await pending.write({ expiresAt: new Date() });
|
|
53
|
+
const token = randomBytes(32).toString('base64url');
|
|
54
|
+
const expiresAt = new Date(Date.now() + INVITATION_DAYS * 86_400_000);
|
|
55
|
+
await ref(su, 'portal.invitation').create([
|
|
56
|
+
{ user, tokenHash: digest(token), expiresAt, invitedBy: by },
|
|
57
|
+
]);
|
|
58
|
+
const url = `${options.portalUrl.replace(/\/$/, '')}/activate?token=${token}`;
|
|
59
|
+
return { url, expiresAt };
|
|
60
|
+
};
|
|
61
|
+
/** Emails the invitation once the transaction commits (a failed send is logged; the inviter has the link). */
|
|
62
|
+
const email = (env, to, name, company, url) => {
|
|
63
|
+
const mailer = options.mailer;
|
|
64
|
+
if (mailer === undefined)
|
|
65
|
+
return false;
|
|
66
|
+
env.afterCommit(async () => {
|
|
67
|
+
const body = layout({
|
|
68
|
+
title: `Your access to ${company}`,
|
|
69
|
+
paragraphs: [
|
|
70
|
+
`Hello ${name},`,
|
|
71
|
+
`${company} invites you to its portal: your quotations, orders and invoices in one place.`,
|
|
72
|
+
`The link works for ${INVITATION_DAYS} days.`,
|
|
73
|
+
],
|
|
74
|
+
action: { label: 'Choose your password', url },
|
|
75
|
+
});
|
|
76
|
+
await mailer
|
|
77
|
+
.send({ to, subject: `Your access to ${company}`, ...body })
|
|
78
|
+
.catch((error) => app.log.error({ err: error }, 'portal invitation not sent'));
|
|
79
|
+
});
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
const companyName = async (su, company) => {
|
|
83
|
+
const [row] = (await ref(su, 'res.company').browse([company]).load(['name'])).read(['name']);
|
|
84
|
+
return String(row?.['name'] ?? '');
|
|
85
|
+
};
|
|
86
|
+
app.get('/api/v1/portal/access', async (request) => {
|
|
87
|
+
const partner = idOf(request.query?.['partner'], 'partner');
|
|
88
|
+
return request.env(async (env) => {
|
|
89
|
+
manager(env);
|
|
90
|
+
const visible = await ref(env, 'res.partner').search({ where: { id: partner }, limit: 1 });
|
|
91
|
+
if (visible.length === 0)
|
|
92
|
+
throw new HttpProblem(404, 'not-found', 'No such contact', `No contact #${partner}.`);
|
|
93
|
+
return env.asSuperuser({ reason: 'portal access' }, async (su) => {
|
|
94
|
+
const keys = ['login', 'active'];
|
|
95
|
+
// Revoked (archived) users too.
|
|
96
|
+
const users = await ref(su.withFlags({ activeTest: false }), 'res.users').search({
|
|
97
|
+
where: { portalPartner: partner },
|
|
98
|
+
});
|
|
99
|
+
const rows = (await users.load(keys)).read(keys);
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
const id = row['id'];
|
|
103
|
+
const invitations = await ref(su, 'portal.invitation').search({ where: { user: id } });
|
|
104
|
+
const sent = (await invitations.load(['usedAt', 'expiresAt'])).read([
|
|
105
|
+
'usedAt',
|
|
106
|
+
'expiresAt',
|
|
107
|
+
]);
|
|
108
|
+
const open = sent
|
|
109
|
+
.filter((i) => i['usedAt'] === null && new Date(String(i['expiresAt'])).getTime() > Date.now())
|
|
110
|
+
.map((i) => String(i['expiresAt']));
|
|
111
|
+
out.push({
|
|
112
|
+
id,
|
|
113
|
+
login: String(row['login']),
|
|
114
|
+
active: row['active'] === true,
|
|
115
|
+
activated: sent.some((i) => i['usedAt'] !== null),
|
|
116
|
+
invitationExpiresAt: open[0] ?? null,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return { users: out };
|
|
120
|
+
});
|
|
121
|
+
}, { write: true });
|
|
122
|
+
});
|
|
123
|
+
app.post('/api/v1/portal/access', async (request, reply) => {
|
|
124
|
+
const partner = idOf(request.body?.['partner'], 'partner');
|
|
125
|
+
const granted = await request.env(async (env) => {
|
|
126
|
+
manager(env);
|
|
127
|
+
const found = await ref(env, 'res.partner').search({ where: { id: partner }, limit: 1 });
|
|
128
|
+
const [contact] = (await found.load(['name', 'email'])).read(['name', 'email']);
|
|
129
|
+
if (contact === undefined)
|
|
130
|
+
throw new HttpProblem(404, 'not-found', 'No such contact', `No contact #${partner}.`);
|
|
131
|
+
const login = String(contact['email'] ?? '')
|
|
132
|
+
.trim()
|
|
133
|
+
.toLowerCase();
|
|
134
|
+
if (login === '')
|
|
135
|
+
throw new HttpProblem(422, 'no-email', 'No email address', 'The contact needs an email address: it is their login and where the invitation goes.');
|
|
136
|
+
const name = String(contact['name']);
|
|
137
|
+
return env.asSuperuser({ reason: 'portal access' }, async (su) => {
|
|
138
|
+
const all = su.withFlags({ activeTest: false });
|
|
139
|
+
const taken = await ref(all, 'res.users').search({ where: { login }, limit: 1 });
|
|
140
|
+
const [existing] = (await taken.load(['active', 'portalPartner'])).read([
|
|
141
|
+
'active',
|
|
142
|
+
'portalPartner',
|
|
143
|
+
]);
|
|
144
|
+
let user;
|
|
145
|
+
if (existing !== undefined) {
|
|
146
|
+
// This contact's revoked portal user comes back; any other user keeps the login.
|
|
147
|
+
if (existing['portalPartner'] !== partner || existing['active'] === true)
|
|
148
|
+
throw new HttpProblem(409, 'login-taken', 'Already a user', `A user with the login ${login} exists already.`, 'Send the invitation again, or use another email for this contact.');
|
|
149
|
+
user = existing['id'];
|
|
150
|
+
await taken.write({ active: true });
|
|
151
|
+
await auth.setPassword(su, user, randomBytes(32).toString('base64url'));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const created = await ref(su, 'res.users').create([
|
|
155
|
+
{
|
|
156
|
+
login,
|
|
157
|
+
name,
|
|
158
|
+
email: login,
|
|
159
|
+
company: env.companyId,
|
|
160
|
+
companies: { set: [env.companyId] },
|
|
161
|
+
portalPartner: partner,
|
|
162
|
+
},
|
|
163
|
+
]);
|
|
164
|
+
user = created.ids[0];
|
|
165
|
+
// No usable password until the invitation is used.
|
|
166
|
+
await auth.setPassword(su, user, randomBytes(32).toString('base64url'));
|
|
167
|
+
await ref(su, 'auth.user_group').create([{ user, group: PORTAL_USER }]);
|
|
168
|
+
}
|
|
169
|
+
const { url, expiresAt } = await invite(su, user, env.uid);
|
|
170
|
+
await audit(env, {
|
|
171
|
+
kind: 'portal.invited',
|
|
172
|
+
summary: `Portal access granted to ${name} (${login}).`,
|
|
173
|
+
targetModel: 'res.users',
|
|
174
|
+
targetId: user,
|
|
175
|
+
details: { partner },
|
|
176
|
+
ip: request.ip,
|
|
177
|
+
});
|
|
178
|
+
const emailed = email(env, login, name, await companyName(su, env.companyId), url);
|
|
179
|
+
return { user, login, activationUrl: url, expiresAt: expiresAt.toISOString(), emailed };
|
|
180
|
+
});
|
|
181
|
+
}, { write: true });
|
|
182
|
+
return reply.code(201).send(granted);
|
|
183
|
+
});
|
|
184
|
+
/** The portal user `id`, if it is a portal user the caller may manage (their contact is readable). */
|
|
185
|
+
const portalUser = async (env, su, id) => {
|
|
186
|
+
const keys = ['login', 'name', 'portalPartner', 'active'];
|
|
187
|
+
const [row] = (await ref(su, 'res.users').browse([id]).load(keys)).read(keys);
|
|
188
|
+
const partner = row?.['portalPartner'];
|
|
189
|
+
if (row === undefined || typeof partner !== 'number')
|
|
190
|
+
throw new HttpProblem(404, 'not-found', 'No such portal user', `No portal user #${id}.`);
|
|
191
|
+
const visible = await ref(env, 'res.partner').search({ where: { id: partner }, limit: 1 });
|
|
192
|
+
if (visible.length === 0)
|
|
193
|
+
throw new HttpProblem(404, 'not-found', 'No such portal user', `No portal user #${id}.`);
|
|
194
|
+
return {
|
|
195
|
+
login: String(row['login']),
|
|
196
|
+
name: String(row['name']),
|
|
197
|
+
active: row['active'] === true,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
app.post('/api/v1/portal/access/:user/invitation', async (request, reply) => {
|
|
201
|
+
const id = idOf(request.params['user'], 'user');
|
|
202
|
+
const sent = await request.env(async (env) => {
|
|
203
|
+
manager(env);
|
|
204
|
+
return env.asSuperuser({ reason: 'portal access' }, async (su) => {
|
|
205
|
+
const user = await portalUser(env, su, id);
|
|
206
|
+
if (!user.active)
|
|
207
|
+
throw new HttpProblem(409, 'revoked', 'Access revoked', 'Grant access again first.');
|
|
208
|
+
const { url, expiresAt } = await invite(su, id, env.uid);
|
|
209
|
+
await audit(env, {
|
|
210
|
+
kind: 'portal.invited',
|
|
211
|
+
summary: `Portal invitation sent again to ${user.name} (${user.login}).`,
|
|
212
|
+
targetModel: 'res.users',
|
|
213
|
+
targetId: id,
|
|
214
|
+
ip: request.ip,
|
|
215
|
+
});
|
|
216
|
+
const emailed = email(env, user.login, user.name, await companyName(su, env.companyId), url);
|
|
217
|
+
return { activationUrl: url, expiresAt: expiresAt.toISOString(), emailed };
|
|
218
|
+
});
|
|
219
|
+
}, { write: true });
|
|
220
|
+
return reply.code(201).send(sent);
|
|
221
|
+
});
|
|
222
|
+
app.delete('/api/v1/portal/access/:user', async (request, reply) => {
|
|
223
|
+
const id = idOf(request.params['user'], 'user');
|
|
224
|
+
await request.env(async (env) => {
|
|
225
|
+
manager(env);
|
|
226
|
+
await env.asSuperuser({ reason: 'portal access' }, async (su) => {
|
|
227
|
+
const user = await portalUser(env, su, id);
|
|
228
|
+
await ref(su, 'res.users').browse([id]).write({ active: false });
|
|
229
|
+
const sessions = await ref(su, 'auth.session').search({
|
|
230
|
+
where: { user: id, revoked: false },
|
|
231
|
+
});
|
|
232
|
+
if (sessions.length > 0)
|
|
233
|
+
await sessions.write({ revoked: true });
|
|
234
|
+
const pending = await ref(su, 'portal.invitation').search({
|
|
235
|
+
where: { user: id, usedAt: null },
|
|
236
|
+
});
|
|
237
|
+
if (pending.length > 0)
|
|
238
|
+
await pending.write({ expiresAt: new Date() });
|
|
239
|
+
await audit(env, {
|
|
240
|
+
kind: 'portal.revoked',
|
|
241
|
+
summary: `Portal access revoked for ${user.name} (${user.login}).`,
|
|
242
|
+
targetModel: 'res.users',
|
|
243
|
+
targetId: id,
|
|
244
|
+
ip: request.ip,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}, { write: true });
|
|
248
|
+
return reply.code(204).send();
|
|
249
|
+
});
|
|
250
|
+
app.post('/api/portal/v1/activate', { config: { auth: false } }, async (request, reply) => {
|
|
251
|
+
const body = (request.body ?? {});
|
|
252
|
+
const token = typeof body['token'] === 'string' ? body['token'] : '';
|
|
253
|
+
const password = typeof body['password'] === 'string' ? body['password'] : '';
|
|
254
|
+
const invalid = () => new HttpProblem(410, 'invitation-invalid', 'This link no longer works', 'The invitation was used, replaced or has expired.', 'Ask for a new invitation.');
|
|
255
|
+
if (token === '')
|
|
256
|
+
throw invalid();
|
|
257
|
+
const login = await context.shutro.bootstrap({ reason: 'portal activation' }, async (su) => {
|
|
258
|
+
const found = await ref(su, 'portal.invitation').search({
|
|
259
|
+
where: { tokenHash: digest(token) },
|
|
260
|
+
limit: 1,
|
|
261
|
+
});
|
|
262
|
+
const [row] = (await found.load(['user', 'expiresAt', 'usedAt'])).read([
|
|
263
|
+
'user',
|
|
264
|
+
'expiresAt',
|
|
265
|
+
'usedAt',
|
|
266
|
+
]);
|
|
267
|
+
if (row === undefined ||
|
|
268
|
+
row['usedAt'] !== null ||
|
|
269
|
+
new Date(String(row['expiresAt'])).getTime() <= Date.now())
|
|
270
|
+
throw invalid();
|
|
271
|
+
const user = row['user'];
|
|
272
|
+
const [account] = (await ref(su, 'res.users').browse([user]).load(['active', 'company', 'login'])).read(['active', 'company', 'login']);
|
|
273
|
+
if (account?.['active'] !== true)
|
|
274
|
+
throw invalid();
|
|
275
|
+
await auth.setPassword(su, user, password);
|
|
276
|
+
await found.write({ usedAt: new Date() });
|
|
277
|
+
await audit(su, {
|
|
278
|
+
kind: 'portal.activated',
|
|
279
|
+
summary: 'A portal user chose their password.',
|
|
280
|
+
actor: user,
|
|
281
|
+
company: account['company'] ?? null,
|
|
282
|
+
targetModel: 'res.users',
|
|
283
|
+
targetId: user,
|
|
284
|
+
ip: request.ip,
|
|
285
|
+
});
|
|
286
|
+
return String(account['login']);
|
|
287
|
+
});
|
|
288
|
+
// The login: the portal signs the user in with the password they just chose.
|
|
289
|
+
return reply.send({ login });
|
|
290
|
+
});
|
|
291
|
+
app.get('/api/portal/v1/me', async (request) => request.env(async (env) => {
|
|
292
|
+
portalOnly(env);
|
|
293
|
+
return env.asSuperuser({ reason: 'portal me' }, async (su) => {
|
|
294
|
+
const keys = ['login', 'name', 'portalPartner', 'company', 'lang'];
|
|
295
|
+
const [user] = (await ref(su, 'res.users').browse([env.uid]).load(keys)).read(keys);
|
|
296
|
+
const partnerId = user?.['portalPartner'];
|
|
297
|
+
const [partner] = typeof partnerId === 'number'
|
|
298
|
+
? (await ref(su, 'res.partner').browse([partnerId]).load(['name', 'parent'])).read([
|
|
299
|
+
'name',
|
|
300
|
+
'parent',
|
|
301
|
+
])
|
|
302
|
+
: [];
|
|
303
|
+
return {
|
|
304
|
+
user: env.uid,
|
|
305
|
+
login: String(user?.['login'] ?? ''),
|
|
306
|
+
name: String(user?.['name'] ?? ''),
|
|
307
|
+
lang: String(user?.['lang'] ?? 'en_US'),
|
|
308
|
+
partner: partner === undefined ? null : { id: partner['id'], name: String(partner['name']) },
|
|
309
|
+
company: { id: env.companyId, name: await companyName(su, env.companyId) },
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}, { write: true }));
|
|
313
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record rule that keeps a document to its customer (RFC 0015 §5.1): a portal user sees the documents of their
|
|
3
|
+
* contact and of the company their contact works for — the company's own, and those of its other people. Bridges
|
|
4
|
+
* add it to the models they show in the portal (`extendModel(…, { rules: { portal: portalRule('partner') } })`).
|
|
5
|
+
*/
|
|
6
|
+
/** Holders of `portal.user` whose contact or company is the record's `partner`. */
|
|
7
|
+
export declare function portalRule(partner: string): {
|
|
8
|
+
readonly groups: readonly string[];
|
|
9
|
+
readonly perms: readonly ['read'];
|
|
10
|
+
readonly domain: Readonly<Record<string, unknown>>;
|
|
11
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record rule that keeps a document to its customer (RFC 0015 §5.1): a portal user sees the documents of their
|
|
3
|
+
* contact and of the company their contact works for — the company's own, and those of its other people. Bridges
|
|
4
|
+
* add it to the models they show in the portal (`extendModel(…, { rules: { portal: portalRule('partner') } })`).
|
|
5
|
+
*/
|
|
6
|
+
/** Holders of `portal.user` whose contact or company is the record's `partner`. */
|
|
7
|
+
export function portalRule(partner) {
|
|
8
|
+
const me = { portalUsers: { any: { id: { $env: 'uid' } } } };
|
|
9
|
+
return {
|
|
10
|
+
groups: ['portal.user'],
|
|
11
|
+
perms: ['read'],
|
|
12
|
+
domain: {
|
|
13
|
+
or: [
|
|
14
|
+
// Their own contact, or (a company's portal user) the company itself.
|
|
15
|
+
{ [`${partner}.portalUsers`]: me.portalUsers },
|
|
16
|
+
// A person of the company whose portal user they are.
|
|
17
|
+
{ [`${partner}.parent.portalUsers`]: me.portalUsers },
|
|
18
|
+
// Their company (the contact's parent).
|
|
19
|
+
{ [`${partner}.contacts`]: { any: me } },
|
|
20
|
+
// A colleague at their company.
|
|
21
|
+
{ [`${partner}.parent.contacts`]: { any: me } },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Portal access" on a contact's form (RFC 0015 §5.2), for portal access managers: grant access (the invitation is
|
|
3
|
+
* emailed when the server can, and its link shown to share), send it again, revoke. Hidden from anyone else (the
|
|
4
|
+
* server answers 403 and the panel stays away).
|
|
5
|
+
*/
|
|
6
|
+
import { type NodeComponentProps } from '../../ui/index.js';
|
|
7
|
+
import { type ReactElement } from 'react';
|
|
8
|
+
export declare function PortalAccess({ record, api }: NodeComponentProps): ReactElement | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* "Portal access" on a contact's form (RFC 0015 §5.2), for portal access managers: grant access (the invitation is
|
|
4
|
+
* emailed when the server can, and its link shown to share), send it again, revoke. Hidden from anyone else (the
|
|
5
|
+
* server answers 403 and the panel stays away).
|
|
6
|
+
*/
|
|
7
|
+
import { Alert, Button, Input, useKosh } from '../../ui/index.js';
|
|
8
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
9
|
+
import { Copy, KeyRound, Send, UserX } from 'lucide-react';
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import { useTranslation } from 'react-i18next';
|
|
12
|
+
export function PortalAccess({ record, api }) {
|
|
13
|
+
const { t } = useTranslation('portal');
|
|
14
|
+
const { format } = useKosh();
|
|
15
|
+
const partner = typeof record?.['id'] === 'number' ? record['id'] : null;
|
|
16
|
+
const [link, setLink] = useState(null);
|
|
17
|
+
const [copied, setCopied] = useState(false);
|
|
18
|
+
const access = useQuery({
|
|
19
|
+
queryKey: ['portal', 'access', partner],
|
|
20
|
+
queryFn: () => api.call('GET', `/api/v1/portal/access?partner=${partner}`),
|
|
21
|
+
enabled: partner !== null,
|
|
22
|
+
retry: false,
|
|
23
|
+
});
|
|
24
|
+
const done = (invitation) => {
|
|
25
|
+
if (invitation !== undefined)
|
|
26
|
+
setLink(invitation);
|
|
27
|
+
setCopied(false);
|
|
28
|
+
void access.refetch();
|
|
29
|
+
};
|
|
30
|
+
const grant = useMutation({
|
|
31
|
+
mutationFn: () => api.call('POST', '/api/v1/portal/access', { partner }),
|
|
32
|
+
onSuccess: (invitation) => done(invitation),
|
|
33
|
+
});
|
|
34
|
+
const resend = useMutation({
|
|
35
|
+
mutationFn: (user) => api.call('POST', `/api/v1/portal/access/${user}/invitation`, {}),
|
|
36
|
+
onSuccess: (invitation) => done(invitation),
|
|
37
|
+
});
|
|
38
|
+
const revoke = useMutation({
|
|
39
|
+
mutationFn: (user) => api.call('DELETE', `/api/v1/portal/access/${user}`),
|
|
40
|
+
onSuccess: () => {
|
|
41
|
+
setLink(null);
|
|
42
|
+
done();
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (partner === null || access.data === undefined)
|
|
46
|
+
return null;
|
|
47
|
+
const users = access.data.users;
|
|
48
|
+
const current = users.find((u) => u.active);
|
|
49
|
+
const former = users[0];
|
|
50
|
+
const error = grant.error ?? resend.error ?? revoke.error;
|
|
51
|
+
let status;
|
|
52
|
+
if (current === undefined)
|
|
53
|
+
status = former === undefined ? t('none') : t('revoked', { login: former.login });
|
|
54
|
+
else if (current.activated)
|
|
55
|
+
status = t('active', { login: current.login });
|
|
56
|
+
else if (current.invitationExpiresAt === null)
|
|
57
|
+
status = t('expired', { login: current.login });
|
|
58
|
+
else
|
|
59
|
+
status = t('pending', {
|
|
60
|
+
login: current.login,
|
|
61
|
+
until: format.datetime(current.invitationExpiresAt),
|
|
62
|
+
});
|
|
63
|
+
return (_jsxs("section", { className: "grid gap-2 rounded-md border p-3", "aria-labelledby": "portal-access-title", children: [_jsx("h3", { id: "portal-access-title", className: "text-sm font-semibold", children: t('title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: status }), _jsx("div", { className: "flex flex-wrap gap-2", children: current === undefined ? (_jsxs(Button, { size: "sm", variant: "secondary", busy: grant.isPending, onClick: () => grant.mutate(), children: [_jsx(KeyRound, { "aria-hidden": true }), t('grant')] })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", busy: resend.isPending, onClick: () => resend.mutate(current.id), children: [_jsx(Send, { "aria-hidden": true }), t('resend')] }), _jsxs(Button, { size: "sm", variant: "danger", busy: revoke.isPending, onClick: () => revoke.mutate(current.id), children: [_jsx(UserX, { "aria-hidden": true }), t('revoke')] })] })) }), link === null ? null : (_jsxs("div", { className: "grid gap-1", children: [_jsx("p", { className: "text-sm", children: link.emailed ? t('emailed') : t('share') }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { readOnly: true, value: link.activationUrl, "aria-label": t('copy') }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": t('copy'), onClick: () => {
|
|
64
|
+
void navigator.clipboard?.writeText(link.activationUrl).then(() => setCopied(true));
|
|
65
|
+
}, children: _jsx(Copy, { "aria-hidden": true }) })] }), copied ? (_jsx("p", { role: "status", className: "text-xs text-muted-foreground", children: t('copied') })) : null] })), error === null ? null : _jsx(Alert, { children: error.message })] }));
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* The back-office contribution of `shutro-erp/portal` (RFC 0015): "Portal access" on the contact form
|
|
4
|
+
* (`portal.Access`) and its messages.
|
|
5
|
+
*/
|
|
6
|
+
import { type Registry } from '../../ui/index.js';
|
|
7
|
+
import type { i18n } from 'i18next';
|
|
8
|
+
import { messages } from './messages.js';
|
|
9
|
+
export { PortalAccess } from './PortalAccess.js';
|
|
10
|
+
/** Registers "Portal access" (and the messages in `i18n`, when given). */
|
|
11
|
+
export declare function registerPortalWeb(registry: Registry, i18n?: i18n): Registry;
|
|
12
|
+
export { messages as portalMessages };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* The back-office contribution of `shutro-erp/portal` (RFC 0015): "Portal access" on the contact form
|
|
4
|
+
* (`portal.Access`) and its messages.
|
|
5
|
+
*/
|
|
6
|
+
import { addMessages } from '../../ui/index.js';
|
|
7
|
+
import { messages } from './messages.js';
|
|
8
|
+
import { PortalAccess } from './PortalAccess.js';
|
|
9
|
+
export { PortalAccess } from './PortalAccess.js';
|
|
10
|
+
/** Registers "Portal access" (and the messages in `i18n`, when given). */
|
|
11
|
+
export function registerPortalWeb(registry, i18n) {
|
|
12
|
+
registry.registerComponent('portal.Access', PortalAccess);
|
|
13
|
+
if (i18n !== undefined)
|
|
14
|
+
addMessages(i18n, 'portal', messages);
|
|
15
|
+
return registry;
|
|
16
|
+
}
|
|
17
|
+
export { messages as portalMessages };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Messages of the portal's back-office parts (namespace `portal`). */
|
|
2
|
+
export const messages = {
|
|
3
|
+
en: {
|
|
4
|
+
title: 'Portal access',
|
|
5
|
+
none: 'This contact has no portal access.',
|
|
6
|
+
grant: 'Grant portal access',
|
|
7
|
+
resend: 'Send the invitation again',
|
|
8
|
+
revoke: 'Revoke access',
|
|
9
|
+
active: 'Active: {{login}}',
|
|
10
|
+
pending: 'Invited: {{login}} has not chosen a password yet (the link works until {{until}}).',
|
|
11
|
+
expired: 'Invited: {{login}}; the link has expired.',
|
|
12
|
+
revoked: 'Access revoked: {{login}}',
|
|
13
|
+
emailed: 'The invitation was emailed. You can also share this link:',
|
|
14
|
+
share: 'Email is not set up on this server: share this link with the contact:',
|
|
15
|
+
copy: 'Copy the link',
|
|
16
|
+
copied: 'Copied.',
|
|
17
|
+
},
|
|
18
|
+
bn: {
|
|
19
|
+
title: 'পোর্টাল অ্যাক্সেস',
|
|
20
|
+
none: 'এই যোগাযোগের কোনো পোর্টাল অ্যাক্সেস নেই।',
|
|
21
|
+
grant: 'পোর্টাল অ্যাক্সেস দিন',
|
|
22
|
+
resend: 'আবার আমন্ত্রণ পাঠান',
|
|
23
|
+
revoke: 'অ্যাক্সেস প্রত্যাহার করুন',
|
|
24
|
+
active: 'সক্রিয়: {{login}}',
|
|
25
|
+
pending: 'আমন্ত্রিত: {{login}} এখনও পাসওয়ার্ড বেছে নেননি ({{until}} পর্যন্ত লিংকটি কাজ করবে)।',
|
|
26
|
+
expired: 'আমন্ত্রিত: {{login}}; লিংকের মেয়াদ শেষ।',
|
|
27
|
+
revoked: 'অ্যাক্সেস প্রত্যাহার করা হয়েছে: {{login}}',
|
|
28
|
+
emailed: 'আমন্ত্রণ ইমেইল করা হয়েছে। এই লিংকটিও শেয়ার করতে পারেন:',
|
|
29
|
+
share: 'এই সার্ভারে ইমেইল চালু নেই: লিংকটি যোগাযোগের সাথে শেয়ার করুন:',
|
|
30
|
+
copy: 'লিংক কপি করুন',
|
|
31
|
+
copied: 'কপি হয়েছে।',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash:
|
|
1
|
+
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: 5d099798463eb84a
|
|
2
2
|
// Bindings of module `product`: import the definition API from here to get types scoped to what it can see.
|
|
3
3
|
export { defineModel, defineModule, extendModel, f, replace } from '../../server/orm.js';
|
|
4
4
|
import { definitions } from '../../server/orm.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash:
|
|
1
|
+
// GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: 5d099798463eb84a
|
|
2
2
|
// Schema view of module `product` (RFC 0002 §7.7): the models it owns or depends on, with the extensions it can see.
|
|
3
3
|
import type { F } from '../../server/orm.js';
|
|
4
4
|
|
|
@@ -32,7 +32,13 @@ export interface AccountTax {
|
|
|
32
32
|
readonly active: F.Bool<{ readonly required: true; readonly default: true }>;
|
|
33
33
|
readonly name: F.Char<{ readonly required: true; readonly translate: true }>;
|
|
34
34
|
readonly use: F.Selection<'sale' | 'purchase', { readonly required: true; readonly default: true }>;
|
|
35
|
+
readonly sequence: F.Integer<{ readonly required: true; readonly default: true }>;
|
|
36
|
+
readonly amountType: F.Selection<'percent' | 'fixed', { readonly required: true; readonly default: true }>;
|
|
35
37
|
readonly rate: F.Decimal<{ readonly required: true; readonly default: true }>;
|
|
38
|
+
readonly fixedAmount: F.Decimal<{ readonly required: true; readonly default: true }>;
|
|
39
|
+
readonly affectsBase: F.Bool<{ readonly required: true; readonly default: true }>;
|
|
40
|
+
readonly priceIncluded: F.Bool<{ readonly required: true; readonly default: true }>;
|
|
41
|
+
readonly reverseCharge: F.Bool<{ readonly required: true; readonly default: true }>;
|
|
36
42
|
};
|
|
37
43
|
readonly methods: AccountTaxMethods;
|
|
38
44
|
}
|
|
@@ -227,8 +233,10 @@ export interface ProductProduct {
|
|
|
227
233
|
readonly standardPrice: F.Decimal<{ readonly required: true; readonly default: true }>;
|
|
228
234
|
readonly saleOk: F.Bool<{ readonly required: true; readonly default: true }>;
|
|
229
235
|
readonly purchaseOk: F.Bool<{ readonly required: true; readonly default: true }>;
|
|
230
|
-
readonly
|
|
231
|
-
readonly
|
|
236
|
+
readonly taxes: F.Many2many<'account.tax', { readonly required: true }>;
|
|
237
|
+
readonly purchaseTaxes: F.Many2many<'account.tax', { readonly required: true }>;
|
|
238
|
+
readonly tax: F.Many2one<'account.tax', { readonly readonly: true }>;
|
|
239
|
+
readonly purchaseTax: F.Many2one<'account.tax', { readonly readonly: true }>;
|
|
232
240
|
readonly description: F.Text<{ readonly translate: true }>;
|
|
233
241
|
};
|
|
234
242
|
readonly methods: ProductProductMethods;
|
|
@@ -250,6 +258,7 @@ export interface ResCompany {
|
|
|
250
258
|
readonly displayName: F.Char<{ readonly required: true; readonly readonly: true; readonly compute: true }>;
|
|
251
259
|
readonly name: F.Char<{ readonly required: true }>;
|
|
252
260
|
readonly currency: F.Many2one<'res.currency', { readonly required: true }>;
|
|
261
|
+
readonly numberGrouping: F.Selection<'international' | 'lakh', { readonly required: true; readonly default: true }>;
|
|
253
262
|
};
|
|
254
263
|
readonly methods: ResCompanyMethods;
|
|
255
264
|
}
|
package/dist/product/demo.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Demo data of Products (RFC 0008 §5.8): construction goods, electronics and services, with Bangladesh VAT. */
|
|
2
|
-
import { idByName } from '../modules-sdk/index.js';
|
|
2
|
+
import { idByName, maybeIdByName } from '../modules-sdk/index.js';
|
|
3
3
|
import { orm } from '../server/orm.js';
|
|
4
4
|
const DINA = { login: 'dina', name: 'Dina Rahman', groups: ['product.manager'] };
|
|
5
5
|
const CATEGORIES = ['Construction', 'Electronics', 'Services'];
|
|
@@ -54,8 +54,9 @@ export async function productDemo(context) {
|
|
|
54
54
|
.model('product.category')
|
|
55
55
|
.create(CATEGORIES.map((name) => ({ name })));
|
|
56
56
|
const category = new Map(CATEGORIES.map((name, i) => [name, categories.ids[i]]));
|
|
57
|
-
|
|
58
|
-
const
|
|
57
|
+
// The VAT comes with the country's localization (l10n_bd); without one, products have no taxes.
|
|
58
|
+
const vatSale = await maybeIdByName(env, 'account.tax', 'VAT 15%');
|
|
59
|
+
const vatPurchase = await maybeIdByName(env, 'account.tax', 'VAT 15% (purchases)');
|
|
59
60
|
const uoms = new Map();
|
|
60
61
|
for (const name of new Set(PRODUCTS.map((p) => p.uom)))
|
|
61
62
|
uoms.set(name, await idByName(env, 'uom.uom', name));
|
|
@@ -66,8 +67,8 @@ export async function productDemo(context) {
|
|
|
66
67
|
uom: orm.asId(uoms.get(p.uom), 'uom.uom'),
|
|
67
68
|
listPrice: p.list,
|
|
68
69
|
standardPrice: p.cost,
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
taxes: { set: vatSale === null ? [] : [orm.asId(vatSale, 'account.tax')] },
|
|
71
|
+
purchaseTaxes: { set: vatPurchase === null ? [] : [orm.asId(vatPurchase, 'account.tax')] },
|
|
71
72
|
})));
|
|
72
73
|
return `${CATEGORIES.length} categories and ${PRODUCTS.length} products`;
|
|
73
74
|
});
|
package/dist/product/index.d.ts
CHANGED
|
@@ -5,4 +5,5 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { defineModule } from '../modules-sdk/index.js';
|
|
7
7
|
export { AccountTax, ProductCategory, ProductProduct, productShutro, UomUom } from './module.js';
|
|
8
|
+
export { computeTaxes, ordered, type TaxLine, type TaxResult, type TaxRule, taxRule, } from './taxes.js';
|
|
8
9
|
export declare const product: ReturnType<typeof defineModule>;
|
package/dist/product/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { action, form, kanban, list, search } from '../views/index.js';
|
|
|
9
9
|
import { productDemo } from './demo.js';
|
|
10
10
|
import { AccountTax, ProductCategory, ProductProduct, productShutro, UomUom } from './module.js';
|
|
11
11
|
export { AccountTax, ProductCategory, ProductProduct, productShutro, UomUom } from './module.js';
|
|
12
|
+
export { computeTaxes, ordered, taxRule, } from './taxes.js';
|
|
12
13
|
export const product = defineModule({
|
|
13
14
|
shutro: productShutro,
|
|
14
15
|
kosh: '^0.1.0',
|
|
@@ -82,8 +83,8 @@ export const product = defineModule({
|
|
|
82
83
|
k.group('prices', ['listPrice', 'standardPrice', 'uom'], { label: 'Prices' }),
|
|
83
84
|
k.notebook('more', [
|
|
84
85
|
k.page('sales', 'Sales & purchase', [
|
|
85
|
-
k.group('sell', ['saleOk', '
|
|
86
|
-
k.group('buy', ['purchaseOk', '
|
|
86
|
+
k.group('sell', ['saleOk', 'taxes']),
|
|
87
|
+
k.group('buy', ['purchaseOk', 'purchaseTaxes']),
|
|
87
88
|
]),
|
|
88
89
|
k.page('description_page', 'Description', ['description']),
|
|
89
90
|
]),
|
|
@@ -97,9 +98,21 @@ export const product = defineModule({
|
|
|
97
98
|
'uom.uom.form': form(UomUom, (k) => ({
|
|
98
99
|
sheet: [k.group('main', ['name', 'category', 'ratio', 'rounding'])],
|
|
99
100
|
})),
|
|
100
|
-
'account.tax.list': list(AccountTax, {
|
|
101
|
+
'account.tax.list': list(AccountTax, {
|
|
102
|
+
columns: ['sequence', 'name', 'use', 'amountType', 'rate', 'affectsBase'],
|
|
103
|
+
}),
|
|
101
104
|
'account.tax.form': form(AccountTax, (k) => ({
|
|
102
|
-
sheet: [
|
|
105
|
+
sheet: [
|
|
106
|
+
k.group('main', ['name', 'use', 'sequence']),
|
|
107
|
+
k.group('amount', [
|
|
108
|
+
'amountType',
|
|
109
|
+
'rate',
|
|
110
|
+
'fixedAmount',
|
|
111
|
+
'affectsBase',
|
|
112
|
+
'priceIncluded',
|
|
113
|
+
'reverseCharge',
|
|
114
|
+
]),
|
|
115
|
+
],
|
|
103
116
|
})),
|
|
104
117
|
},
|
|
105
118
|
search: {
|