@frihet/mcp-server 2.0.1 → 2.0.2

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
@@ -19,7 +19,7 @@
19
19
  <a href="https://www.npmjs.com/package/@frihet/mcp-server"><img src="https://img.shields.io/npm/v/@frihet/mcp-server?style=flat&color=18181b&labelColor=09090b" alt="npm version"></a>
20
20
  <a href="https://www.npmjs.com/package/@frihet/mcp-server"><img src="https://img.shields.io/npm/dm/@frihet/mcp-server?style=flat&color=18181b&labelColor=09090b&label=downloads" alt="npm downloads"></a>
21
21
  <a href="https://registry.modelcontextprotocol.io/?q=io.frihet"><img src="https://img.shields.io/badge/MCP_Registry-io.frihet%2Ferp-4A90D9?style=flat&logo=anthropic&logoColor=white" alt="MCP Registry"></a>
22
- <img src="https://img.shields.io/badge/catalogue-156_operations-18181b?style=flat&labelColor=09090b" alt="156 canonical catalogue operations">
22
+ <img src="https://img.shields.io/badge/catalogue-158_operations-18181b?style=flat&labelColor=09090b" alt="158 canonical catalogue operations">
23
23
  <a href="https://github.com/Frihet-io/frihet-mcp/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-18181b?style=flat&labelColor=09090b" alt="license"></a>
24
24
  </p>
25
25
 
@@ -38,7 +38,7 @@ You: "Create an invoice for TechStart SL, 40 hours of consulting at 75 EUR/h
38
38
  Claude: Done. Invoice INV-2026-089 created. Total: 3,000.00 EUR + 21% IVA = 3,630.00 EUR.
