@marcfargas/odoo-skills 0.3.0 → 0.4.1
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/.well-known/skills/index.json +12 -0
- package/README.md +6 -2
- package/package.json +2 -1
- package/skills/SKILL.md +3 -1
- package/skills/modules/accounting.md +322 -0
- package/skills/modules/contracts.md +180 -0
- package/skills/oca/mis-builder.md +61 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://pi-skills.dev/schema/v1/index.json",
|
|
3
|
+
"name": "@marcfargas/odoo-skills",
|
|
4
|
+
"description": "Battle-tested Odoo knowledge modules for AI agents — 5,200+ lines validated against Odoo v17 in CI",
|
|
5
|
+
"skills": [
|
|
6
|
+
{
|
|
7
|
+
"name": "odoo",
|
|
8
|
+
"path": "skills/SKILL.md",
|
|
9
|
+
"description": "Odoo ERP integration - connect, introspect, and automate your Odoo instance"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ npx @marcfargas/create-odoo-skills my-odoo-skills
|
|
|
33
33
|
| [introspection](./skills/base/introspection.md) | Discover models and fields dynamically |
|
|
34
34
|
| [properties](./skills/base/properties.md) | Dynamic user-defined fields |
|
|
35
35
|
| [modules](./skills/base/modules.md) | Module lifecycle management |
|
|
36
|
+
| [skill-generation](./skills/base/skill-generation.md) | How to create new skills |
|
|
36
37
|
|
|
37
38
|
### Mail System
|
|
38
39
|
|
|
@@ -46,9 +47,12 @@ npx @marcfargas/create-odoo-skills my-odoo-skills
|
|
|
46
47
|
|
|
47
48
|
| Module | Required Odoo Modules | What it teaches |
|
|
48
49
|
|--------|----------------------|-----------------|
|
|
50
|
+
| [accounting](./skills/modules/accounting.md) | `account` | Accounting patterns, cash discovery, reconciliation, PnL, validation |
|
|
51
|
+
| [attendance](./skills/modules/attendance.md) | `hr_attendance` | Clock in/out, presence tracking |
|
|
52
|
+
| [contracts](./skills/modules/contracts.md) | `contract` (OCA) | Recurring contracts, billing schedules, revenue projection |
|
|
49
53
|
| [timesheets](./skills/modules/timesheets.md) | `hr_timesheet` | Time tracking on projects |
|
|
50
|
-
| [
|
|
51
|
-
| [mis-builder](./skills/oca/mis-builder.md) | `mis_builder` | OCA financial reports |
|
|
54
|
+
| [mis-builder](./skills/oca/mis-builder.md) | `mis_builder` | OCA financial reports (reading, computing, exporting) |
|
|
55
|
+
| [mis-builder-dev](./skills/oca/mis-builder-dev.md) | `mis_builder` | OCA financial reports (creating, editing, expression language) |
|
|
52
56
|
|
|
53
57
|
## Prerequisites
|
|
54
58
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcfargas/odoo-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Battle-tested Odoo knowledge modules for AI agents — 5,200+ lines validated against Odoo v17 in CI",
|
|
5
5
|
"files": [
|
|
6
6
|
"skills",
|
|
7
|
+
".well-known",
|
|
7
8
|
"LICENSE",
|
|
8
9
|
"README.md"
|
|
9
10
|
],
|
package/skills/SKILL.md
CHANGED
|
@@ -36,6 +36,7 @@ Domain-specific helpers are accessed via lazy getters on the client:
|
|
|
36
36
|
|----------|-------------|-----------|
|
|
37
37
|
| `client.mail.*` | Post notes & messages on chatter | `mail/chatter.md` |
|
|
38
38
|
| `client.modules.*` | Install, uninstall, check modules | `base/modules.md` |
|
|
39
|
+
| `client.accounting.*` | Cash discovery, reconciliation, partner resolution | `modules/accounting.md` |
|
|
39
40
|
| `client.attendance.*` | Clock in/out, presence tracking | `modules/attendance.md` |
|
|
40
41
|
| `client.timesheets.*` | Timer start/stop, time logging | `modules/timesheets.md` |
|
|
41
42
|
|
|
@@ -90,7 +91,8 @@ Load by reading the path shown below:
|
|
|
90
91
|
|
|
91
92
|
| Skill | Path | Required Modules | Description |
|
|
92
93
|
|-------|------|------------------|-------------|
|
|
93
|
-
| accounting | `modules/accounting.md` | `account` | Accounting patterns, cashflow, reconciliation,
|
|
94
|
+
| accounting | `modules/accounting.md` | `account` | Accounting patterns, cashflow, reconciliation, PnL, validation |
|
|
95
|
+
| contracts | `modules/contracts.md` | `contract` (OCA) | Recurring contracts, billing schedules, revenue projection |
|
|
94
96
|
| attendance | `modules/attendance.md` | `hr_attendance` | Clock in/out, presence tracking (`client.attendance.*`) |
|
|
95
97
|
| timesheets | `modules/timesheets.md` | `hr_timesheet` | Timer start/stop, time logging on projects (`client.timesheets.*`) |
|
|
96
98
|
| mis-builder | `oca/mis-builder.md` | `mis_builder`, `date_range`, `report_xlsx` | MIS Builder — reading, computing, exporting reports |
|
|
@@ -4,6 +4,8 @@ Hard-won knowledge from building accounting dashboards and cashflow tools agains
|
|
|
4
4
|
|
|
5
5
|
**Required modules**: `account` (Invoicing/Accounting)
|
|
6
6
|
|
|
7
|
+
**Optional modules**: `account_loan` (loan management), `contract` (recurring contracts — see `contracts.md`)
|
|
8
|
+
|
|
7
9
|
## Critical API Gotchas
|
|
8
10
|
|
|
9
11
|
### searchRead Default Limit is 100
|
|
@@ -68,6 +70,86 @@ const lines = await client.searchRead('account.loan.line',
|
|
|
68
70
|
);
|
|
69
71
|
```
|
|
70
72
|
|
|
73
|
+
## Account Move Fundamentals
|
|
74
|
+
|
|
75
|
+
### Move Types
|
|
76
|
+
|
|
77
|
+
`account.move` uses `move_type` to distinguish document types:
|
|
78
|
+
|
|
79
|
+
| `move_type` | Description |
|
|
80
|
+
|-------------|-------------|
|
|
81
|
+
| `out_invoice` | Customer invoice |
|
|
82
|
+
| `out_refund` | Customer credit note |
|
|
83
|
+
| `in_invoice` | Supplier invoice |
|
|
84
|
+
| `in_refund` | Supplier credit note |
|
|
85
|
+
| `entry` | General journal entry (payroll, depreciation, closing, adjustments, bank statements) |
|
|
86
|
+
|
|
87
|
+
### Always Filter Posted State
|
|
88
|
+
|
|
89
|
+
Draft and cancelled entries must be excluded from all financial analysis. **This is the single most common mistake.**
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// ✅ On account.move — filter by state
|
|
93
|
+
const moves = await client.searchRead('account.move',
|
|
94
|
+
[['state', '=', 'posted'], ['date', '>=', '2025-01-01']],
|
|
95
|
+
{ fields: ['name', 'date', 'amount_total'], limit: 0 }
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// ✅ On account.move.line — filter by parent_state
|
|
99
|
+
const lines = await client.searchRead('account.move.line',
|
|
100
|
+
[['parent_state', '=', 'posted'], ['date', '>=', '2025-01-01']],
|
|
101
|
+
{ fields: ['account_id', 'debit', 'credit', 'balance'], limit: 0 }
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Sign Convention for PnL Analysis
|
|
106
|
+
|
|
107
|
+
`balance = debit - credit` uses natural debit sign. For PnL analysis this is counterintuitive because income accounts (7xx, credit balances) appear negative.
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// ✅ Invert sign for PnL: income → positive, expenses → negative
|
|
111
|
+
// Then SUM naturally gives EBITDA / operating result
|
|
112
|
+
const pnlLines = lines.map(l => ({
|
|
113
|
+
...l,
|
|
114
|
+
pnl_amount: -(l.debit - l.credit), // or equivalently: l.credit - l.debit
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
// SUM(pnl_amount) where account 7xx → positive (income)
|
|
118
|
+
// SUM(pnl_amount) where account 6xx → negative (expenses)
|
|
119
|
+
// Total SUM → operating result
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### amount_untaxed vs balance Aggregation
|
|
123
|
+
|
|
124
|
+
Two valid approaches — pick one, never mix:
|
|
125
|
+
|
|
126
|
+
| Approach | Source | Use for |
|
|
127
|
+
|----------|--------|---------|
|
|
128
|
+
| `account.move.amount_untaxed` | Invoice header | Invoice-level analysis, quick totals |
|
|
129
|
+
| `SUM(account.move.line.balance)` on 6xx/7xx | Journal items | Per-line, per-partner, per-account granularity |
|
|
130
|
+
|
|
131
|
+
### Detecting Year-End Closing Entries
|
|
132
|
+
|
|
133
|
+
`move_type='entry'` includes operational entries (payroll, depreciation) AND year-end closing entries. Closing entries distort operational PnL.
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// Detect closing entries: any sibling line on account 129x (resultado del ejercicio)
|
|
137
|
+
async function isClosingEntry(client: OdooClient, moveId: number): Promise<boolean> {
|
|
138
|
+
const lines = await client.searchRead('account.move.line',
|
|
139
|
+
[['move_id', '=', moveId]],
|
|
140
|
+
{ fields: ['account_id'], limit: 0 }
|
|
141
|
+
);
|
|
142
|
+
return lines.some(l => {
|
|
143
|
+
const code = Array.isArray(l.account_id) ? l.account_id[1] : String(l.account_id);
|
|
144
|
+
return /^129/.test(code);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// When querying PnL, exclude closing entries
|
|
149
|
+
// Option 1: Pre-filter move IDs
|
|
150
|
+
// Option 2: Exclude account 129x lines and check remaining moves
|
|
151
|
+
```
|
|
152
|
+
|
|
71
153
|
## Cash Account Discovery
|
|
72
154
|
|
|
73
155
|
### Use Journals, Not Account Code Prefixes
|
|
@@ -86,6 +168,37 @@ const cashAccountIds = journals
|
|
|
86
168
|
.filter(Boolean);
|
|
87
169
|
```
|
|
88
170
|
|
|
171
|
+
### Exclude Transit and Third-Party Accounts
|
|
172
|
+
|
|
173
|
+
Not everything in bank journals is your money:
|
|
174
|
+
|
|
175
|
+
| Account | Description | Include in cash? |
|
|
176
|
+
|---------|-------------|------------------|
|
|
177
|
+
| 57x | Bank/cash accounts | ✅ Yes |
|
|
178
|
+
| 5201x | Credit line drawdowns | ✅ Yes — drawn credit is liquid |
|
|
179
|
+
| 5200x | Short-term loan reclassifications | ❌ No — accounting reclassification, not cash |
|
|
180
|
+
| 555 | Transient / pending application | ❌ No — not the company's money |
|
|
181
|
+
| 561 | Client provisions (suplidos) | ❌ No — third-party funds held temporarily |
|
|
182
|
+
|
|
183
|
+
### Cash Balance from GL, Not from Movements
|
|
184
|
+
|
|
185
|
+
Summing cash movements within a date range misses prior history and opening balances. True cash balance = cumulative GL balance:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// ✅ CORRECT: Cash balance at a point in time from GL
|
|
189
|
+
const cashBalance = await client.searchRead('account.move.line',
|
|
190
|
+
[
|
|
191
|
+
['account_id', 'in', cashAccountIds],
|
|
192
|
+
['parent_state', '=', 'posted'],
|
|
193
|
+
['date', '<=', '2025-06-30'],
|
|
194
|
+
],
|
|
195
|
+
{ fields: ['balance'], limit: 0 }
|
|
196
|
+
);
|
|
197
|
+
const totalCash = cashBalance.reduce((sum, l) => sum + l.balance, 0);
|
|
198
|
+
|
|
199
|
+
// ❌ WRONG: Only summing movements in a period misses opening balance
|
|
200
|
+
```
|
|
201
|
+
|
|
89
202
|
## Bank Statement Patterns
|
|
90
203
|
|
|
91
204
|
### Partner Is on the Counterpart Line, Not the Bank Line
|
|
@@ -160,6 +273,47 @@ if (reconcileId) {
|
|
|
160
273
|
}
|
|
161
274
|
```
|
|
162
275
|
|
|
276
|
+
### Days-to-Pay Calculation
|
|
277
|
+
|
|
278
|
+
Use `full_reconcile_id` to calculate how long an invoice takes to get paid:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
async function calculateDaysToPay(
|
|
282
|
+
client: OdooClient, invoiceId: number
|
|
283
|
+
): Promise<number | null> {
|
|
284
|
+
// Get receivable/payable lines from the invoice
|
|
285
|
+
const invoiceLines = await client.searchRead('account.move.line',
|
|
286
|
+
[
|
|
287
|
+
['move_id', '=', invoiceId],
|
|
288
|
+
['full_reconcile_id', '!=', false],
|
|
289
|
+
['account_id.account_type', 'in', ['asset_receivable', 'liability_payable']],
|
|
290
|
+
],
|
|
291
|
+
{ fields: ['full_reconcile_id', 'date'], limit: 0 }
|
|
292
|
+
);
|
|
293
|
+
if (!invoiceLines.length) return null;
|
|
294
|
+
|
|
295
|
+
const reconcileId = Array.isArray(invoiceLines[0].full_reconcile_id)
|
|
296
|
+
? invoiceLines[0].full_reconcile_id[0]
|
|
297
|
+
: invoiceLines[0].full_reconcile_id;
|
|
298
|
+
const invoiceDate = invoiceLines[0].date;
|
|
299
|
+
|
|
300
|
+
// Find all lines in the reconciliation group — the latest date is the payment
|
|
301
|
+
const allReconLines = await client.searchRead('account.move.line',
|
|
302
|
+
[['full_reconcile_id', '=', reconcileId]],
|
|
303
|
+
{ fields: ['date'], limit: 0 }
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const paymentDate = allReconLines.reduce((max, l) =>
|
|
307
|
+
l.date > max ? l.date : max, allReconLines[0].date
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const msPerDay = 86_400_000;
|
|
311
|
+
return Math.round(
|
|
312
|
+
(new Date(paymentDate).getTime() - new Date(invoiceDate).getTime()) / msPerDay
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
163
317
|
## Loan Accounting
|
|
164
318
|
|
|
165
319
|
### Loan Moves Don't Touch Cash
|
|
@@ -169,6 +323,42 @@ if (reconcileId) {
|
|
|
169
323
|
- Use `account.loan` for: outstanding balances, payment schedules, projections
|
|
170
324
|
- Do NOT use for: cashflow classification (the actual cash movement is a separate bank entry)
|
|
171
325
|
|
|
326
|
+
### Bank Loans vs Tax Deferrals
|
|
327
|
+
|
|
328
|
+
The `account.loan` module handles both bank loans and tax payment deferrals. Distinguish by the short-term account:
|
|
329
|
+
|
|
330
|
+
| `short_term_loan_account_id` | Type | Cashflow Category |
|
|
331
|
+
|-------------------------------|------|-------------------|
|
|
332
|
+
| 475x (Hacienda Pública) | Tax deferral (APLAZ) | Operating — tax payments |
|
|
333
|
+
| 520x / 170x (Deudas) | Bank loan | Financing — debt repayment |
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
const loans = await client.searchRead('account.loan',
|
|
337
|
+
[],
|
|
338
|
+
{ fields: ['name', 'short_term_loan_account_id', 'long_term_loan_account_id'], limit: 0 }
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
for (const loan of loans) {
|
|
342
|
+
const stAccount = Array.isArray(loan.short_term_loan_account_id)
|
|
343
|
+
? loan.short_term_loan_account_id[1] : '';
|
|
344
|
+
const isBank = /^(520|170)/.test(stAccount);
|
|
345
|
+
const isTaxDeferral = /^475/.test(stAccount);
|
|
346
|
+
console.log(`${loan.name}: ${isBank ? 'BANK LOAN' : isTaxDeferral ? 'TAX DEFERRAL' : 'OTHER'}`);
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Loan Lines Link to Accounting Entries
|
|
351
|
+
|
|
352
|
+
Each `account.loan.line` has a `move_ids` field linking to the journal entries it generated. Use this to tag bank movements as loan repayments:
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
const loanLines = await client.searchRead('account.loan.line',
|
|
356
|
+
[['loan_id', '=', loanId], ['move_ids', '!=', false]],
|
|
357
|
+
{ fields: ['date', 'payment_amount', 'move_ids'], limit: 0 }
|
|
358
|
+
);
|
|
359
|
+
// move_ids links to account.move records — these are the loan accounting entries
|
|
360
|
+
```
|
|
361
|
+
|
|
172
362
|
### Loan Repayments Go Through Payables
|
|
173
363
|
|
|
174
364
|
Loan payments typically flow: 520/170 → 410/411 → bank. Look for patterns in the move reference:
|
|
@@ -191,8 +381,140 @@ const isLoanRepayment = loanPattern.test(line.name || '') || loanPattern.test(li
|
|
|
191
381
|
| 555 | Transient/pending | Not the company's money |
|
|
192
382
|
| 57x | Bank/cash | Discover via journals, not code prefix |
|
|
193
383
|
|
|
384
|
+
## PnL & Cash Flow Structure (Spanish GAAP / PGC)
|
|
385
|
+
|
|
386
|
+
### Account Code Ranges
|
|
387
|
+
|
|
388
|
+
Spanish Plan General Contable maps 3-digit prefixes to PnL categories:
|
|
389
|
+
|
|
390
|
+
| Range | Category | Examples |
|
|
391
|
+
|-------|----------|----------|
|
|
392
|
+
| 600-609 | Purchases / COGS | 600 Compras mercaderías, 607 Subcontratas |
|
|
393
|
+
| 620-629 | External services | 621 Arrendamientos, 623 Servicios profesionales |
|
|
394
|
+
| 630-639 | Taxes | 631 Otros tributos |
|
|
395
|
+
| 640-649 | Personnel costs | 640 Sueldos y salarios, 642 SS empresa |
|
|
396
|
+
| 650-659 | Other operating expenses | |
|
|
397
|
+
| 660-669 | Financial expenses | 662 Intereses deudas, 663 Pérdidas valores |
|
|
398
|
+
| 680-689 | Depreciation / amortization | 681 Amortización inmovilizado |
|
|
399
|
+
| 690-699 | Provisions | |
|
|
400
|
+
| 700-709 | Sales | 700 Ventas, 705 Prestaciones servicios |
|
|
401
|
+
| 740-749 | Subsidies / grants | |
|
|
402
|
+
| 760-769 | Financial income | 763 Beneficios valores |
|
|
403
|
+
| 790-797 | Provision reversals | |
|
|
404
|
+
|
|
405
|
+
### Non-Cash Items
|
|
406
|
+
|
|
407
|
+
These accounts represent non-cash movements — exclude from cashflow analysis:
|
|
408
|
+
|
|
409
|
+
- **680-689**: Depreciation / amortization
|
|
410
|
+
- **690-699**: Provisions (dotaciones)
|
|
411
|
+
- **790-797**: Provision reversals
|
|
412
|
+
- **663/763**: Fair value gains/losses on financial instruments
|
|
413
|
+
|
|
414
|
+
### Cash Flow Statement (EFE) Sections
|
|
415
|
+
|
|
416
|
+
Per PGC, the Estado de Flujos de Efectivo has three sections:
|
|
417
|
+
|
|
418
|
+
| Section | Description | Typical Accounts |
|
|
419
|
+
|---------|-------------|------------------|
|
|
420
|
+
| Explotación | Operating activities | 6xx/7xx (PnL) + working capital changes (430, 410, 475) |
|
|
421
|
+
| Inversión | Investing activities | 2xx (fixed assets), 25x (financial investments) |
|
|
422
|
+
| Financiación | Financing activities | 17x/52x (debt), 1x (equity) |
|
|
423
|
+
|
|
424
|
+
Working capital changes (movement in receivables, payables, tax accounts) go under Operating.
|
|
425
|
+
|
|
426
|
+
## Validation Patterns for Financial Data
|
|
427
|
+
|
|
428
|
+
### Total Invariance
|
|
429
|
+
|
|
430
|
+
When reclassifying, splitting, or transforming financial amounts, the total must NEVER change:
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
function validateTotalInvariance(
|
|
434
|
+
before: number[], after: number[], tolerance = 0.01
|
|
435
|
+
): void {
|
|
436
|
+
const sumBefore = before.reduce((a, b) => a + b, 0);
|
|
437
|
+
const sumAfter = after.reduce((a, b) => a + b, 0);
|
|
438
|
+
if (Math.abs(sumBefore - sumAfter) > tolerance) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Total invariance violated: before=${sumBefore.toFixed(2)}, after=${sumAfter.toFixed(2)}, ` +
|
|
441
|
+
`diff=${(sumAfter - sumBefore).toFixed(2)}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Floating-Point Tolerance
|
|
448
|
+
|
|
449
|
+
Rounding differences are inevitable in accounting. Use €0.01 tolerance:
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
// ✅ Accounting equality check
|
|
453
|
+
const isEqual = (a: number, b: number) => Math.abs(a - b) <= 0.01;
|
|
454
|
+
|
|
455
|
+
// ❌ Never use strict equality for monetary amounts
|
|
456
|
+
// if (total === expected) { ... }
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Strict Mode: Fail on Bad Data
|
|
460
|
+
|
|
461
|
+
Don't continue processing with validation errors. Log, save, and throw:
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
if (!isEqual(computedTotal, expectedTotal)) {
|
|
465
|
+
console.error(`Validation failed: computed=${computedTotal}, expected=${expectedTotal}`);
|
|
466
|
+
// Save partial results for debugging, then throw
|
|
467
|
+
throw new Error('Financial validation failed — see logs');
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Service Accessor (`client.accounting.*`)
|
|
472
|
+
|
|
473
|
+
Most patterns in this document are available as programmatic helpers via the accounting service:
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
import { createClient } from '@marcfargas/odoo-client';
|
|
477
|
+
const client = await createClient();
|
|
478
|
+
|
|
479
|
+
// Cash account discovery
|
|
480
|
+
const cashAccounts = await client.accounting.discoverCashAccounts();
|
|
481
|
+
const cashIds = await client.accounting.getCashAccountIds();
|
|
482
|
+
|
|
483
|
+
// Cash balance from GL (not movements)
|
|
484
|
+
const balance = await client.accounting.getCashBalance(cashIds, '2025-06-30');
|
|
485
|
+
|
|
486
|
+
// Partner resolution from bank statement counterparts
|
|
487
|
+
const partner = await client.accounting.resolvePartnerFromMove(moveId, cashIds);
|
|
488
|
+
if (partner.isBatchPayment) {
|
|
489
|
+
console.log('Batch payment to:', partner.allPartnerIds);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Reconciliation tracing
|
|
493
|
+
const trace = await client.accounting.traceReconciliation(fullReconcileId);
|
|
494
|
+
|
|
495
|
+
// Closing entry detection
|
|
496
|
+
if (await client.accounting.isClosingEntry(moveId)) { /* skip */ }
|
|
497
|
+
|
|
498
|
+
// Days-to-pay calculation
|
|
499
|
+
const result = await client.accounting.calculateDaysToPay(invoiceId);
|
|
500
|
+
if (result) console.log(`Paid in ${result.days} days`);
|
|
501
|
+
|
|
502
|
+
// Query posted move lines (parent_state='posted' auto-applied, limit defaults to all)
|
|
503
|
+
const pnlLines = await client.accounting.getPostedMoveLines(
|
|
504
|
+
[['account_id.code', '=like', '7%'], ['date', '>=', '2025-01-01']],
|
|
505
|
+
{ fields: ['account_id', 'debit', 'credit', 'balance'] }
|
|
506
|
+
);
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Standalone functions are also exported for advanced composition:
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
import { discoverCashAccounts, getPostedMoveLines } from '@marcfargas/odoo-client';
|
|
513
|
+
```
|
|
514
|
+
|
|
194
515
|
## Related Documents
|
|
195
516
|
|
|
196
517
|
- [connection.md](../base/connection.md) - Authentication
|
|
518
|
+
- [contracts.md](./contracts.md) - Recurring contracts and billing
|
|
197
519
|
- [domains.md](../base/domains.md) - Query filters (especially date ranges, dot notation)
|
|
198
520
|
- [search.md](../base/search.md) - Search patterns
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# OCA Contracts — Recurring Invoicing
|
|
2
|
+
|
|
3
|
+
Patterns for working with the OCA `contract` module for recurring billing, revenue projection, and subscription-style invoicing.
|
|
4
|
+
|
|
5
|
+
**Required modules**: `contract` (from [OCA/contract](https://github.com/OCA/contract))
|
|
6
|
+
|
|
7
|
+
## Key Models
|
|
8
|
+
|
|
9
|
+
| Model | Description |
|
|
10
|
+
|-------|-------------|
|
|
11
|
+
| `contract.contract` | Contract header — partner, journal, billing schedule |
|
|
12
|
+
| `contract.line` | Individual contract lines — product, price, recurrence |
|
|
13
|
+
|
|
14
|
+
## Reading Contracts
|
|
15
|
+
|
|
16
|
+
### Include Archived/Ended Contracts
|
|
17
|
+
|
|
18
|
+
Ended and cancelled contracts are archived (`active=False`). Use `active_test: false` to see them:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// ✅ All contracts, including ended/cancelled
|
|
22
|
+
const contracts = await client.searchRead('contract.contract',
|
|
23
|
+
[],
|
|
24
|
+
{
|
|
25
|
+
fields: [
|
|
26
|
+
'name', 'partner_id', 'active', 'recurring_next_date',
|
|
27
|
+
'date_start', 'date_end', 'contract_line_ids',
|
|
28
|
+
],
|
|
29
|
+
context: { active_test: false },
|
|
30
|
+
limit: 0,
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Reading Contract Lines
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
const lines = await client.searchRead('contract.line',
|
|
39
|
+
[['contract_id', '=', contractId]],
|
|
40
|
+
{
|
|
41
|
+
fields: [
|
|
42
|
+
'name', 'product_id', 'quantity', 'price_unit', 'price_subtotal',
|
|
43
|
+
'recurring_rule_type', 'recurring_interval', 'recurring_next_date',
|
|
44
|
+
'date_start', 'date_end', 'is_canceled',
|
|
45
|
+
],
|
|
46
|
+
limit: 0,
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Billing Schedule
|
|
52
|
+
|
|
53
|
+
### Recurrence Fields
|
|
54
|
+
|
|
55
|
+
| Field | Values | Description |
|
|
56
|
+
|-------|--------|-------------|
|
|
57
|
+
| `recurring_rule_type` | `daily`, `weekly`, `monthly`, `monthlylastday`, `quarterly`, `semesterly`, `yearly` | Billing frequency unit |
|
|
58
|
+
| `recurring_interval` | integer | How many units between billings |
|
|
59
|
+
| `recurring_next_date` | date | Next scheduled billing date |
|
|
60
|
+
|
|
61
|
+
### Price Is Per Billing Cycle, NOT Monthly
|
|
62
|
+
|
|
63
|
+
**Critical**: `contract.line.price_subtotal` is the amount per billing event. A yearly contract billed annually has ONE payment for the full amount — not twelve monthly portions.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
// ❌ WRONG — dividing yearly price by 12 gives wrong monthly amount
|
|
67
|
+
// if the contract actually bills once a year
|
|
68
|
+
const monthlyRevenue = line.price_subtotal / 12;
|
|
69
|
+
|
|
70
|
+
// ✅ CORRECT — respect the billing schedule
|
|
71
|
+
const billingCycleDays = {
|
|
72
|
+
daily: 1 * line.recurring_interval,
|
|
73
|
+
weekly: 7 * line.recurring_interval,
|
|
74
|
+
monthly: 30 * line.recurring_interval,
|
|
75
|
+
quarterly: 90 * line.recurring_interval,
|
|
76
|
+
semesterly: 180 * line.recurring_interval,
|
|
77
|
+
yearly: 365 * line.recurring_interval,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const cycleDays = billingCycleDays[line.recurring_rule_type] ?? 30;
|
|
81
|
+
const dailyRate = line.price_subtotal / cycleDays;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Revenue Projection
|
|
85
|
+
|
|
86
|
+
### Event-Based Projection
|
|
87
|
+
|
|
88
|
+
Generate one billing event per contract line per billing date. This matches how Odoo actually generates invoices:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
interface BillingEvent {
|
|
92
|
+
contractId: number;
|
|
93
|
+
lineId: number;
|
|
94
|
+
date: string;
|
|
95
|
+
amount: number;
|
|
96
|
+
partnerId: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function projectBillingEvents(
|
|
100
|
+
line: any,
|
|
101
|
+
contract: any,
|
|
102
|
+
fromDate: string,
|
|
103
|
+
toDate: string,
|
|
104
|
+
): BillingEvent[] {
|
|
105
|
+
const events: BillingEvent[] = [];
|
|
106
|
+
let nextDate = new Date(line.recurring_next_date || fromDate);
|
|
107
|
+
const end = new Date(toDate);
|
|
108
|
+
const contractEnd = line.date_end ? new Date(line.date_end) : null;
|
|
109
|
+
|
|
110
|
+
while (nextDate <= end) {
|
|
111
|
+
if (contractEnd && nextDate > contractEnd) break;
|
|
112
|
+
if (nextDate >= new Date(fromDate)) {
|
|
113
|
+
events.push({
|
|
114
|
+
contractId: Array.isArray(contract.id) ? contract.id[0] : contract.id,
|
|
115
|
+
lineId: line.id,
|
|
116
|
+
date: nextDate.toISOString().slice(0, 10),
|
|
117
|
+
amount: line.price_subtotal,
|
|
118
|
+
partnerId: Array.isArray(contract.partner_id)
|
|
119
|
+
? contract.partner_id[0] : contract.partner_id,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Advance by recurring_interval × recurring_rule_type
|
|
124
|
+
switch (line.recurring_rule_type) {
|
|
125
|
+
case 'daily':
|
|
126
|
+
nextDate.setDate(nextDate.getDate() + line.recurring_interval);
|
|
127
|
+
break;
|
|
128
|
+
case 'weekly':
|
|
129
|
+
nextDate.setDate(nextDate.getDate() + 7 * line.recurring_interval);
|
|
130
|
+
break;
|
|
131
|
+
case 'monthly':
|
|
132
|
+
case 'monthlylastday':
|
|
133
|
+
nextDate.setMonth(nextDate.getMonth() + line.recurring_interval);
|
|
134
|
+
break;
|
|
135
|
+
case 'quarterly':
|
|
136
|
+
nextDate.setMonth(nextDate.getMonth() + 3 * line.recurring_interval);
|
|
137
|
+
break;
|
|
138
|
+
case 'semesterly':
|
|
139
|
+
nextDate.setMonth(nextDate.getMonth() + 6 * line.recurring_interval);
|
|
140
|
+
break;
|
|
141
|
+
case 'yearly':
|
|
142
|
+
nextDate.setFullYear(nextDate.getFullYear() + line.recurring_interval);
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
nextDate.setMonth(nextDate.getMonth() + line.recurring_interval);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return events;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### `recurring_next_date` Can Be Far in the Future
|
|
154
|
+
|
|
155
|
+
The contract module can pre-generate invoices ahead of schedule. When computing reference dates or filtering, don't use `MAX(recurring_next_date)` without a reasonable cap:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// ❌ Dangerous — could be years ahead
|
|
159
|
+
const maxDate = contracts.reduce((max, c) =>
|
|
160
|
+
c.recurring_next_date > max ? c.recurring_next_date : max, '');
|
|
161
|
+
|
|
162
|
+
// ✅ Cap to a reasonable horizon
|
|
163
|
+
const horizon = new Date();
|
|
164
|
+
horizon.setFullYear(horizon.getFullYear() + 1);
|
|
165
|
+
const maxDate = horizon.toISOString().slice(0, 10);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Common Account Patterns
|
|
169
|
+
|
|
170
|
+
| Account Range | Typical Contract Use |
|
|
171
|
+
|---------------|---------------------|
|
|
172
|
+
| 700 | Sales of goods (product contracts) |
|
|
173
|
+
| 705 | Service revenue (service contracts) |
|
|
174
|
+
| 759 | Other operating income |
|
|
175
|
+
|
|
176
|
+
## Related Documents
|
|
177
|
+
|
|
178
|
+
- [accounting.md](./accounting.md) - Accounting patterns, cashflow, reconciliation
|
|
179
|
+
- [../base/domains.md](../base/domains.md) - Query filter syntax
|
|
180
|
+
- [../base/search.md](../base/search.md) - Search patterns
|
|
@@ -299,6 +299,67 @@ These templates use Spanish PGCE 2008 account codes (7XX for income, 6XX for exp
|
|
|
299
299
|
| `currency_id` | many2one | No | Target currency |
|
|
300
300
|
| `landscape_pdf` | boolean | No | PDF orientation |
|
|
301
301
|
|
|
302
|
+
## Gotchas
|
|
303
|
+
|
|
304
|
+
### Archived Budgets Need `active_test: false`
|
|
305
|
+
|
|
306
|
+
MIS budgets (`mis.budget`) are often archived after the period ends. Without the context flag, they become invisible — easy to miss because "it worked last month."
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// ✅ Include archived budgets
|
|
310
|
+
const budgets = await client.searchRead('mis.budget',
|
|
311
|
+
[],
|
|
312
|
+
{ fields: ['name', 'active'], context: { active_test: false }, limit: 0 }
|
|
313
|
+
);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### `mis.report.instance` Has NO `active` Field
|
|
317
|
+
|
|
318
|
+
Don't filter by `active` — the field doesn't exist and will cause an error:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// ❌ WRONG — will error
|
|
322
|
+
const instances = await client.searchRead('mis.report.instance',
|
|
323
|
+
[['active', '=', true]], { fields: ['name'] }
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// ✅ CORRECT — no active field, just query directly
|
|
327
|
+
const instances = await client.searchRead('mis.report.instance',
|
|
328
|
+
[], { fields: ['name'] }
|
|
329
|
+
);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### `mis.report.instance.period` Has NO `company_id` Field
|
|
333
|
+
|
|
334
|
+
Company filtering is on the **instance** (`mis.report.instance`), not on individual periods. Don't try to filter periods by company.
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
// ❌ WRONG — company_id doesn't exist on periods
|
|
338
|
+
const periods = await client.searchRead('mis.report.instance.period',
|
|
339
|
+
[['company_id', '=', 1]], { fields: ['name'] }
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// ✅ CORRECT — filter at the instance level
|
|
343
|
+
const instances = await client.searchRead('mis.report.instance',
|
|
344
|
+
[['company_id', '=', 1]],
|
|
345
|
+
{ fields: ['name', 'period_ids'] }
|
|
346
|
+
);
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Budget Account Codes Differ from Actual Accounting
|
|
350
|
+
|
|
351
|
+
Budgets often use different account codes than the general ledger. Example: a person budgeted as "employee" (account 640) but actually paid as "subcontractor" (account 607).
|
|
352
|
+
|
|
353
|
+
**Solution**: Build an explicit equivalence table mapping budget codes to actual codes. Always adjust the budget side — the actual accounting entries are correct.
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
// Equivalence table: budget code → actual code(s)
|
|
357
|
+
const budgetEquivalences: Record<string, string[]> = {
|
|
358
|
+
'640': ['640', '607'], // Budget "employees" includes some subcontractors
|
|
359
|
+
'621': ['621', '622'], // Budget "leases" includes both rent types
|
|
360
|
+
};
|
|
361
|
+
```
|
|
362
|
+
|
|
302
363
|
## Error Handling
|
|
303
364
|
|
|
304
365
|
```typescript
|