@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.
@@ -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
- | [accounting](./skills/modules/accounting.md) | `account` | Accounting patterns |
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.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, bank statements |
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