jaz-cli 2.6.0 → 2.7.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: api
3
- version: 2.6.0
3
+ version: 2.7.0
4
4
  description: Complete reference for the Jaz/Juan REST API — the accounting platform backend. Use this skill whenever building, modifying, debugging, or extending any code that calls the API — including API clients, integrations, data seeding, test data, or new endpoint work. Contains every field name, response shape, error, gotcha, and edge case discovered through live production testing.
5
5
  license: MIT
6
6
  compatibility: Requires Jaz/Juan API key (x-jk-api-key header). Works with Claude Code, Claude Cowork, Claude.ai, and any agent that reads markdown.
@@ -22,7 +22,7 @@ You are working with the **Jaz/Juan REST API** — the backend for Jaz (Singapor
22
22
 
23
23
  **Base URL**: `https://api.getjaz.com`
24
24
  **Auth**: `x-jk-api-key: <key>` header on every request — key has `jk-` prefix (e.g., `jk-a1b2c3...`). NOT `Authorization: Bearer` or `x-api-key`.
25
- **Content-Type**: `application/json` for all POST/PUT/PATCH
25
+ **Content-Type**: `application/json` for all POST/PUT/PATCH (except multipart endpoints: `createBusinessTransactionFromAttachment` FILE mode, `importBankStatementFromAttachment`, and attachment uploads)
26
26
  **All paths are prefixed**: `/api/v1/` (e.g., `https://api.getjaz.com/api/v1/invoices`)
27
27
 
28
28
  ## Critical Rules
@@ -129,6 +129,14 @@ You are working with the **Jaz/Juan REST API** — the backend for Jaz (Singapor
129
129
  55. **Scheduled endpoints support date aliases** — `txnDateAliases` middleware (mapping `issueDate`/`date` → `valueDate`) now applies to all scheduled create/update endpoints: `POST/PUT /scheduled/invoices`, `POST/PUT /scheduled/bills`, `POST/PUT /scheduled/journals`, `POST/PUT /scheduled/subscriptions`.
130
130
  56. **Kebab-case URL aliases** — `capsuleTypes` endpoints also accept kebab-case paths: `/capsule-types` (list, search, CRUD). `moveTransactionCapsules` also accepts `/move-transaction-capsules`. Both camelCase and kebab-case work identically.
131
131
 
132
+ ### Jaz Magic — Extraction & Autofill
133
+ 57. **When the user starts from an attachment, always use Jaz Magic** — if the input is a PDF, JPG, or any document image (invoice, bill, receipt), the correct path is `POST /magic/createBusinessTransactionFromAttachment`. Do NOT manually construct a `POST /invoices` or `POST /bills` payload from an attachment — Jaz Magic handles the entire extraction-and-autofill pipeline server-side: OCR, line item detection, contact matching, CoA auto-mapping via ML learning, and draft creation with all fields pre-filled. Only use `POST /invoices` or `POST /bills` when building transactions from structured data (JSON, CSV, database rows) where the fields are already known.
134
+ 58. **Two upload modes with different content types** — `sourceType: "FILE"` requires **multipart/form-data** with `sourceFile` blob (JSON body fails with 400 "sourceFile is a required field"). `sourceType: "URL"` accepts **application/json** with `sourceURL` string. The OAS only documents URL mode — FILE mode (the common case) is undocumented.
135
+ 59. **Three required fields**: `sourceFile` (multipart blob — NOT `file`), `businessTransactionType` (`"INVOICE"` or `"BILL"` only — `EXPENSE` rejected), `sourceType` (`"FILE"` or `"URL"`). All three are validated server-side.
136
+ 60. **Response maps transaction types**: Request `BILL` → response `businessTransactionType: "PURCHASE"`. Request `INVOICE` → response `businessTransactionType: "SALE"`. S3 paths follow: `/purchases/` vs `/sales/`.
137
+ 61. **Extraction is asynchronous** — the API response is immediate (file upload confirmation only). The actual Magic pipeline — OCR, line item extraction, contact matching, CoA learning, and autofill — runs asynchronously. The `subscriptionFBPath` in the response (e.g., `magic_transactions/{orgId}/purchase/{fileId}`) is a Firebase Realtime Database path for subscribing to extraction status updates.
138
+ 62. **Accepts PDF and JPG/JPEG** — both file types confirmed working. Handwritten documents are accepted at upload stage (extraction quality varies). `fileType` in response reflects actual format: `"PDF"`, `"JPEG"`.
139
+
132
140
  ## Supporting Files
133
141
 
134
142
  For detailed reference, read these files in this skill directory:
@@ -154,6 +162,8 @@ The backend DX overhaul is live. Key improvements now available:
154
162
 
155
163
  ## Recommended Client Patterns
156
164
 
165
+ - **Starting from an attachment?** → Use Jaz Magic (`POST /magic/createBusinessTransactionFromAttachment`). Never manually parse a PDF/JPG to construct `POST /invoices` or `POST /bills` — let the extraction & autofill pipeline handle it.
166
+ - **Starting from structured data?** → Use `POST /invoices` or `POST /bills` directly with the known field values.
157
167
  - **Serialization (Python)**: `model_dump(mode="json", by_alias=True, exclude_unset=True, exclude_none=True)`
158
168
  - **Field names**: All request bodies use camelCase
159
169
  - **Date serialization**: Python `date` type → `YYYY-MM-DD` strings
@@ -54,8 +54,9 @@ Resources MUST be created in this order. Steps at the same level can run in para
54
54
  - `POST /items` → create items (needs CoA + tax profile IDs from Levels 0-1)
55
55
 
56
56
  ### Level 3: Transactions (parallel)
57
- - `POST /invoices` → create invoices (needs contacts + CoA + tax profiles)
58
- - `POST /bills` → create bills (same deps, can embed payments)
57
+ - `POST /magic/createBusinessTransactionFromAttachment` → **starting from an attachment (PDF/JPG)?** Use Jaz Magic extraction & autofill creates a draft invoice or bill with all fields pre-filled. See SKILL.md Rules 57-62.
58
+ - `POST /invoices` → create invoices from structured data (needs contacts + CoA + tax profiles)
59
+ - `POST /bills` → create bills from structured data (same deps, can embed payments)
59
60
  - `POST /journals` → create journal entries (needs CoA)
60
61
  - `POST /cash-in-journals` → cash receipts (needs bank account from CoA)
61
62
  - `POST /cash-out-journals` → cash payments (needs bank account from CoA)
@@ -790,6 +790,84 @@ Valid `datatypeCode`: `TEXT`, `NUMBER`, `BOOLEAN`, `DATE`, `LINK`.
790
790
 
791
791
  ---
792
792
 
793
+ ## 14g. Jaz Magic — Extraction & Autofill
794
+
795
+ ### POST /api/v1/magic/createBusinessTransactionFromAttachment
796
+
797
+ **When the user starts from an attachment (PDF, JPG, document image), this is the endpoint to use.** Do not manually parse files to construct `POST /invoices` or `POST /bills` — Jaz Magic handles the full extraction-and-autofill pipeline server-side: OCR, line item detection, contact matching, and CoA auto-mapping via ML learning. Creates a complete draft transaction with all fields pre-filled. Use `POST /invoices` or `POST /bills` only when building from structured data where the fields are already known.
798
+
799
+ Processing is **asynchronous** — the API response confirms file upload immediately. The extraction pipeline runs server-side and pushes status updates via Firebase Realtime Database.
800
+
801
+ **Two modes** — content type depends on `sourceType`:
802
+
803
+ #### FILE mode (multipart/form-data) — most common
804
+
805
+ ```
806
+ POST /api/v1/magic/createBusinessTransactionFromAttachment
807
+ Content-Type: multipart/form-data
808
+
809
+ Fields:
810
+ - sourceFile: PDF or JPG file blob (NOT "file")
811
+ - businessTransactionType: "INVOICE" or "BILL" (NOT "EXPENSE")
812
+ - sourceType: "FILE"
813
+ ```
814
+
815
+ ```json
816
+ / Response (201):
817
+ {
818
+ "data": {
819
+ "businessTransactionType": "PURCHASE",
820
+ "filename": "NB64458.pdf",
821
+ "invalidFiles": [],
822
+ "validFiles": [{
823
+ "subscriptionFBPath": "magic_transactions/{orgId}/purchase/{fileId}",
824
+ "errorCode": null,
825
+ "errorMessage": null,
826
+ "fileDetails": {
827
+ "fileId": "6e999313b8b53ccef0757394ee6c7e6a",
828
+ "fileType": "PDF",
829
+ "fileURL": "https://s3.ap-southeast-1.amazonaws.com/.../{resourceId}.PDF",
830
+ "fileName": "NB64458.pdf"
831
+ }
832
+ }]
833
+ }
834
+ }
835
+ ```
836
+
837
+ #### URL mode (application/json) — for remote files
838
+
839
+ ```json
840
+ / Request:
841
+ POST /api/v1/magic/createBusinessTransactionFromAttachment
842
+ Content-Type: application/json
843
+
844
+ {
845
+ "businessTransactionType": "BILL",
846
+ "sourceType": "URL",
847
+ "sourceURL": "https://example.com/invoice.pdf"
848
+ }
849
+
850
+ / Response: same shape as FILE mode
851
+ ```
852
+
853
+ **What Jaz Magic extracts and autofills:**
854
+ - Line items (description, quantity, unit price, amounts)
855
+ - Contact name and details (matched against existing contacts)
856
+ - Chart of Accounts mapping (ML-based learning from past transactions)
857
+ - Tax amounts and profiles
858
+ - Document reference numbers, dates, currency
859
+
860
+ **Key gotchas:**
861
+ - `sourceFile` is the field name (NOT `file`) — same pattern as bank statement endpoint
862
+ - Only `INVOICE` and `BILL` accepted — `EXPENSE` returns 422
863
+ - Response maps types: `INVOICE` → `SALE`, `BILL` → `PURCHASE`
864
+ - JSON body with `sourceType: "FILE"` always fails (400) — MUST use multipart
865
+ - `subscriptionFBPath` is the Firebase path for tracking extraction progress
866
+ - All three fields (`sourceFile`/`sourceURL`, `businessTransactionType`, `sourceType`) are required — omitting any returns 422
867
+ - File types confirmed: PDF, JPG/JPEG (handwritten documents accepted — extraction quality varies)
868
+
869
+ ---
870
+
793
871
  ## 15. Bank Records
794
872
 
795
873
  ### POST /api/v1/magic/importBankStatementFromAttachment (multipart)
@@ -14,7 +14,7 @@ Key capabilities: draft/approval workflows, multi-currency (auto-fetch ECB rates
14
14
 
15
15
  If payment date equals invoice date, it's recorded as a cash transaction (not AR). Transaction fees are deducted from cash received. RGL = `(Invoice payment / Transaction Rate) - (Cash received / Payment Rate)`.
16
16
 
17
- **API**: CRUD `GET/POST/PUT/DELETE /invoices`, `POST /invoices/search`, `POST /invoices/:id/payments`, `GET /invoices/:id/payments`, `POST /invoices/:id/credits`, `GET /invoices/:id/download`, `POST/GET/DELETE /invoices/:id/attachments`, `PUT /invoices/:id/approve`, `POST /scheduled/invoices` (CRUD), `POST /scheduled/subscriptions` (recurring). Generic payment ops: `GET/PUT/DELETE /payments/:id`
17
+ **API**: CRUD `GET/POST/PUT/DELETE /invoices`, `POST /invoices/search`, `POST /invoices/:id/payments`, `GET /invoices/:id/payments`, `POST /invoices/:id/credits`, `GET /invoices/:id/download`, `POST/GET/DELETE /invoices/:id/attachments`, `PUT /invoices/:id/approve`, `POST /scheduled/invoices` (CRUD), `POST /scheduled/subscriptions` (recurring). Generic payment ops: `GET/PUT/DELETE /payments/:id`. **Starting from a PDF/JPG attachment?** Use `POST /magic/createBusinessTransactionFromAttachment` instead — Jaz Magic handles extraction & autofill (see AI Agents section).
18
18
 
19
19
  ---
20
20
 
@@ -26,7 +26,7 @@ Key capabilities: bill receipts (short-form template creating bill + payment tog
26
26
 
27
27
  Transaction fees are added to cash spent (not deducted like invoices). RGL = `(Cash spent / Payment rate) - (Bill payment / Transaction rate)`.
28
28
 
29
- **API**: CRUD `GET/POST/PUT/DELETE /bills`, `POST /bills/search`, `POST /bills/:id/payments`, `GET /bills/:id/payments`, `POST /bills/:id/credits`, `POST/GET/DELETE /bills/:id/attachments`, `PUT /bills/:id/approve`, `POST /scheduled/bills` (CRUD). Generic payment ops: `GET/PUT/DELETE /payments/:id`
29
+ **API**: CRUD `GET/POST/PUT/DELETE /bills`, `POST /bills/search`, `POST /bills/:id/payments`, `GET /bills/:id/payments`, `POST /bills/:id/credits`, `POST/GET/DELETE /bills/:id/attachments`, `PUT /bills/:id/approve`, `POST /scheduled/bills` (CRUD). Generic payment ops: `GET/PUT/DELETE /payments/:id`. **Starting from a PDF/JPG attachment?** Use `POST /magic/createBusinessTransactionFromAttachment` instead — Jaz Magic handles extraction & autofill (see AI Agents section).
30
30
 
31
31
  ---
32
32
 
@@ -173,9 +173,9 @@ Fixed assets lock their linked line items — cannot edit account, amounts, or e
173
173
 
174
174
  **Agent Builder**: Configure custom AI agents in Settings > Agent Builder with name, email (a-z, 0-9, + symbol), and workflow preferences.
175
175
 
176
- **Jaz Magic**: Contact-level setting controlling auto-extraction behavior line items (detailed extraction), summary totals (single amount), or none. Up to 10 images can be merged into a single PDF.
176
+ **Jaz Magic**: The extraction & autofill engine. When users start from an attachment (PDF, JPG, document image), Jaz Magic is the correct path — it handles OCR, line item detection, contact matching, and CoA auto-mapping via ML learning, producing a complete draft transaction. Contact-level settings control extraction behavior: line items (detailed extraction), summary totals (single amount), or none. Up to 10 images can be merged into a single PDF. **Do not manually parse attachments to construct `POST /invoices` or `POST /bills` — always use Jaz Magic when the input is a file.**
177
177
 
178
- **API**: `POST /magic/createBusinessTransactionFromAttachment` (OCR PDF → draft transaction), `POST /magic/importBankStatementFromAttachment`, `PUT /invoices/magic-update`, `PUT /bills/magic-update`, `PUT /journals/magic-update`, `GET /invoices/magic-search`, `GET /bills/magic-search` (all magic endpoints use `x-magic-api-key`)
178
+ **API**: `POST /magic/createBusinessTransactionFromAttachment` (**attachment → draft transaction** — the primary endpoint for file-based creation), `POST /magic/importBankStatementFromAttachment` (bank statements), `PUT /invoices/magic-update`, `PUT /bills/magic-update`, `PUT /journals/magic-update`, `GET /invoices/magic-search`, `GET /bills/magic-search` (all magic endpoints use `x-magic-api-key`)
179
179
 
180
180
  ---
181
181
 
@@ -260,6 +260,23 @@ When POSTing, `classificationType` must be one of these exact strings (same as `
260
260
 
261
261
  ---
262
262
 
263
+ ## Jaz Magic — Extraction & Autofill
264
+
265
+ | What You'd Guess | Actual API Field | Notes |
266
+ |------------------|-------------------|-------|
267
+ | `file` | `sourceFile` | Multipart blob — same pattern as bank statement |
268
+ | `type: "BILL"` | `businessTransactionType: "BILL"` | Request accepts `INVOICE` or `BILL` only |
269
+ | Response `"BILL"` | Response `"PURCHASE"` | Response maps: `BILL` → `PURCHASE`, `INVOICE` → `SALE` |
270
+ | JSON body | multipart/form-data | FILE mode requires multipart — JSON returns 400 |
271
+ | Sync response | Async extraction | Response = upload confirmation; extraction & autofill run async |
272
+ | `status` field | `subscriptionFBPath` | Firebase path for tracking extraction progress |
273
+
274
+ **Content-Type depends on sourceType:**
275
+ - `sourceType: "FILE"` → `Content-Type: multipart/form-data` (MUST use multipart)
276
+ - `sourceType: "URL"` → `Content-Type: application/json` (JSON body works)
277
+
278
+ ---
279
+
263
280
  ## Bank Records
264
281
 
265
282
  | What You'd Guess | Actual API Field | Notes |
@@ -386,7 +386,7 @@
386
386
 
387
387
  | Method | Path | Description |
388
388
  |--------|------|-------------|
389
- | POST | `/magic/createBusinessTransactionFromAttachment` | OCR: Convert PDF → transaction |
389
+ | POST | `/magic/createBusinessTransactionFromAttachment` | **Jaz Magic: Extraction & Autofill.** Upload PDF/JPGfull extraction pipeline (OCR, line items, contact matching, CoA ML learning) → draft invoice or bill with all fields autofilled. FILE mode = multipart (`sourceFile` blob), URL mode = JSON (`sourceURL`). Async — returns `subscriptionFBPath` for tracking extraction progress. Request `INVOICE` → response `SALE`, `BILL` → `PURCHASE`. |
390
390
  | POST | `/magic/importBankStatementFromAttachment` | Convert bank statement → entries |
391
391
  | PUT | `/invoices/magic-update` | AI-enhanced invoice update |
392
392
  | PUT | `/bills/magic-update` | AI-enhanced bill update |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conversion
3
- version: 2.6.0
3
+ version: 2.7.0
4
4
  description: Accounting data conversion skill — migrates customer data from Xero, QuickBooks, Sage, MYOB, and Excel exports to Jaz. Covers config, quick, and full conversion workflows, Excel parsing, CoA/contact/tax/items mapping, clearing accounts, TTB, and TB verification.
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: transaction-recipes
3
- version: 2.6.0
3
+ version: 2.7.0
4
4
  description: 16 IFRS-compliant recipes for complex multi-step accounting in Jaz — prepaid amortization, deferred revenue, loan schedules, IFRS 16 leases, hire purchase, fixed deposits, asset disposal, FX revaluation, ECL provisioning, IAS 37 provisions, dividends, intercompany, and capital WIP. Each recipe includes journal entries, capsule structure, and verification steps. Paired with 10 financial calculators that produce execution-ready blueprints with workings.
5
5
  license: MIT
6
6
  compatibility: Works with Claude Code, Claude Cowork, Claude.ai, and any agent that reads markdown. For API payloads, load the jaz-api skill alongside this one.
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { calculatePrepaidExpense, calculateDeferredRevenue } from '../calc/amortization.js';
3
+ import { CalcValidationError } from '../calc/validate.js';
4
+ describe('calculatePrepaidExpense', () => {
5
+ const base = { amount: 12000, periods: 12 };
6
+ it('per period = amount / periods', () => {
7
+ const r = calculatePrepaidExpense(base);
8
+ expect(r.perPeriodAmount).toBe(1000);
9
+ });
10
+ it('schedule has correct length', () => {
11
+ const r = calculatePrepaidExpense(base);
12
+ expect(r.schedule).toHaveLength(12);
13
+ });
14
+ it('total amortized = original amount', () => {
15
+ const r = calculatePrepaidExpense(base);
16
+ const total = r.schedule.reduce((s, row) => s + row.amortized, 0);
17
+ expect(Math.round(total * 100) / 100).toBe(12000);
18
+ });
19
+ it('remaining balance reaches zero at final period', () => {
20
+ const r = calculatePrepaidExpense(base);
21
+ expect(r.schedule[r.schedule.length - 1].remainingBalance).toBe(0);
22
+ });
23
+ it('handles rounding — 10000 / 3 periods', () => {
24
+ const r = calculatePrepaidExpense({ amount: 10000, periods: 3 });
25
+ expect(r.perPeriodAmount).toBe(3333.33);
26
+ const total = r.schedule.reduce((s, row) => s + row.amortized, 0);
27
+ expect(Math.round(total * 100) / 100).toBe(10000);
28
+ // Final period absorbs rounding
29
+ expect(r.schedule[2].amortized).toBe(3333.34);
30
+ });
31
+ it('every journal entry balanced', () => {
32
+ const r = calculatePrepaidExpense(base);
33
+ for (const row of r.schedule) {
34
+ const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
35
+ const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
36
+ expect(debits).toBe(credits);
37
+ }
38
+ });
39
+ it('journal entries use Expense / Prepaid Asset accounts', () => {
40
+ const r = calculatePrepaidExpense(base);
41
+ expect(r.schedule[0].journal.lines[0].account).toBe('Expense');
42
+ expect(r.schedule[0].journal.lines[1].account).toBe('Prepaid Asset');
43
+ });
44
+ // Blueprint
45
+ it('blueprint step 1 = bill', () => {
46
+ const r = calculatePrepaidExpense({ ...base, startDate: '2025-01-01' });
47
+ expect(r.blueprint.steps[0].action).toBe('bill');
48
+ });
49
+ it('blueprint step count = 1 + periods', () => {
50
+ const r = calculatePrepaidExpense({ ...base, startDate: '2025-01-01' });
51
+ expect(r.blueprint.steps).toHaveLength(13);
52
+ });
53
+ it('blueprint null without startDate', () => {
54
+ const r = calculatePrepaidExpense(base);
55
+ expect(r.blueprint).toBeNull();
56
+ });
57
+ // Quarterly
58
+ it('quarterly frequency works', () => {
59
+ const r = calculatePrepaidExpense({ amount: 12000, periods: 4, frequency: 'quarterly', startDate: '2025-01-01' });
60
+ expect(r.schedule).toHaveLength(4);
61
+ // Date present and properly formatted (exact value depends on TZ)
62
+ expect(r.schedule[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
63
+ });
64
+ // Validation
65
+ it('rejects zero amount', () => {
66
+ expect(() => calculatePrepaidExpense({ amount: 0, periods: 12 })).toThrow(CalcValidationError);
67
+ });
68
+ it('rejects zero periods', () => {
69
+ expect(() => calculatePrepaidExpense({ amount: 12000, periods: 0 })).toThrow(CalcValidationError);
70
+ });
71
+ });
72
+ describe('calculateDeferredRevenue', () => {
73
+ const base = { amount: 36000, periods: 12 };
74
+ it('per period = amount / periods', () => {
75
+ const r = calculateDeferredRevenue(base);
76
+ expect(r.perPeriodAmount).toBe(3000);
77
+ });
78
+ it('total recognized = original amount', () => {
79
+ const r = calculateDeferredRevenue(base);
80
+ const total = r.schedule.reduce((s, row) => s + row.amortized, 0);
81
+ expect(Math.round(total * 100) / 100).toBe(36000);
82
+ });
83
+ it('journal entries use Deferred Revenue / Revenue accounts', () => {
84
+ const r = calculateDeferredRevenue(base);
85
+ expect(r.schedule[0].journal.lines[0].account).toBe('Deferred Revenue');
86
+ expect(r.schedule[0].journal.lines[1].account).toBe('Revenue');
87
+ });
88
+ // Blueprint
89
+ it('blueprint step 1 = invoice', () => {
90
+ const r = calculateDeferredRevenue({ ...base, startDate: '2025-01-01' });
91
+ expect(r.blueprint.steps[0].action).toBe('invoice');
92
+ });
93
+ it('capsuleType is Deferred Revenue', () => {
94
+ const r = calculateDeferredRevenue({ ...base, startDate: '2025-01-01' });
95
+ expect(r.blueprint.capsuleType).toBe('Deferred Revenue');
96
+ });
97
+ it('capsuleDescription present', () => {
98
+ const r = calculateDeferredRevenue({ ...base, startDate: '2025-01-01' });
99
+ expect(r.blueprint.capsuleDescription).toBeTruthy();
100
+ });
101
+ });
@@ -0,0 +1,249 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { calculateAssetDisposal } from '../calc/asset-disposal.js';
3
+ import { CalcValidationError } from '../calc/validate.js';
4
+ describe('calculateAssetDisposal (gain)', () => {
5
+ const base = {
6
+ cost: 50000,
7
+ salvageValue: 5000,
8
+ usefulLifeYears: 5,
9
+ acquisitionDate: '2022-01-01',
10
+ disposalDate: '2025-06-15',
11
+ proceeds: 20000,
12
+ };
13
+ it('NBV = cost - accumulated depreciation', () => {
14
+ const r = calculateAssetDisposal(base);
15
+ expect(r.netBookValue).toBe(r.inputs.cost - r.accumulatedDepreciation);
16
+ });
17
+ it('gainOrLoss = proceeds - NBV', () => {
18
+ const r = calculateAssetDisposal(base);
19
+ expect(r.gainOrLoss).toBe(r.inputs.proceeds - r.netBookValue);
20
+ });
21
+ it('isGain is true when proceeds > NBV', () => {
22
+ const r = calculateAssetDisposal(base);
23
+ expect(r.isGain).toBe(true);
24
+ });
25
+ it('monthsHeld is calculated correctly', () => {
26
+ const r = calculateAssetDisposal(base);
27
+ // Jan 2022 to Jun 2025 = 42 months, day 15 >= day 01 so +1 = 42
28
+ expect(r.monthsHeld).toBe(42);
29
+ });
30
+ it('journal is balanced', () => {
31
+ const r = calculateAssetDisposal(base);
32
+ const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
33
+ const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
34
+ expect(Math.abs(debits - credits)).toBeLessThan(0.01);
35
+ });
36
+ it('journal includes Cash, Accum Dep, FA at cost, and Gain', () => {
37
+ const r = calculateAssetDisposal(base);
38
+ const accounts = r.disposalJournal.lines.map(l => l.account);
39
+ expect(accounts).toContain('Cash / Bank Account');
40
+ expect(accounts).toContain('Accumulated Depreciation');
41
+ expect(accounts).toContain('Fixed Asset (at cost)');
42
+ expect(accounts).toContain('Gain on Disposal');
43
+ });
44
+ });
45
+ describe('calculateAssetDisposal (loss)', () => {
46
+ const base = {
47
+ cost: 50000,
48
+ salvageValue: 5000,
49
+ usefulLifeYears: 5,
50
+ acquisitionDate: '2022-01-01',
51
+ disposalDate: '2023-06-15',
52
+ proceeds: 5000, // low proceeds = loss
53
+ };
54
+ it('isGain is false when proceeds < NBV', () => {
55
+ const r = calculateAssetDisposal(base);
56
+ expect(r.isGain).toBe(false);
57
+ expect(r.gainOrLoss).toBeLessThan(0);
58
+ });
59
+ it('journal includes Loss on Disposal', () => {
60
+ const r = calculateAssetDisposal(base);
61
+ const accounts = r.disposalJournal.lines.map(l => l.account);
62
+ expect(accounts).toContain('Loss on Disposal');
63
+ expect(accounts).not.toContain('Gain on Disposal');
64
+ });
65
+ it('journal is balanced', () => {
66
+ const r = calculateAssetDisposal(base);
67
+ const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
68
+ const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
69
+ expect(Math.abs(debits - credits)).toBeLessThan(0.01);
70
+ });
71
+ });
72
+ describe('calculateAssetDisposal (scrap / write-off)', () => {
73
+ const base = {
74
+ cost: 50000,
75
+ salvageValue: 5000,
76
+ usefulLifeYears: 5,
77
+ acquisitionDate: '2022-01-01',
78
+ disposalDate: '2024-01-01',
79
+ proceeds: 0,
80
+ };
81
+ it('gainOrLoss is negative (loss) when proceeds = 0', () => {
82
+ const r = calculateAssetDisposal(base);
83
+ expect(r.isGain).toBe(false);
84
+ expect(r.gainOrLoss).toBeLessThan(0);
85
+ });
86
+ it('journal has no Cash line when proceeds = 0', () => {
87
+ const r = calculateAssetDisposal(base);
88
+ const accounts = r.disposalJournal.lines.map(l => l.account);
89
+ expect(accounts).not.toContain('Cash / Bank Account');
90
+ });
91
+ it('journal is still balanced', () => {
92
+ const r = calculateAssetDisposal(base);
93
+ const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
94
+ const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
95
+ expect(Math.abs(debits - credits)).toBeLessThan(0.01);
96
+ });
97
+ });
98
+ describe('calculateAssetDisposal (at book value)', () => {
99
+ it('gainOrLoss = 0 and isGain = true when proceeds = NBV', () => {
100
+ // Fully depreciated over 5 years: NBV = salvage = 5000
101
+ const r = calculateAssetDisposal({
102
+ cost: 50000,
103
+ salvageValue: 5000,
104
+ usefulLifeYears: 5,
105
+ acquisitionDate: '2020-01-01',
106
+ disposalDate: '2025-01-01',
107
+ proceeds: 5000, // exactly = salvage (fully depreciated)
108
+ });
109
+ expect(r.gainOrLoss).toBe(0);
110
+ expect(r.isGain).toBe(true);
111
+ });
112
+ });
113
+ describe('calculateAssetDisposal (fully depreciated)', () => {
114
+ it('accum dep capped at depreciable base (held past useful life)', () => {
115
+ const r = calculateAssetDisposal({
116
+ cost: 50000,
117
+ salvageValue: 5000,
118
+ usefulLifeYears: 5,
119
+ acquisitionDate: '2018-01-01', // held 7+ years
120
+ disposalDate: '2025-06-01',
121
+ proceeds: 3000,
122
+ });
123
+ expect(r.accumulatedDepreciation).toBe(45000); // max = cost - salvage
124
+ expect(r.netBookValue).toBe(5000);
125
+ });
126
+ });
127
+ describe('calculateAssetDisposal (DDB method)', () => {
128
+ it('DDB accumulated dep is correct', () => {
129
+ const r = calculateAssetDisposal({
130
+ cost: 50000,
131
+ salvageValue: 5000,
132
+ usefulLifeYears: 5,
133
+ acquisitionDate: '2022-01-01',
134
+ disposalDate: '2022-12-31', // ~12 months (monthsBetween rounds up partial)
135
+ proceeds: 30000,
136
+ method: 'ddb',
137
+ });
138
+ // DDB year 1: 50000 * 2/5 = 20000
139
+ expect(r.accumulatedDepreciation).toBe(20000);
140
+ expect(r.netBookValue).toBe(30000);
141
+ expect(r.gainOrLoss).toBe(0); // proceeds = NBV
142
+ });
143
+ it('DDB journal is balanced', () => {
144
+ const r = calculateAssetDisposal({
145
+ cost: 50000,
146
+ salvageValue: 5000,
147
+ usefulLifeYears: 5,
148
+ acquisitionDate: '2022-01-01',
149
+ disposalDate: '2024-06-15',
150
+ proceeds: 10000,
151
+ method: 'ddb',
152
+ });
153
+ const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
154
+ const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
155
+ expect(Math.abs(debits - credits)).toBeLessThan(0.01);
156
+ });
157
+ });
158
+ describe('calculateAssetDisposal (rounding)', () => {
159
+ it('normalizes inputs to 2dp and journals still balance', () => {
160
+ const r = calculateAssetDisposal({
161
+ cost: 50000.999,
162
+ salvageValue: 5000.111,
163
+ usefulLifeYears: 5,
164
+ acquisitionDate: '2022-01-01',
165
+ disposalDate: '2024-01-01',
166
+ proceeds: 20000.555,
167
+ });
168
+ // Inputs should be normalized
169
+ expect(r.inputs.cost).toBe(50001);
170
+ expect(r.inputs.salvageValue).toBe(5000.11);
171
+ expect(r.inputs.proceeds).toBe(20000.56);
172
+ // Journal must still balance
173
+ const debits = r.disposalJournal.lines.reduce((s, l) => s + l.debit, 0);
174
+ const credits = r.disposalJournal.lines.reduce((s, l) => s + l.credit, 0);
175
+ expect(Math.abs(debits - credits)).toBeLessThan(0.01);
176
+ });
177
+ });
178
+ describe('calculateAssetDisposal (blueprint)', () => {
179
+ const base = {
180
+ cost: 50000,
181
+ salvageValue: 5000,
182
+ usefulLifeYears: 5,
183
+ acquisitionDate: '2022-01-01',
184
+ disposalDate: '2025-06-15',
185
+ proceeds: 20000,
186
+ };
187
+ it('blueprint always present (no startDate needed)', () => {
188
+ const r = calculateAssetDisposal(base);
189
+ expect(r.blueprint).not.toBeNull();
190
+ expect(r.blueprint.capsuleDescription).toBeTruthy();
191
+ });
192
+ it('blueprint has 2 steps (journal + note)', () => {
193
+ const r = calculateAssetDisposal(base);
194
+ expect(r.blueprint.steps).toHaveLength(2);
195
+ });
196
+ it('step 1 = journal (disposal entry)', () => {
197
+ const r = calculateAssetDisposal(base);
198
+ expect(r.blueprint.steps[0].action).toBe('journal');
199
+ });
200
+ it('step 2 = note (FA register update)', () => {
201
+ const r = calculateAssetDisposal(base);
202
+ expect(r.blueprint.steps[1].action).toBe('note');
203
+ });
204
+ it('capsuleType is Asset Disposal', () => {
205
+ const r = calculateAssetDisposal(base);
206
+ expect(r.blueprint.capsuleType).toBe('Asset Disposal');
207
+ });
208
+ it('capsuleDescription contains IAS 16', () => {
209
+ const r = calculateAssetDisposal(base);
210
+ expect(r.blueprint.capsuleDescription).toContain('IAS 16');
211
+ });
212
+ });
213
+ describe('calculateAssetDisposal (validation)', () => {
214
+ const base = {
215
+ cost: 50000,
216
+ salvageValue: 5000,
217
+ usefulLifeYears: 5,
218
+ acquisitionDate: '2022-01-01',
219
+ disposalDate: '2025-06-15',
220
+ proceeds: 20000,
221
+ };
222
+ it('rejects zero cost', () => {
223
+ expect(() => calculateAssetDisposal({ ...base, cost: 0 })).toThrow(CalcValidationError);
224
+ });
225
+ it('rejects negative proceeds', () => {
226
+ expect(() => calculateAssetDisposal({ ...base, proceeds: -100 })).toThrow(CalcValidationError);
227
+ });
228
+ it('rejects salvage >= cost', () => {
229
+ expect(() => calculateAssetDisposal({ ...base, salvageValue: 50000 })).toThrow(CalcValidationError);
230
+ });
231
+ it('rejects disposal before acquisition', () => {
232
+ expect(() => calculateAssetDisposal({
233
+ ...base, acquisitionDate: '2025-01-01', disposalDate: '2024-01-01',
234
+ })).toThrow(CalcValidationError);
235
+ });
236
+ it('rejects invalid date format', () => {
237
+ expect(() => calculateAssetDisposal({
238
+ ...base, acquisitionDate: '01-01-2022',
239
+ })).toThrow(CalcValidationError);
240
+ });
241
+ it('allows zero salvage value', () => {
242
+ const r = calculateAssetDisposal({ ...base, salvageValue: 0 });
243
+ expect(r.accumulatedDepreciation).toBeGreaterThan(0);
244
+ });
245
+ it('allows zero proceeds (scrap)', () => {
246
+ const r = calculateAssetDisposal({ ...base, proceeds: 0 });
247
+ expect(r.isGain).toBe(false);
248
+ });
249
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { journalStep, billStep, invoiceStep, cashOutStep, cashInStep, fixedAssetStep, noteStep, fmtCapsuleAmount, fmtAmt, } from '../calc/blueprint.js';
3
+ describe('step builders', () => {
4
+ const lines = [
5
+ { account: 'Cash', debit: 100, credit: 0 },
6
+ { account: 'Loan', debit: 0, credit: 100 },
7
+ ];
8
+ it('journalStep sets action to journal', () => {
9
+ const s = journalStep(1, 'Test', '2025-01-01', lines);
10
+ expect(s.action).toBe('journal');
11
+ expect(s.step).toBe(1);
12
+ expect(s.description).toBe('Test');
13
+ expect(s.date).toBe('2025-01-01');
14
+ expect(s.lines).toBe(lines);
15
+ });
16
+ it('billStep sets action to bill', () => {
17
+ const s = billStep(1, 'Supplier bill', '2025-01-01', lines);
18
+ expect(s.action).toBe('bill');
19
+ });
20
+ it('invoiceStep sets action to invoice', () => {
21
+ const s = invoiceStep(1, 'Customer invoice', '2025-01-01', lines);
22
+ expect(s.action).toBe('invoice');
23
+ });
24
+ it('cashOutStep sets action to cash-out', () => {
25
+ const s = cashOutStep(1, 'Payment', '2025-01-01', lines);
26
+ expect(s.action).toBe('cash-out');
27
+ });
28
+ it('cashInStep sets action to cash-in', () => {
29
+ const s = cashInStep(1, 'Receipt', '2025-01-01', lines);
30
+ expect(s.action).toBe('cash-in');
31
+ });
32
+ it('fixedAssetStep sets action to fixed-asset with empty lines', () => {
33
+ const s = fixedAssetStep(2, 'Register ROU', '2025-01-01');
34
+ expect(s.action).toBe('fixed-asset');
35
+ expect(s.lines).toEqual([]);
36
+ });
37
+ it('noteStep sets action to note with empty lines', () => {
38
+ const s = noteStep(3, 'Update FA register');
39
+ expect(s.action).toBe('note');
40
+ expect(s.lines).toEqual([]);
41
+ expect(s.date).toBeNull();
42
+ });
43
+ it('null date passes through', () => {
44
+ const s = journalStep(1, 'Test', null, lines);
45
+ expect(s.date).toBeNull();
46
+ });
47
+ });
48
+ describe('fmtCapsuleAmount', () => {
49
+ it('formats with currency', () => {
50
+ expect(fmtCapsuleAmount(100000, 'SGD')).toBe('SGD 100,000');
51
+ });
52
+ it('formats without currency', () => {
53
+ expect(fmtCapsuleAmount(100000)).toBe('100,000');
54
+ });
55
+ it('formats small amounts', () => {
56
+ expect(fmtCapsuleAmount(50, 'USD')).toBe('USD 50');
57
+ });
58
+ });
59
+ describe('fmtAmt', () => {
60
+ it('formats with currency and 2dp', () => {
61
+ expect(fmtAmt(1234.5, 'SGD')).toBe('SGD 1,234.50');
62
+ });
63
+ it('formats without currency', () => {
64
+ expect(fmtAmt(1234.56)).toBe('1,234.56');
65
+ });
66
+ it('formats zero', () => {
67
+ expect(fmtAmt(0)).toBe('0.00');
68
+ });
69
+ it('handles null currency', () => {
70
+ expect(fmtAmt(100, null)).toBe('100.00');
71
+ });
72
+ });