@frihet/mcp-server 2.0.0 → 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.
@@ -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
@@ -164,6 +164,7 @@ const SAFE_META_FLAGS = [
164
164
  "io.frihet/transportOutcomeUnknown", "io.frihet/retryable", "io.frihet/operationMayHaveCompleted",
165
165
  "io.frihet/resourceNotFound", "io.frihet/accessDenied",
166
166
  "io.frihet/confirmationRequired", "io.frihet/localValidationRejected", "io.frihet/requestRejected",
167
+ "io.frihet/searchScanTruncated", "io.frihet/cursorWithQueryUnsupported",
167
168
  ];
168
169
  /** Claude does not expose opaque tool metadata as an actionable error message.
169
170
  * Render fixed text from the typed flags, never provider text, codes or IDs.
@@ -185,6 +186,12 @@ function claudeErrorText(meta) {
185
186
  if (meta["io.frihet/resourceNotFound"] === true) {
186
187
  return "NOT_FOUND: No record is available with this ID in the authorized workspace. Check the ID; after deletion, verify absence with a list. Do not automatically repeat a write.";
187
188
  }
189
+ if (meta["io.frihet/cursorWithQueryUnsupported"] === true) {
190
+ return "CURSOR_WITH_QUERY_UNSUPPORTED: Text search ('q') cannot be combined with cursor pagination ('after') because the ERP backend slices search results by 'offset'/'limit' and ignores 'after'. No action was performed. Use 'offset' and 'limit' when searching with 'q', or omit 'q' when paginating with 'after'.";
191
+ }
192
+ if (meta["io.frihet/searchScanTruncated"] === true) {
193
+ return "SEARCH_SCAN_TRUNCATED: The text search hit the ERP 500-document scan cap (truncated=true) and was withheld so an incomplete scan is never mistaken for an exhaustive list. Narrow 'q' with a more specific term or paginate without 'q' using 'after'.";
194
+ }
188
195
  if (meta["io.frihet/localValidationRejected"] === true) {
189
196
  return "VALIDATION_FAILED: The ID or input parameters were rejected before sending the request. No action was performed. Correct the ID and parameters before retrying.";
190
197
  }
@@ -203,6 +210,8 @@ export function claudeToolResult(result, toolName) {
203
210
  const fixedRemediation = error && (safeMeta["io.frihet/confirmationRequired"] === true
204
211
  || safeMeta["io.frihet/localValidationRejected"] === true
205
212
  || safeMeta["io.frihet/requestRejected"] === true
213
+ || safeMeta["io.frihet/cursorWithQueryUnsupported"] === true
214
+ || safeMeta["io.frihet/searchScanTruncated"] === true
206
215
  || safeMeta["io.frihet/operationOutcomeUnknown"] === true
207
216
  || safeMeta["io.frihet/transportOutcomeUnknown"] === true
208
217
  || safeMeta["io.frihet/operationMayHaveCompleted"] === true);
@@ -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.").
@@ -22,10 +22,8 @@ import type { CreateWebhookInput, CreateWebhookResult, PaginatedResponse, Update
22
22
  import { type BinaryDocument, type EInvoiceDocument, type EInvoiceExportResult, type McpEInvoiceExportFormat } from "./client.js";
23
23
  type Rec = Record<string, unknown>;
24
24
  export declare class DemoFrihetClient implements IFrihetClient {
25
- listInvoices(params?: {
26
- limit?: number;
27
- offset?: number;
28
- }): Promise<PaginatedResponse<Rec>>;
25
+ readonly isDemoClient: true;
26
+ listInvoices(params?: Parameters<IFrihetClient["listInvoices"]>[0]): Promise<PaginatedResponse<Rec>>;
29
27
  getInvoice(id: string): Promise<Rec>;
30
28
  createInvoice(data: Rec): Promise<Rec>;
31
29
  updateInvoice(id: string, data: Rec): Promise<Rec>;
@@ -34,10 +32,7 @@ export declare class DemoFrihetClient implements IFrihetClient {
34
32
  limit?: number;
35
33
  offset?: number;
36
34
  }): Promise<PaginatedResponse<Rec>>;
37
- listExpenses(params?: {
38
- limit?: number;
39
- offset?: number;
40
- }): Promise<PaginatedResponse<Rec>>;
35
+ listExpenses(params?: Parameters<IFrihetClient["listExpenses"]>[0]): Promise<PaginatedResponse<Rec>>;
41
36
  getExpense(id: string): Promise<Rec>;
42
37
  createExpense(data: Rec): Promise<Rec>;
43
38
  updateExpense(id: string, data: Rec): Promise<Rec>;
@@ -74,6 +69,13 @@ export declare class DemoFrihetClient implements IFrihetClient {
74
69
  createVendor(data: Rec): Promise<Rec>;
75
70
  updateVendor(id: string, data: Rec): Promise<Rec>;
76
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>;
77
79
  sendInvoice(id: string, to?: string): Promise<Rec>;
78
80
  markInvoicePaid(id: string, paidDate?: string): Promise<Rec>;
79
81
  getInvoicePdf(id: string): Promise<BinaryDocument>;
@@ -270,10 +272,7 @@ export declare class DemoFrihetClient implements IFrihetClient {
270
272
  offset?: number;
271
273
  }): Promise<PaginatedResponse<Rec>>;
272
274
  getBankAccount(id: string): Promise<Rec>;
273
- listTransactions(params?: {
274
- limit?: number;
275
- offset?: number;
276
- }): Promise<PaginatedResponse<Rec>>;
275
+ listTransactions(params?: Parameters<IFrihetClient["listTransactions"]>[0]): Promise<PaginatedResponse<Rec>>;
277
276
  categorizeTransaction(id: string, data: {
278
277
  category: string;
279
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",
@@ -75,9 +75,15 @@ const DEMO_OWNER_CAPABILITIES = Object.entries(DEMO_PERMISSION_MATRIX.owner)
75
75
  .flatMap(([resource, actions]) => actions.map((action) => `${resource}:${action}`))
76
76
  .sort();
77
77
  export class DemoFrihetClient {
78
+ isDemoClient = true;
78
79
  // ---------------------------------------------------------------- Invoices
79
80
  async listInvoices(params) {
80
- 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"]);
81
87
  }
82
88
  async getInvoice(id) {
83
89
  return findOrStub(demoInvoices, id);
@@ -108,7 +114,11 @@ export class DemoFrihetClient {
108
114
  }
109
115
  // ---------------------------------------------------------------- Expenses
110
116
  async listExpenses(params) {
111
- 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"]);
112
122
  }
113
123
  async getExpense(id) {
114
124
  return findOrStub(demoExpenses, id);
@@ -186,6 +196,21 @@ export class DemoFrihetClient {
186
196
  async deleteVendor(_id) {
187
197
  return;
188
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
+ }
189
214
  // ---------------------------------------------------------------- Invoice actions
190
215
  async sendInvoice(id, to) {
191
216
  // Email dispatch — simulated, no email is actually sent.
@@ -370,6 +395,17 @@ export class DemoFrihetClient {
370
395
  }
371
396
  // ---------------------------------------------------------------- Intelligence
372
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");
373
409
  return {
374
410
  business: {
375
411
  name: "Demo Studio SL",
@@ -381,39 +417,70 @@ export class DemoFrihetClient {
381
417
  defaults: { taxRate: 21, irpfRate: 15, dueDays: 30, currency: "EUR" },
382
418
  plan: {
383
419
  name: "free",
384
- invoices: { used: 5, limit: 999 },
385
- expenses: { used: demoExpenses.length, limit: 5 },
420
+ invoices: { used: monthInvoices.length, limit: 999 },
421
+ expenses: { used: monthExpenses.length, limit: 5 },
386
422
  aiMessages: { used: 2, limit: 30 },
387
423
  },
388
- 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)) }],
389
425
  recentActivity: {
390
- lastInvoice: { number: "F-2026-005", date: "2026-07-18", client: "Acme Studio" },
391
- lastExpense: { date: "2026-07-17", vendor: "Demo Supplies", amount: 405.6 },
392
- overdueCount: 1,
393
- overdueAmount: 640,
394
- 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 },
395
436
  },
396
- topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }],
437
+ topClients: demoTopClients(demoInvoices.filter((invoice) => invoice.status === "paid")),
397
438
  currentMonth: {
398
- revenue: 3960.4,
399
- expenses: 405.6,
400
- profit: 3554.8,
401
- invoiceCount: 5,
402
- expenseCount: demoExpenses.length,
439
+ revenue,
440
+ expenses,
441
+ profit: demoMoney(revenue - expenses),
442
+ invoiceCount: monthInvoices.length,
443
+ expenseCount: monthExpenses.length,
403
444
  },
404
445
  ...READ_STAMP,
405
446
  };
406
447
  }
407
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);
408
467
  return {
409
- period: month ?? "2026-07",
410
- revenue: { total: 3960.4, taxBase: 3273.06, tax: 687.34, irpf: 0 },
411
- expenses: { total: 405.6, deductible: 405.6, tax: 70.4 },
412
- profit: { gross: 3554.8, net: 2937.86 },
413
- invoices: { created: 5, sent: 1, paid: 3, overdue: 1 },
414
- topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }],
415
- byCategory: { supplies: 405.6 },
416
- 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 },
417
484
  ...READ_STAMP,
418
485
  };
419
486
  }
@@ -516,7 +583,12 @@ export class DemoFrihetClient {
516
583
  return findOrStub(demoBankAccounts, id);
517
584
  }
518
585
  async listTransactions(params) {
519
- 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"]);
520
592
  }
521
593
  async categorizeTransaction(id, data) {
522
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 +