@marcfargas/odoo-skills 0.2.0 → 0.4.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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcfargas/odoo-skills",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",
package/skills/SKILL.md CHANGED
@@ -36,6 +36,9 @@ 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` |
40
+ | `client.attendance.*` | Clock in/out, presence tracking | `modules/attendance.md` |
41
+ | `client.timesheets.*` | Timer start/stop, time logging | `modules/timesheets.md` |
39
42
 
40
43
  Core CRUD (`searchRead`, `create`, `write`, `unlink`, etc.) stays directly on `client`.
41
44
 
@@ -88,8 +91,10 @@ Load by reading the path shown below:
88
91
 
89
92
  | Skill | Path | Required Modules | Description |
90
93
  |-------|------|------------------|-------------|
91
- | accounting | `modules/accounting.md` | `account` | Accounting patterns, cashflow, reconciliation, bank statements |
92
- | timesheets | `modules/timesheets.md` | `hr_timesheet` | Track employee time on projects and tasks |
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 |
96
+ | attendance | `modules/attendance.md` | `hr_attendance` | Clock in/out, presence tracking (`client.attendance.*`) |
97
+ | timesheets | `modules/timesheets.md` | `hr_timesheet` | Timer start/stop, time logging on projects (`client.timesheets.*`) |
93
98
  | mis-builder | `oca/mis-builder.md` | `mis_builder`, `date_range`, `report_xlsx` | MIS Builder — reading, computing, exporting reports |
94
99
  | mis-builder-dev | `oca/mis-builder-dev.md` | `mis_builder`, `date_range`, `report_xlsx` | MIS Builder — creating & editing report templates, expression language, styling |
95
100
 
@@ -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,185 @@
1
+ # Attendance (hr_attendance)
2
+
3
+ Track employee presence with clock-in/clock-out using Odoo's attendance system.
4
+
5
+ ## Overview
6
+
7
+ The `hr_attendance` module tracks when employees are physically present (in the office, on-site, etc.). Each attendance record has a `check_in` time and optionally a `check_out` time. Odoo enforces that an employee can only have one open (no `check_out`) attendance at a time.
8
+
9
+ ## Prerequisites
10
+
11
+ - Authenticated OdooClient connection
12
+ - Module: **hr_attendance** (must be installed)
13
+ - Depends on: **hr**
14
+
15
+ ## Key Models
16
+
17
+ | Model | Description |
18
+ |-------|-------------|
19
+ | `hr.attendance` | Attendance records (clock in/out) |
20
+ | `hr.employee` | Employees who clock in/out |
21
+
22
+ ## Service Accessor
23
+
24
+ Access attendance operations via `client.attendance`:
25
+
26
+ ```typescript
27
+ const client = await createClient();
28
+
29
+ // Clock in
30
+ const record = await client.attendance.clockIn();
31
+
32
+ // Check status
33
+ const status = await client.attendance.getStatus();
34
+
35
+ // Clock out
36
+ const closed = await client.attendance.clockOut();
37
+ ```
38
+
39
+ All methods accept an optional `employeeId` parameter. When omitted, the current user's linked `hr.employee` record is used automatically.
40
+
41
+ ## Field Reference
42
+
43
+ ### hr.attendance
44
+
45
+ | Field | Type | Required | Description |
46
+ |-------|------|----------|-------------|
47
+ | `employee_id` | Many2one → hr.employee | Yes | Employee who is present |
48
+ | `check_in` | Datetime | Yes | When the employee clocked in |
49
+ | `check_out` | Datetime | No | When the employee clocked out (false = still present) |
50
+ | `worked_hours` | Float | Computed | Hours between check_in and check_out |
51
+
52
+ ## Checking Module Installation
53
+
54
+ ```typescript testable id="attendance-check-module" needs="client" expect="result.installed === true"
55
+ const installed = await client.modules.isModuleInstalled('hr_attendance');
56
+ return { installed };
57
+ ```
58
+
59
+ ## Clock In
60
+
61
+ ```typescript testable id="attendance-clock-in" needs="client" creates="hr.attendance" expect="result.success === true"
62
+ // Clock in (uses current user's employee)
63
+ const record = await client.attendance.clockIn();
64
+ trackRecord('hr.attendance', record.id);
65
+
66
+ // Check the record
67
+ const checkInTime = record.check_in; // UTC datetime string
68
+ const isOpen = !record.check_out; // true — still clocked in
69
+
70
+ // Clock out to clean up
71
+ await client.attendance.clockOut();
72
+
73
+ return {
74
+ success: true,
75
+ attendanceId: record.id,
76
+ checkIn: checkInTime,
77
+ wasOpen: isOpen
78
+ };
79
+ ```
80
+
81
+ ## Clock Out
82
+
83
+ ```typescript testable id="attendance-clock-out" needs="client" creates="hr.attendance" expect="result.success === true"
84
+ // Must be clocked in first
85
+ await client.attendance.clockIn();
86
+
87
+ // Clock out
88
+ const record = await client.attendance.clockOut();
89
+ trackRecord('hr.attendance', record.id);
90
+
91
+ return {
92
+ success: true,
93
+ attendanceId: record.id,
94
+ checkIn: record.check_in,
95
+ checkOut: record.check_out,
96
+ workedHours: record.worked_hours
97
+ };
98
+ ```
99
+
100
+ ## Check Status
101
+
102
+ ```typescript testable id="attendance-status" needs="client" expect="result.success === true"
103
+ const status = await client.attendance.getStatus();
104
+
105
+ if (status.checkedIn) {
106
+ const att = status.currentAttendance!;
107
+ // Employee is in the office since att.check_in
108
+ } else {
109
+ // Employee is not in the office
110
+ }
111
+
112
+ return {
113
+ success: true,
114
+ checkedIn: status.checkedIn,
115
+ employeeName: status.employee[1]
116
+ };
117
+ ```
118
+
119
+ ## List Attendance Records
120
+
121
+ ```typescript testable id="attendance-list" needs="client" expect="result.success === true"
122
+ const today = new Date().toISOString().split('T')[0];
123
+
124
+ const records = await client.attendance.list({
125
+ dateFrom: today,
126
+ dateTo: today,
127
+ limit: 20,
128
+ });
129
+
130
+ const totalHours = records.reduce((sum, r) => sum + (r.worked_hours || 0), 0);
131
+
132
+ return {
133
+ success: true,
134
+ count: records.length,
135
+ totalHours
136
+ };
137
+ ```
138
+
139
+ ### Filter by Employee
140
+
141
+ ```typescript
142
+ // Get attendance for a specific employee
143
+ const records = await client.attendance.list({
144
+ employeeId: 42,
145
+ dateFrom: '2026-02-01',
146
+ dateTo: '2026-02-28',
147
+ });
148
+ ```
149
+
150
+ ## Standalone Functions
151
+
152
+ For advanced composition, standalone functions are also exported:
153
+
154
+ ```typescript
155
+ import { clockIn, clockOut, getStatus, listAttendances } from '@marcfargas/odoo-client';
156
+
157
+ const record = await clockIn(client, employeeId);
158
+ const closed = await clockOut(client, employeeId);
159
+ const status = await getStatus(client, employeeId);
160
+ const list = await listAttendances(client, { employeeId: 42, limit: 10 });
161
+ ```
162
+
163
+ ## Important Notes
164
+
165
+ ### One Open Attendance Per Employee
166
+
167
+ Odoo enforces via `_check_validity` constraint that an employee can only have **one open attendance** (no `check_out`) at a time. Attempting to clock in when already clocked in will throw an `OdooValidationError`.
168
+
169
+ ### Employee ↔ User Relationship
170
+
171
+ - `employee_id` links to `hr.employee`
172
+ - Each employee has a `user_id` linking to `res.users`
173
+ - The service auto-resolves the current user's employee when `employeeId` is omitted
174
+
175
+ ### Datetime Handling
176
+
177
+ - `check_in` and `check_out` are stored in **UTC** as `YYYY-MM-DD HH:MM:SS`
178
+ - Odoo applies the user's timezone for display in the UI
179
+ - `worked_hours` is computed as `(check_out - check_in)` in hours
180
+
181
+ ## Related Documents
182
+
183
+ - [timesheets.md](./timesheets.md) — Time tracking on projects (different from attendance)
184
+ - [../base/crud.md](../base/crud.md) — CRUD operations
185
+ - [../base/modules.md](../base/modules.md) — Module management
@@ -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
@@ -6,6 +6,10 @@ Track employee time on projects and tasks using Odoo's timesheet system.
6
6
 
7
7
  The `hr_timesheet` module allows employees to log time spent on projects and tasks. Time entries are stored in `account.analytic.line` with project/task context.
8
8
 
9
+ Two workflows:
10
+ 1. **Timer-based**: Start a timer → work → stop the timer (duration auto-computed)
11
+ 2. **Manual**: Log completed work with known hours
12
+
9
13
  ## Prerequisites
10
14
 
11
15
  - Authenticated OdooClient connection
@@ -21,6 +25,39 @@ The `hr_timesheet` module allows employees to log time spent on projects and tas
21
25
  | `project.task` | Tasks within projects |
22
26
  | `hr.employee` | Employees who log time |
23
27
 
28
+ ## Service Accessor
29
+
30
+ Access timesheet operations via `client.timesheets`:
31
+
32
+ ```typescript
33
+ const client = await createClient();
34
+
35
+ // Timer workflow
36
+ const entry = await client.timesheets.startTimer({
37
+ description: 'Feature development',
38
+ projectId: 5,
39
+ taskId: 42,
40
+ });
41
+ // ... work ...
42
+ const stopped = await client.timesheets.stopTimer(entry.id);
43
+ console.log(`Logged ${stopped.unit_amount.toFixed(2)} hours`);
44
+
45
+ // Manual logging
46
+ await client.timesheets.logTime({
47
+ description: 'Code review',
48
+ projectId: 5,
49
+ hours: 1.5,
50
+ });
51
+
52
+ // Check running timers (entries with unit_amount = 0)
53
+ const running = await client.timesheets.getRunningTimers();
54
+
55
+ // List entries
56
+ const entries = await client.timesheets.list({ projectId: 5 });
57
+ ```
58
+
59
+ All methods accept an optional `employeeId`. When omitted, the current user's linked `hr.employee` record is used automatically.
60
+
24
61
  ## Field Reference
25
62
 
26
63
  ### account.analytic.line (Timesheet Entry)
@@ -36,6 +73,7 @@ The `hr_timesheet` module allows employees to log time spent on projects and tas
36
73
  | `company_id` | Many2one → res.company | Yes | Company (defaults to current) |
37
74
  | `amount` | Monetary | Yes | Cost amount (auto-calculated from hourly cost) |
38
75
  | `user_id` | Many2one → res.users | No | Related user |
76
+ | `create_date` | Datetime | Auto | Record creation time (used to compute elapsed time for timers) |
39
77
 
40
78
  ## Checking Module Installation
41
79
 
@@ -362,8 +400,151 @@ The `amount` field is calculated based on:
362
400
  - `unit_amount` (hours) x employee's `hourly_cost`
363
401
  - Set hourly cost on `hr.employee.hourly_cost`
364
402
 
403
+ ## Timer Operations (via service accessor)
404
+
405
+ ### Start a Timer
406
+
407
+ ```typescript testable id="timesheets-start-timer" needs="client" creates="account.analytic.line" expect="result.success === true"
408
+ // Find a project with timesheets enabled
409
+ const [project] = await client.searchRead('project.project', [
410
+ ['allow_timesheets', '=', true]
411
+ ], { fields: ['id', 'name'], limit: 1 });
412
+
413
+ if (!project) {
414
+ throw new Error('No project with timesheets enabled');
415
+ }
416
+
417
+ // Start a timer
418
+ const entry = await client.timesheets.startTimer({
419
+ description: 'Working on feature X',
420
+ projectId: project.id,
421
+ });
422
+ trackRecord('account.analytic.line', entry.id);
423
+
424
+ // Timer is now running — unit_amount = 0
425
+ const isRunning = entry.unit_amount === 0;
426
+
427
+ // Stop it to clean up
428
+ await client.timesheets.stopTimer(entry.id);
429
+
430
+ return { success: true, entryId: entry.id, wasRunning: isRunning };
431
+ ```
432
+
433
+ ### Stop a Timer
434
+
435
+ ```typescript testable id="timesheets-stop-timer" needs="client" creates="account.analytic.line" expect="result.success === true"
436
+ const [project] = await client.searchRead('project.project', [
437
+ ['allow_timesheets', '=', true]
438
+ ], { fields: ['id'], limit: 1 });
439
+
440
+ // Start then stop
441
+ const entry = await client.timesheets.startTimer({
442
+ description: 'Timer to stop',
443
+ projectId: project.id,
444
+ });
445
+ trackRecord('account.analytic.line', entry.id);
446
+
447
+ // Wait briefly so there's measurable time
448
+ await new Promise(resolve => setTimeout(resolve, 500));
449
+
450
+ const stopped = await client.timesheets.stopTimer(entry.id);
451
+
452
+ return {
453
+ success: true,
454
+ hoursLogged: stopped.unit_amount,
455
+ timerStopped: stopped.unit_amount > 0
456
+ };
457
+ ```
458
+
459
+ ### Find Running Timers
460
+
461
+ ```typescript testable id="timesheets-running-timers" needs="client" expect="result.success === true"
462
+ const running = await client.timesheets.getRunningTimers();
463
+
464
+ return {
465
+ success: true,
466
+ runningCount: running.length,
467
+ entries: running.map(e => ({
468
+ id: e.id,
469
+ description: e.name,
470
+ createdAt: e.create_date
471
+ }))
472
+ };
473
+ ```
474
+
475
+ ### Log Completed Time (No Timer)
476
+
477
+ ```typescript testable id="timesheets-log-time" needs="client" creates="account.analytic.line" expect="result.success === true"
478
+ const [project] = await client.searchRead('project.project', [
479
+ ['allow_timesheets', '=', true]
480
+ ], { fields: ['id'], limit: 1 });
481
+
482
+ const entry = await client.timesheets.logTime({
483
+ description: 'Completed code review',
484
+ projectId: project.id,
485
+ hours: 1.5,
486
+ });
487
+ trackRecord('account.analytic.line', entry.id);
488
+
489
+ return {
490
+ success: true,
491
+ entryId: entry.id,
492
+ hours: entry.unit_amount,
493
+ description: entry.name
494
+ };
495
+ ```
496
+
497
+ ### List Timesheet Entries
498
+
499
+ ```typescript testable id="timesheets-list" needs="client" expect="result.success === true"
500
+ const entries = await client.timesheets.list({
501
+ limit: 10,
502
+ });
503
+
504
+ const totalHours = entries.reduce((sum, e) => sum + (e.unit_amount || 0), 0);
505
+
506
+ return {
507
+ success: true,
508
+ count: entries.length,
509
+ totalHours
510
+ };
511
+ ```
512
+
513
+ ## Timer Architecture
514
+
515
+ The timer concept is simple — it's just the `unit_amount` field:
516
+
517
+ - **Running clock** = `unit_amount = 0` (entry without duration)
518
+ - **Closed entry** = `unit_amount > 0` (duration filled)
519
+
520
+ This is standard `hr_timesheet` behavior. No extra modules needed. The OCA module
521
+ [`project_timesheet_time_control`](https://github.com/OCA/project/tree/17.0/project_timesheet_time_control)
522
+ adds UI buttons (Start/Stop/Resume) for this workflow, but the data model is the same.
523
+
524
+ The `client.timesheets` service wraps this into a clean API:
525
+ - `startTimer()` → creates entry with `unit_amount = 0`
526
+ - `stopTimer()` → computes elapsed time from `create_date`, writes `unit_amount`
527
+ - `getRunningTimers()` → searches for entries with `unit_amount = 0`
528
+
529
+ ## Standalone Functions
530
+
531
+ For advanced composition, standalone functions are also exported:
532
+
533
+ ```typescript
534
+ import {
535
+ startTimer, stopTimer, getRunningTimers, logTime, listTimesheets
536
+ } from '@marcfargas/odoo-client';
537
+
538
+ const entry = await startTimer(client, { description: 'Work', projectId: 5 });
539
+ const stopped = await stopTimer(client, entry.id);
540
+ const running = await getRunningTimers(client, employeeId);
541
+ const logged = await logTime(client, { description: 'Review', projectId: 5, hours: 2 });
542
+ const list = await listTimesheets(client, { projectId: 5 });
543
+ ```
544
+
365
545
  ## Related Documents
366
546
 
547
+ - [attendance.md](./attendance.md) - Clock in/out (physical presence, separate from time tracking)
367
548
  - [crud.md](../base/crud.md) - CRUD operations
368
549
  - [search.md](../base/search.md) - Search patterns
369
550
  - [domains.md](../base/domains.md) - Domain filters
@@ -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