39
39
  ```
40
40
 
41
- 156 canonical operations. Five fiscal aliases. Ten prompts. The local package serves 11 resources; the hosted Worker deliberately serves the 7 static resources, while API-backed workspace resources remain local-profile only.
41
+ 158 canonical operations. Five fiscal aliases. Ten prompts. The local package serves 11 resources; the hosted Worker deliberately serves the 7 static resources, while API-backed workspace resources remain local-profile only.
42
42
 
43
43
  ---
44
44
 
@@ -77,7 +77,7 @@ Local installs need Node 22.23.4+, 24.22.1+ or 26.12.1+. Other clients are cover
77
77
 
78
78
  Also listed on [Glama](https://glama.ai/mcp/servers/Frihet-io/frihet-mcp), [Cursor Directory](https://cursor.directory/plugins/mcp-frihet), [mcp.so](https://mcp.so/servers/frihet-mcp), [mcpservers.org](https://mcpservers.org/servers/frihet-io/frihet-mcp) and the [ToolSDK registry](https://github.com/toolsdk-ai/toolsdk-mcp-registry/blob/main/packages/finance-fintech/frihet-mcp.json).
79
79
 
80
- > **Surface truth:** the catalogue contains 156 canonical operations. The local full profile serves 161 tool names, 11 resources, and 10 prompts (156 canonical operations plus 5 fiscal aliases). The hosted grouped profile serves 164 tool names, 7 resources, and 10 prompts (the same names plus 3 discovery tools, with API-backed resources kept local-only). The separately reviewed OpenAI profile serves 33 tool names, 0 resources, and 0 prompts. These counts describe the current source release; the hosted server reports the version it is running at `/health`. Catalogue membership is not a promise that a backing API is enabled for every workspace.
80
+ > **Surface truth:** the catalogue contains 158 canonical operations. The local full profile serves 163 tool names, 11 resources, and 10 prompts (158 canonical operations plus 5 fiscal aliases). The hosted grouped profile serves 166 tool names, 7 resources, and 10 prompts (the same names plus 3 discovery tools, with API-backed resources kept local-only). The separately reviewed OpenAI profile serves 33 tool names, 0 resources, and 0 prompts. These counts describe the current source release; the hosted server reports the version it is running at `/health`. Catalogue membership is not a promise that a backing API is enabled for every workspace.
81
81
 
82
82
  ---
83
83
 
@@ -197,7 +197,7 @@ If you are an agent reading this repository, the contract is machine-readable an
197
197
 
198
198
  ## What to expect
199
199
 
200
- This MCP is a **structured data interface** -- you describe what you want in natural language, and the AI creates, queries, or modifies business records in Frihet. Most of the 156 canonical operations are CRUD operations over the REST API; the rest are read-only summaries and fiscal/e-invoice actions. Alias and discovery names are counted separately.
200
+ This MCP is a **structured data interface** -- you describe what you want in natural language, and the AI creates, queries, or modifies business records in Frihet. Most of the 158 canonical operations are CRUD operations over the REST API; the rest are read-only summaries and fiscal/e-invoice actions. Alias and discovery names are counted separately.
201
201
 
202
202
  It does not do OCR, PDF scanning, file upload or image processing. To digitize a paper invoice, extract the data first (a vision model, an OCR service, or manual entry), then ask the assistant to create the record. One API key maps to one Frihet workspace, and you need a Frihet account at [app.frihet.io](https://app.frihet.io).
203
203
 
@@ -206,7 +206,7 @@ It does not do OCR, PDF scanning, file upload or image processing. To digitize a
206
206
  ## Full catalogue
207
207
 
208
208
  <details>
209
- <summary><strong>All 156 canonical operations, resources and prompts</strong></summary>
209
+ <summary><strong>All 158 canonical operations, resources and prompts</strong></summary>
210
210
 
211
211
  ### Invoices (12)
212
212
 
@@ -629,7 +629,7 @@ Test with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspect
629
629
 
630
630
  | Package | What it is |
631
631
  |---------|-----------|
632
- | [`@frihet/mcp-server`](https://www.npmjs.com/package/@frihet/mcp-server) | This MCP server (156 canonical operations + 5 alias names; 11 local resources; 10 prompts) |
632
+ | [`@frihet/mcp-server`](https://www.npmjs.com/package/@frihet/mcp-server) | This MCP server (158 canonical operations + 5 alias names; 11 local resources; 10 prompts) |
633
633
  | [`@frihet/sdk`](https://github.com/Frihet-io/frihet-sdk) | TypeScript SDK (`frihet.invoices.create()`) |
634
634
  | [`frihet`](https://www.npmjs.com/package/frihet) | CLI (`frihet invoices list --status overdue`) |
635
635
  | [`n8n-nodes-frihet`](https://www.npmjs.com/package/n8n-nodes-frihet) | n8n community node for workflow automation |
@@ -15,7 +15,7 @@
15
15
  */
16
16
  export declare const HARNESS_CONTRACT_VERSION = 1;
17
17
  export declare const INSPECTOR_PINNED_VERSION = "2.3.0";
18
- export declare const CANONICAL_OPERATIONS_COUNT = 156;
18
+ export declare const CANONICAL_OPERATIONS_COUNT = 158;
19
19
  export declare const FISCAL_ALIASES_COUNT: number;
20
20
  export type CheckStatus = "PASS" | "FAIL" | "UNSUPPORTED" | "NOT_EXERCISED";
21
21
  export type OverallStatus = "PASS" | "PASS_WITH_GAPS" | "FAIL";
@@ -24,8 +24,9 @@ export const HARNESS_CONTRACT_VERSION = 1;
24
24
  export const INSPECTOR_PINNED_VERSION = "2.3.0";
25
25
  // Updated 2026-10-10 by issue #124 (IGIC-pattern withdraw of dead onboarding_status /
26
26
  // onboarding_persona_set — the Frihet-ERP /v1/onboarding/* family is absent, so the
27
- // MCP catalogue now declares 156 canonical operations, down from 158).
28
- export const CANONICAL_OPERATIONS_COUNT = 156;
27
+ // MCP catalogue then declared 156 canonical operations, down from 158; the two read-only
28
+ // purchase-order tools (ADR 0027 S2) bring it to 158).
29
+ export const CANONICAL_OPERATIONS_COUNT = 158;
29
30
  export const FISCAL_ALIASES_COUNT = Object.keys(FISCAL_MODELO_ALIASES).length;
30
31
  /**
31
32
  * Executes the complete compatibility harness in-process and returns
@@ -170,7 +170,9 @@ export const CLAUDE_EXCLUDED_TOOLS = {
170
170
  "period_reopen": "The handler deliberately defers execution; capability truth marks this deferred.",
171
171
  "refund_sale": "Executes an actual Stripe money transfer; excluded under Directory policy section 4.A.",
172
172
  "send_quote": "Previewed recipient is not atomically bound and delivery is not retry-safe.",
173
- "sync_channel": "The handler deliberately defers execution; capability truth marks this deferred."
173
+ "sync_channel": "The handler deliberately defers execution; capability truth marks this deferred.",
174
+ "get_purchase_order": "Added after the 176de12 Claude operational candidate freeze; not yet admitted to the Claude profile.",
175
+ "list_purchase_orders": "Added after the 176de12 Claude operational candidate freeze; not yet admitted to the Claude profile."
174
176
  };
175
177
  export const CLAUDE_CANDIDATE_RESOURCES = new Set([
176
178
  "frihet://config/currencies",
@@ -101,6 +101,13 @@ export interface IFrihetClient {
101
101
  createVendor(data: Record<string, unknown>): Promise<Record<string, unknown>>;
102
102
  updateVendor(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
103
103
  deleteVendor(id: string): Promise<void>;
104
+ listPurchaseOrders(params?: {
105
+ status?: string;
106
+ vendorId?: string;
107
+ limit?: number;
108
+ offset?: number;
109
+ }): Promise<PaginatedResponse<Record<string, unknown>>>;
110
+ getPurchaseOrder(id: string): Promise<Record<string, unknown>>;
104
111
  sendInvoice(id: string, to?: string): Promise<Record<string, unknown>>;
105
112
  markInvoicePaid(id: string, paidDate?: string): Promise<Record<string, unknown>>;
106
113
  getInvoicePdf(id: string): Promise<import("./client.js").BinaryDocument>;
package/dist/client.d.ts CHANGED
@@ -275,6 +275,13 @@ export declare class FrihetClient {
275
275
  createVendor(data: Record<string, unknown>): Promise<Record<string, unknown>>;
276
276
  updateVendor(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
277
277
  deleteVendor(id: string): Promise<void>;
278
+ listPurchaseOrders(params?: {
279
+ status?: string;
280
+ vendorId?: string;
281
+ limit?: number;
282
+ offset?: number;
283
+ }): Promise<PaginatedResponse<Record<string, unknown>>>;
284
+ getPurchaseOrder(id: string): Promise<Record<string, unknown>>;
278
285
  sendInvoice(id: string, to?: string): Promise<Record<string, unknown>>;
279
286
  markInvoicePaid(id: string, paidDate?: string): Promise<Record<string, unknown>>;
280
287
  getInvoicePdf(id: string): Promise<BinaryDocument>;
package/dist/client.js CHANGED
@@ -850,6 +850,22 @@ export class FrihetClient {
850
850
  async deleteVendor(id) {
851
851
  return this.request("DELETE", `/vendors/${pathSegment(id)}`);
852
852
  }
853
+ // ---------------------------------------------------------------- Purchase Orders
854
+ // ----------------------------------------------------------------
855
+ // ADR 0027 S2, server-owned lifecycle. Routes are read-only here; the
856
+ // write surface (create draft, send, confirm, cancel, receive,
857
+ // reverse-receipt, bill) is deliberately not exposed by the MCP server.
858
+ async listPurchaseOrders(params) {
859
+ return this.requestPaginated("GET", "/purchase-orders", undefined, {
860
+ status: params?.status,
861
+ vendorId: params?.vendorId,
862
+ limit: params?.limit,
863
+ offset: params?.offset,
864
+ });
865
+ }
866
+ async getPurchaseOrder(id) {
867
+ return this.requestUnwrapped("GET", `/purchase-orders/${pathSegment(id)}`);
868
+ }
853
869
  // ---------------------------------------------------------------- Invoice Actions
854
870
  // ----------------------------------------------------------------
855
871
  async sendInvoice(id, to) {
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * A drop-in IFrihetClient that makes ZERO network calls: it serves the canned
5
5
  * PII-safe fixtures in demo-fixtures.ts. index.ts swaps this in for FrihetClient
6
- * when demo mode is on, so tools (which stringify the client's return value)
7
- * automatically propagate the `_demo`/`_demoNotice` banner without any change
8
- * under src/tools/.
6
+ * when demo mode is on. Single-record fixtures carry their own demo stamps;
7
+ * server-composition.ts stamps paginated MCP results after profile projection
8
+ * and declares those fields only on demo schemas, preserving live contracts.
9
9
  *
10
10
  * Response contract (see SPEC-mcp-demo-mode.md option B/C):
11
- * - reads/lists → fixture data stamped `_demo:true` (READ notice).
11
+ * - reads/lists → MCP fixture data stamped `_demo:true` (READ notice).
12
12
  * - lists without fixtures → EMPTY PaginatedResponse, still stamped.
13
13
  * - writes → plausible fixture-shaped object with a `demo_`-prefixed id
14
14
  * (WRITE notice "…Simulated — not persisted.").
@@ -23,10 +23,7 @@ import { type BinaryDocument, type EInvoiceDocument, type EInvoiceExportResult,
23
23
  type Rec = Record<string, unknown>;
24
24
  export declare class DemoFrihetClient implements IFrihetClient {
25
25
  readonly isDemoClient: true;
26
- listInvoices(params?: {
27
- limit?: number;
28
- offset?: number;
29
- }): Promise<PaginatedResponse<Rec>>;
26
+ listInvoices(params?: Parameters<IFrihetClient["listInvoices"]>[0]): Promise<PaginatedResponse<Rec>>;
30
27
  getInvoice(id: string): Promise<Rec>;
31
28
  createInvoice(data: Rec): Promise<Rec>;
32
29
  updateInvoice(id: string, data: Rec): Promise<Rec>;
@@ -35,10 +32,7 @@ export declare class DemoFrihetClient implements IFrihetClient {
35
32
  limit?: number;
36
33
  offset?: number;
37
34
  }): Promise<PaginatedResponse<Rec>>;
38
- listExpenses(params?: {
39
- limit?: number;
40
- offset?: number;
41
- }): Promise<PaginatedResponse<Rec>>;
35
+ listExpenses(params?: Parameters<IFrihetClient["listExpenses"]>[0]): Promise<PaginatedResponse<Rec>>;
42
36
  getExpense(id: string): Promise<Rec>;
43
37
  createExpense(data: Rec): Promise<Rec>;
44
38
  updateExpense(id: string, data: Rec): Promise<Rec>;
@@ -75,6 +69,13 @@ export declare class DemoFrihetClient implements IFrihetClient {
75
69
  createVendor(data: Rec): Promise<Rec>;
76
70
  updateVendor(id: string, data: Rec): Promise<Rec>;
77
71
  deleteVendor(_id: string): Promise<void>;
72
+ listPurchaseOrders(params?: {
73
+ status?: string;
74
+ vendorId?: string;
75
+ limit?: number;
76
+ offset?: number;
77
+ }): Promise<PaginatedResponse<Rec>>;
78
+ getPurchaseOrder(id: string): Promise<Rec>;
78
79
  sendInvoice(id: string, to?: string): Promise<Rec>;
79
80
  markInvoicePaid(id: string, paidDate?: string): Promise<Rec>;
80
81
  getInvoicePdf(id: string): Promise<BinaryDocument>;
@@ -271,10 +272,7 @@ export declare class DemoFrihetClient implements IFrihetClient {
271
272
  offset?: number;
272
273
  }): Promise<PaginatedResponse<Rec>>;
273
274
  getBankAccount(id: string): Promise<Rec>;
274
- listTransactions(params?: {
275
- limit?: number;
276
- offset?: number;
277
- }): Promise<PaginatedResponse<Rec>>;
275
+ listTransactions(params?: Parameters<IFrihetClient["listTransactions"]>[0]): Promise<PaginatedResponse<Rec>>;
278
276
  categorizeTransaction(id: string, data: {
279
277
  category: string;
280
278
  notes?: string;
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * A drop-in IFrihetClient that makes ZERO network calls: it serves the canned
5
5
  * PII-safe fixtures in demo-fixtures.ts. index.ts swaps this in for FrihetClient
6
- * when demo mode is on, so tools (which stringify the client's return value)
7
- * automatically propagate the `_demo`/`_demoNotice` banner without any change
8
- * under src/tools/.
6
+ * when demo mode is on. Single-record fixtures carry their own demo stamps;
7
+ * server-composition.ts stamps paginated MCP results after profile projection
8
+ * and declares those fields only on demo schemas, preserving live contracts.
9
9
  *
10
10
  * Response contract (see SPEC-mcp-demo-mode.md option B/C):
11
- * - reads/lists → fixture data stamped `_demo:true` (READ notice).
11
+ * - reads/lists → MCP fixture data stamped `_demo:true` (READ notice).
12
12
  * - lists without fixtures → EMPTY PaginatedResponse, still stamped.
13
13
  * - writes → plausible fixture-shaped object with a `demo_`-prefixed id
14
14
  * (WRITE notice "…Simulated — not persisted.").
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { toApiEInvoiceExportFormat, } from "./client.js";
21
21
  import { Buffer } from "node:buffer";
22
- import { READ_STAMP, FISCAL_STAMP, DEMO_NOW, demoPage, demoEmptyPage, findOrStub, demoId, simulateWrite, simulateAction, demoClients, demoProducts, demoInvoices, demoExpenses, demoQuotes, demoVendors, demoBankAccounts, demoTransactions, } from "./demo-fixtures.js";
22
+ import { READ_STAMP, FISCAL_STAMP, DEMO_NOW, demoPage, demoEmptyPage, findOrStub, demoId, simulateWrite, simulateAction, demoClients, demoMoney, demoSum, demoTopClients, demoProducts, demoInvoices, demoExpenses, demoQuotes, demoVendors, demoPurchaseOrders, demoBankAccounts, demoTransactions, } from "./demo-fixtures.js";
23
23
  const DEMO_RBAC_ROLES = ["owner", "admin", "manager", "sales", "accountant", "employee", "viewer"];
24
24
  const DEMO_RBAC_RESOURCES = [
25
25
  "workspace", "invoices", "quotes", "expenses", "clients", "products", "accounting",
@@ -78,7 +78,12 @@ export class DemoFrihetClient {
78
78
  isDemoClient = true;
79
79
  // ---------------------------------------------------------------- Invoices
80
80
  async listInvoices(params) {
81
- return demoPage(demoInvoices, params);
81
+ const filtered = demoInvoices.filter((row) => (!params?.from || (typeof row.issueDate === "string" && row.issueDate >= params.from))
82
+ && (!params?.to || (typeof row.issueDate === "string" && row.issueDate <= params.to))
83
+ && (!params?.status || row.status === params.status)
84
+ && (!params?.clientId || row.clientId === params.clientId)
85
+ && (!params?.seriesId || row.seriesId === params.seriesId));
86
+ return demoPage(filtered, params, ["issueDate", "documentNumber", "id"]);
82
87
  }
83
88
  async getInvoice(id) {
84
89
  return findOrStub(demoInvoices, id);
@@ -109,7 +114,11 @@ export class DemoFrihetClient {
109
114
  }
110
115
  // ---------------------------------------------------------------- Expenses
111
116
  async listExpenses(params) {
112
- return demoPage(demoExpenses, params);
117
+ const filtered = demoExpenses.filter((row) => (!params?.from || (typeof row.date === "string" && row.date >= params.from))
118
+ && (!params?.to || (typeof row.date === "string" && row.date <= params.to))
119
+ && (!params?.vendorId || row.vendorId === params.vendorId)
120
+ && (!params?.category || row.category === params.category));
121
+ return demoPage(filtered, params, ["date", "id"]);
113
122
  }
114
123
  async getExpense(id) {
115
124
  return findOrStub(demoExpenses, id);
@@ -187,6 +196,21 @@ export class DemoFrihetClient {
187
196
  async deleteVendor(_id) {
188
197
  return;
189
198
  }
199
+ // ---------------------------------------------------------------- Purchase Orders
200
+ // Read-only surface (ADR 0027 S2). Write tools are intentionally absent.
201
+ async listPurchaseOrders(params) {
202
+ const filtered = demoPurchaseOrders.filter((po) => {
203
+ if (params?.status && po.status !== params.status)
204
+ return false;
205
+ if (params?.vendorId && po.vendorId !== params.vendorId)
206
+ return false;
207
+ return true;
208
+ });
209
+ return demoPage(filtered, params);
210
+ }
211
+ async getPurchaseOrder(id) {
212
+ return findOrStub(demoPurchaseOrders, id);
213
+ }
190
214
  // ---------------------------------------------------------------- Invoice actions
191
215
  async sendInvoice(id, to) {
192
216
  // Email dispatch — simulated, no email is actually sent.
@@ -371,6 +395,17 @@ export class DemoFrihetClient {
371
395
  }
372
396
  // ---------------------------------------------------------------- Intelligence
373
397
  async getBusinessContext() {
398
+ const month = DEMO_NOW.slice(0, 7);
399
+ const today = DEMO_NOW.slice(0, 10);
400
+ const monthInvoices = demoInvoices.filter((invoice) => String(invoice.issueDate).startsWith(month));
401
+ const monthExpenses = demoExpenses.filter((expense) => String(expense.date).startsWith(month));
402
+ // Fixture invoices have no partial payments: paid means settled; drafts are not receivables.
403
+ const unpaid = demoInvoices.filter((invoice) => invoice.status === "sent" || invoice.status === "overdue");
404
+ const overdue = unpaid.filter((invoice) => String(invoice.dueDate) < today);
405
+ const lastInvoice = [...demoInvoices].sort((a, b) => String(b.issueDate).localeCompare(String(a.issueDate)))[0];
406
+ const lastExpense = [...demoExpenses].sort((a, b) => String(b.date).localeCompare(String(a.date)))[0];
407
+ const revenue = demoSum(monthInvoices.filter((invoice) => invoice.status !== "draft" && invoice.status !== "cancelled"), "total");
408
+ const expenses = demoSum(monthExpenses, "amount");
374
409
  return {
375
410
  business: {
376
411
  name: "Demo Studio SL",
@@ -382,39 +417,70 @@ export class DemoFrihetClient {
382
417
  defaults: { taxRate: 21, irpfRate: 15, dueDays: 30, currency: "EUR" },
383
418
  plan: {
384
419
  name: "free",
385
- invoices: { used: 5, limit: 999 },
386
- expenses: { used: demoExpenses.length, limit: 5 },
420
+ invoices: { used: monthInvoices.length, limit: 999 },
421
+ expenses: { used: monthExpenses.length, limit: 5 },
387
422
  aiMessages: { used: 2, limit: 30 },
388
423
  },
389
- series: [{ id: "default", prefix: "F", current: 5, year: 2026 }],
424
+ series: [{ id: "default", prefix: "F", current: demoInvoices.length, year: Number(DEMO_NOW.slice(0, 4)) }],
390
425
  recentActivity: {
391
- lastInvoice: { number: "F-2026-005", date: "2026-07-18", client: "Acme Studio" },
392
- lastExpense: { date: "2026-07-17", vendor: "Demo Supplies", amount: 405.6 },
393
- overdueCount: 1,
394
- overdueAmount: 640,
395
- unpaidCount: 2,
426
+ lastInvoice: lastInvoice
427
+ ? { number: lastInvoice.documentNumber, date: lastInvoice.issueDate, client: lastInvoice.clientName }
428
+ : null,
429
+ lastExpense: lastExpense
430
+ ? { date: lastExpense.date, vendor: lastExpense.vendor, amount: lastExpense.amount }
431
+ : null,
432
+ overdueCount: overdue.length,
433
+ overdueAmount: demoSum(overdue, "total"),
434
+ unpaidCount: unpaid.length,
435
+ coverage: { totalAnalyzed: demoInvoices.length, sampleLimit: 500, isTruncated: false, asOf: today, currency: "EUR", excludedForeignInvoices: 0 },
396
436
  },
397
- topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }],
437
+ topClients: demoTopClients(demoInvoices.filter((invoice) => invoice.status === "paid")),
398
438
  currentMonth: {
399
- revenue: 3960.4,
400
- expenses: 405.6,
401
- profit: 3554.8,
402
- invoiceCount: 5,
403
- expenseCount: demoExpenses.length,
439
+ revenue,
440
+ expenses,
441
+ profit: demoMoney(revenue - expenses),
442
+ invoiceCount: monthInvoices.length,
443
+ expenseCount: monthExpenses.length,
404
444
  },
405
445
  ...READ_STAMP,
406
446
  };
407
447
  }
408
448
  async getMonthlySummary(month) {
449
+ const period = month ?? DEMO_NOW.slice(0, 7);
450
+ const invoices = demoInvoices.filter((invoice) => String(invoice.issueDate).startsWith(`${period}-`));
451
+ const expenses = demoExpenses.filter((expense) => String(expense.date).startsWith(`${period}-`));
452
+ // Match /v1/monthly: drafts count as created, but do not contribute revenue or tax.
453
+ const issued = invoices.filter((invoice) => invoice.status !== "draft" && invoice.status !== "cancelled");
454
+ const deductible = expenses.filter((expense) => expense.taxDeductible !== false);
455
+ const revenue = {
456
+ total: demoSum(issued, "total"), taxBase: demoSum(issued, "subtotal"),
457
+ tax: demoSum(issued, "taxAmount"), irpf: demoSum(issued, "irpfAmount"),
458
+ };
459
+ const expenseTotal = demoSum(expenses, "amount");
460
+ const inputTax = demoSum(deductible, "tax");
461
+ const byCategory = {};
462
+ for (const expense of expenses) {
463
+ const category = String(expense.category ?? "other");
464
+ byCategory[category] = demoMoney((byCategory[category] ?? 0) + Number(expense.amount));
465
+ }
466
+ const vatPayable = demoMoney(revenue.tax - inputTax);
409
467
  return {
410
- period: month ?? "2026-07",
411
- revenue: { total: 3960.4, taxBase: 3273.06, tax: 687.34, irpf: 0 },
412
- expenses: { total: 405.6, deductible: 405.6, tax: 70.4 },
413
- profit: { gross: 3554.8, net: 2937.86 },
414
- invoices: { created: 5, sent: 1, paid: 3, overdue: 1 },
415
- topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }],
416
- byCategory: { supplies: 405.6 },
417
- taxLiability: { vatPayable: 616.94, irpfRetained: 0, estimatedModel303: 616.94 },
468
+ period,
469
+ revenue,
470
+ expenses: { total: expenseTotal, deductible: demoSum(deductible, "amount"), tax: inputTax },
471
+ profit: {
472
+ gross: demoMoney(revenue.total - expenseTotal),
473
+ net: demoMoney(revenue.total - expenseTotal - revenue.tax + inputTax + revenue.irpf),
474
+ },
475
+ invoices: {
476
+ created: invoices.length,
477
+ sent: invoices.filter((invoice) => invoice.status === "sent" || invoice.status === "partial").length,
478
+ paid: invoices.filter((invoice) => invoice.status === "paid").length,
479
+ overdue: invoices.filter((invoice) => invoice.status === "overdue").length,
480
+ },
481
+ topClients: demoTopClients(issued),
482
+ byCategory,
483
+ taxLiability: { vatPayable, irpfRetained: revenue.irpf, estimatedModel303: vatPayable },
418
484
  ...READ_STAMP,
419
485
  };
420
486
  }
@@ -517,7 +583,12 @@ export class DemoFrihetClient {
517
583
  return findOrStub(demoBankAccounts, id);
518
584
  }
519
585
  async listTransactions(params) {
520
- return demoPage(demoTransactions, params);
586
+ const filtered = demoTransactions.filter((row) => (!params?.from || (typeof row.postedAt === "string" && row.postedAt >= params.from))
587
+ && (!params?.to || (typeof row.postedAt === "string" && row.postedAt <= params.to))
588
+ && (!params?.accountId || row.accountId === params.accountId)
589
+ && (!params?.status || row.status === params.status)
590
+ && (!params?.category || row.category === params.category));
591
+ return demoPage(filtered, params, ["postedAt", "id"]);
521
592
  }
522
593
  async categorizeTransaction(id, data) {
523
594
  return simulateAction(id, { category: data.category, ...(data.notes ? { notes: data.notes } : {}) });
@@ -31,11 +31,11 @@ export declare const DEMO_NOW = "2026-07-15T10:00:00.000Z";
31
31
  /** Test IBAN (ECBS/AEAT example — never a real account). */
32
32
  export declare const DEMO_TEST_IBAN = "ES9121000418450200051332";
33
33
  export type DemoPaginated = PaginatedResponse<Record<string, unknown>>;
34
- /** Wrap a fixture array in a PaginatedResponse. */
35
- export declare function demoPage(data: Record<string, unknown>[], params?: {
34
+ /** Page fixtures after filtering; opt-in string keys sort descending on a copy. */
35
+ export declare function demoPage(data: readonly Record<string, unknown>[], params?: {
36
36
  limit?: number;
37
37
  offset?: number;
38
- }): DemoPaginated;
38
+ }, descendingKeys?: readonly string[]): DemoPaginated;
39
39
  /** Empty PaginatedResponse for surfaces without dedicated fixtures. */
40
40
  export declare function demoEmptyPage(params?: {
41
41
  limit?: number;
@@ -43,15 +43,25 @@ export declare function demoEmptyPage(params?: {
43
43
  }): DemoPaginated;
44
44
  export declare const demoClients: Record<string, unknown>[];
45
45
  export declare const demoProducts: Record<string, unknown>[];
46
+ export declare function demoMoney(n: number): number;
46
47
  export declare const demoInvoices: Record<string, unknown>[];
48
+ /** Aggregate stored fixture amounts; never recalculate invoice taxes here. */
49
+ export declare function demoSum(records: Record<string, unknown>[], field: string): number;
50
+ /** Rank only the invoices selected by the caller (paid or issued in a month). */
51
+ export declare function demoTopClients(invoices: Record<string, unknown>[]): {
52
+ name: string;
53
+ totalRevenue: number;
54
+ invoiceCount: number;
55
+ }[];
47
56
  export declare const demoExpenses: Record<string, unknown>[];
48
57
  export declare const demoQuotes: Record<string, unknown>[];
49
58
  export declare const demoVendors: Record<string, unknown>[];
59
+ export declare const demoPurchaseOrders: Record<string, unknown>[];
50
60
  export declare const demoBankAccounts: Record<string, unknown>[];
51
61
  export declare const demoTransactions: Record<string, unknown>[];
52
62
  /** Find a fixture by id, or synthesize a minimal stamped object with that id. */
53
63
  export declare function findOrStub(collection: Record<string, unknown>[], id: string): Record<string, unknown> & DemoStamp;
54
- /** Deterministic-ish demo id (no network, no clock dependency). */
64
+ /** Simulated ids cannot alias read-only fixtures (no network or clock dependency). */
55
65
  export declare function demoId(prefix: string): string;
56
66
  /**
57
67
  * Simulate a create/update: echo the input, stamp a fabricated demo id +
@@ -36,12 +36,21 @@ export const EMPTY_STAMP = {
36
36
  export const DEMO_NOW = "2026-07-15T10:00:00.000Z";
37
37
  /** Test IBAN (ECBS/AEAT example — never a real account). */
38
38
  export const DEMO_TEST_IBAN = "ES9121000418450200051332";
39
- /** Wrap a fixture array in a PaginatedResponse. */
40
- export function demoPage(data, params) {
39
+ /** Page fixtures after filtering; opt-in string keys sort descending on a copy. */
40
+ export function demoPage(data, params, descendingKeys) {
41
41
  const limit = params?.limit ?? 50;
42
42
  const offset = params?.offset ?? 0;
43
+ const ordered = descendingKeys?.length ? [...data].sort((a, b) => {
44
+ for (const key of descendingKeys) {
45
+ const left = String(a[key] ?? "");
46
+ const right = String(b[key] ?? "");
47
+ if (left !== right)
48
+ return left < right ? 1 : -1;
49
+ }
50
+ return 0;
51
+ }) : data;
43
52
  return {
44
- data: data.slice(offset, offset + limit),
53
+ data: ordered.slice(offset, offset + limit),
45
54
  total: data.length,
46
55
  limit,
47
56
  offset,
@@ -124,16 +133,17 @@ export const demoProducts = [
124
133
  { id: "demo_prd_007", name: "Menú catering (por persona)", unitPrice: 18, taxRate: 10, description: "Servicio de catering, IVA reducido hostelería", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
125
134
  { id: "demo_prd_008", name: "Libro técnico impreso", unitPrice: 24, taxRate: 4, description: "Publicación impresa, IVA superreducido", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
126
135
  ];
127
- function money(n) {
136
+ export function demoMoney(n) {
128
137
  return Math.round(n * 100) / 100;
129
138
  }
130
139
  function invoice(opts) {
131
- const subtotal = money(opts.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0));
132
- const taxAmount = money(subtotal * (opts.taxRate / 100));
133
- const irpfAmount = opts.irpfRate ? money(subtotal * (opts.irpfRate / 100)) : 0;
134
- const total = money(subtotal + taxAmount - irpfAmount);
140
+ const subtotal = demoMoney(opts.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0));
141
+ const taxAmount = demoMoney(subtotal * (opts.taxRate / 100));
142
+ const irpfAmount = opts.irpfRate ? demoMoney(subtotal * (opts.irpfRate / 100)) : 0;
143
+ const total = demoMoney(subtotal + taxAmount - irpfAmount);
135
144
  return {
136
145
  id: opts.id,
146
+ documentNumber: `F-${opts.issueDate.slice(0, 4)}-${opts.id.split("_").at(-1)}`,
137
147
  clientId: opts.clientId,
138
148
  clientName: opts.clientName,
139
149
  items: opts.items,
@@ -163,16 +173,29 @@ export const demoInvoices = [
163
173
  invoice({ id: "demo_inv_008", clientId: "demo_cli_003", clientName: "Servicios Insulares Canarias SL", items: [{ description: "Mantenimiento mensual", quantity: 3, unitPrice: 150 }], taxRate: 7, status: "overdue", issueDate: "2026-04-05", dueDate: "2026-05-05", notes: "IGIC 7% — factura vencida", clientLocation: "canarias" }),
164
174
  invoice({ id: "demo_inv_009", clientId: "demo_cli_004", clientName: "Marina Delgado (autónoma)", items: [{ description: "Diseño de marca", quantity: 1, unitPrice: 1200 }, { description: "Auditoría SEO", quantity: 1, unitPrice: 600 }], taxRate: 21, status: "draft", issueDate: "2026-07-08", dueDate: "2026-08-08", irpfRate: 15, notes: "Autónoma con retención IRPF 15%", clientLocation: "peninsula" }),
165
175
  ];
176
+ /** Aggregate stored fixture amounts; never recalculate invoice taxes here. */
177
+ export function demoSum(records, field) {
178
+ return demoMoney(records.reduce((sum, record) => sum + Number(record[field] ?? 0), 0));
179
+ }
180
+ /** Rank only the invoices selected by the caller (paid or issued in a month). */
181
+ export function demoTopClients(invoices) {
182
+ return demoClients.map((client) => {
183
+ const matched = invoices.filter((invoice) => invoice.clientId === client.id);
184
+ return { name: String(client.name), totalRevenue: demoSum(matched, "total"), invoiceCount: matched.length };
185
+ }).filter((client) => client.invoiceCount > 0)
186
+ .sort((a, b) => b.totalRevenue - a.totalRevenue)
187
+ .slice(0, 5);
188
+ }
166
189
  /* ------------------------------------------------------------------ */
167
190
  /* Expenses (6) — categorized */
168
191
  /* ------------------------------------------------------------------ */
169
192
  export const demoExpenses = [
170
- { id: "demo_exp_001", description: "Suscripción software de diseño", amount: 15.4, category: "software", date: "2026-07-01", vendor: "Herramientas Digitales SL", taxDeductible: true, createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
171
- { id: "demo_exp_002", description: "Material de oficina", amount: 48.2, category: "office_supplies", date: "2026-06-18", vendor: "Papelería Central", taxDeductible: true, createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
172
- { id: "demo_exp_003", description: "Comida de trabajo con cliente", amount: 62.0, category: "meals", date: "2026-06-22", vendor: "Restaurante El Puerto", taxDeductible: true, createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
173
- { id: "demo_exp_004", description: "Combustible desplazamiento", amount: 70.0, category: "travel", date: "2026-06-25", vendor: "Estación de Servicio Norte", taxDeductible: true, createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
174
- { id: "demo_exp_005", description: "Cuota gestoría mensual", amount: 90.0, category: "professional_services", date: "2026-07-01", vendor: "Gestoría Ramírez", taxDeductible: true, createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
175
- { id: "demo_exp_006", description: "Hosting anual", amount: 120.0, category: "software", date: "2026-05-30", vendor: "Proveedor Cloud SL", taxDeductible: true, createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
193
+ { id: "demo_exp_001", description: "Suscripción software de diseño", amount: 15.4, category: "software", date: "2026-07-01", vendor: "Herramientas Digitales SL", taxDeductible: true, currency: "EUR", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
194
+ { id: "demo_exp_002", description: "Material de oficina", amount: 48.2, category: "office_supplies", date: "2026-06-18", vendor: "Papelería Central", taxDeductible: true, currency: "EUR", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
195
+ { id: "demo_exp_003", description: "Comida de trabajo con cliente", amount: 62.0, category: "meals", date: "2026-06-22", vendor: "Restaurante El Puerto", taxDeductible: true, currency: "EUR", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
196
+ { id: "demo_exp_004", description: "Combustible desplazamiento", amount: 70.0, category: "travel", date: "2026-06-25", vendor: "Estación de Servicio Norte", taxDeductible: true, currency: "EUR", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
197
+ { id: "demo_exp_005", description: "Cuota gestoría mensual", amount: 90.0, category: "professional_services", date: "2026-07-01", vendor: "Gestoría Ramírez", taxDeductible: true, currency: "EUR", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
198
+ { id: "demo_exp_006", description: "Hosting anual", amount: 120.0, category: "software", date: "2026-05-30", vendor: "Proveedor Cloud SL", taxDeductible: true, currency: "EUR", createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
176
199
  ];
177
200
  /* ------------------------------------------------------------------ */
178
201
  /* Quotes (2) */
@@ -218,6 +241,51 @@ export const demoVendors = [
218
241
  { id: "demo_ven_002", name: "Proveedor Cloud SL", email: "facturacion@example.com", phone: "+34 600 000 011", taxId: "B22222222", address: { city: "Barcelona", postalCode: "08007", country: "ES" }, createdAt: DEMO_NOW, updatedAt: DEMO_NOW },
219
242
  ];
220
243
  /* ------------------------------------------------------------------ */
244
+ /* Purchase orders (2) — ADR 0027 S2 read-only surface. */
245
+ /* Same vendor ids as demoVendors so list+get read coherently. */
246
+ /* ------------------------------------------------------------------ */
247
+ export const demoPurchaseOrders = [
248
+ {
249
+ id: "demo_po_001",
250
+ poNumber: "PO-2026-0001",
251
+ vendorId: "demo_ven_001",
252
+ vendorName: "Gestoría Ramírez",
253
+ status: "sent",
254
+ items: [
255
+ { description: "Asesoria contable mensual", quantity: 1, unitPrice: 350, productId: "demo_prod_001" },
256
+ ],
257
+ issueDate: "2026-09-01",
258
+ expectedDate: "2026-10-01",
259
+ taxRate: 21,
260
+ subtotal: 350,
261
+ tax: 73.5,
262
+ total: 423.5,
263
+ currency: "EUR",
264
+ notes: "Renovacion contrato asesoria",
265
+ createdAt: DEMO_NOW,
266
+ updatedAt: DEMO_NOW,
267
+ },
268
+ {
269
+ id: "demo_po_002",
270
+ poNumber: "PO-2026-0002",
271
+ vendorId: "demo_ven_002",
272
+ vendorName: "Proveedor Cloud SL",
273
+ status: "draft",
274
+ items: [
275
+ { description: "Hosting VPS 4 vCPU", quantity: 12, unitPrice: 45, productId: "demo_prod_002" },
276
+ { description: "Backup storage 500 GB", quantity: 1, unitPrice: 80 },
277
+ ],
278
+ issueDate: "2026-09-15",
279
+ taxRate: 21,
280
+ subtotal: 620,
281
+ tax: 130.2,
282
+ total: 750.2,
283
+ currency: "EUR",
284
+ createdAt: DEMO_NOW,
285
+ updatedAt: DEMO_NOW,
286
+ },
287
+ ];
288
+ /* ------------------------------------------------------------------ */
221
289
  /* Banking — 1 account + 4 transactions */
222
290
  /* ------------------------------------------------------------------ */
223
291
  export const demoBankAccounts = [
@@ -250,10 +318,10 @@ export function findOrStub(collection, id) {
250
318
  return { id, ...READ_STAMP };
251
319
  }
252
320
  let demoIdCounter = 0;
253
- /** Deterministic-ish demo id (no network, no clock dependency). */
321
+ /** Simulated ids cannot alias read-only fixtures (no network or clock dependency). */
254
322
  export function demoId(prefix) {
255
323
  demoIdCounter += 1;
256
- return `${prefix}_${String(demoIdCounter).padStart(3, "0")}`;
324
+ return `${prefix}_simulated_${String(demoIdCounter).padStart(3, "0")}`;
257
325
  }
258
326
  /**
259
327
  * Simulate a create/update: echo the input, stamp a fabricated demo id +