financy 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ CliError,
4
+ EXIT,
5
+ VERSION,
6
+ createDraft,
7
+ fetchAccount,
8
+ fetchAccounts,
9
+ fetchBranches,
10
+ fetchCategories,
11
+ fetchConnection,
12
+ fetchConnections,
13
+ fetchIntegration,
14
+ fetchIntegrations,
15
+ fetchInvoicingCustomer,
16
+ fetchInvoicingCustomers,
17
+ fetchInvoicingDocument,
18
+ fetchInvoicingDocuments,
19
+ fetchProposal,
20
+ fetchProposals,
21
+ fetchProviders,
22
+ fetchReceivables,
23
+ fetchRefresh,
24
+ fetchStatus,
25
+ fetchTransaction,
26
+ fetchTransactions,
27
+ loadConfig,
28
+ notConfigured
29
+ } from "./chunk-LZSZIBYL.js";
30
+
31
+ // src/mcp/server.ts
32
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
33
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
34
+ import {
35
+ ListToolsRequestSchema,
36
+ CallToolRequestSchema
37
+ } from "@modelcontextprotocol/sdk/types.js";
38
+
39
+ // src/mcp/tools.ts
40
+ var str = (v) => v == null ? void 0 : String(v);
41
+ var bool = (v) => v === true;
42
+ function requireId(args) {
43
+ const id = args.id;
44
+ if (typeof id !== "string" || id.length === 0) {
45
+ throw new CliError(EXIT.USAGE, "MISSING_ARGUMENT", "the `id` argument is required");
46
+ }
47
+ return id;
48
+ }
49
+ var PAGING = {
50
+ limit: { type: "number", description: "page size (defaults to the API default)" },
51
+ cursor: { type: "string", description: "opaque nextPage cursor from a prior call" },
52
+ all: { type: "boolean", description: "auto-paginate to the end (ignores cursor/limit paging)" }
53
+ };
54
+ var NO_ARGS = { type: "object", properties: {} };
55
+ var idSchema = (noun) => ({
56
+ type: "object",
57
+ properties: { id: { type: "string", description: `the ${noun} id` } },
58
+ required: ["id"]
59
+ });
60
+ var listArgs = (a) => ({
61
+ limit: str(a.limit),
62
+ cursor: str(a.cursor),
63
+ all: bool(a.all)
64
+ });
65
+ var TOOLS = [
66
+ {
67
+ name: "list_connections",
68
+ description: "List the user's bank/card connections and their fetch state (status, data-through date, consent expiry, per-type counts). Start here to see what is connected. Supports limit/cursor paging and all.",
69
+ inputSchema: { type: "object", properties: { ...PAGING } },
70
+ run: (a, ctx) => fetchConnections(ctx.config, ctx.now, listArgs(a))
71
+ },
72
+ {
73
+ name: "get_connection",
74
+ description: "Read a single connection by id (from list_connections). Errors CONNECTION_NOT_FOUND if it is not in the user's scope.",
75
+ inputSchema: idSchema("connection"),
76
+ run: (a, ctx) => fetchConnection(ctx.config, ctx.now, requireId(a))
77
+ },
78
+ {
79
+ name: "list_accounts",
80
+ description: "List accounts with balances (securities embedded). Filter by type (CHECKING|CARD|LOAN|SAVINGS|SECURITY) or connection. Supports limit/cursor/all.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ type: { type: "string", description: "CHECKING|CARD|LOAN|SAVINGS|SECURITY" },
85
+ connection: { type: "string", description: "filter to one connection id" },
86
+ ...PAGING
87
+ }
88
+ },
89
+ run: (a, ctx) => fetchAccounts(ctx.config, ctx.now, {
90
+ ...listArgs(a),
91
+ type: str(a.type),
92
+ connection: str(a.connection)
93
+ })
94
+ },
95
+ {
96
+ name: "get_account",
97
+ description: "Read a single account by id (from list_accounts). Errors ACCOUNT_NOT_FOUND if not in scope.",
98
+ inputSchema: idSchema("account"),
99
+ run: (a, ctx) => fetchAccount(ctx.config, ctx.now, requireId(a))
100
+ },
101
+ {
102
+ name: "list_transactions",
103
+ description: "List transactions, most useful with filters: from/to (YYYY-MM-DD), account, connection, type (BANK|CARD). Page with limit/cursor or fetch everything with all. Hebrew merchant names are returned as-is. To then read one transaction, pass its `SK` (not its `id`) to get_transaction.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ from: { type: "string", description: "earliest transaction date (YYYY-MM-DD)" },
108
+ to: { type: "string", description: "latest transaction date (YYYY-MM-DD)" },
109
+ account: { type: "string", description: "filter to one account id" },
110
+ connection: { type: "string", description: "filter to one connection id" },
111
+ type: { type: "string", description: "BANK|CARD" },
112
+ ...PAGING
113
+ }
114
+ },
115
+ run: (a, ctx) => fetchTransactions(ctx.config, ctx.now, {
116
+ ...listArgs(a),
117
+ from: str(a.from),
118
+ to: str(a.to),
119
+ account: str(a.account),
120
+ connection: str(a.connection),
121
+ type: str(a.type)
122
+ })
123
+ },
124
+ {
125
+ name: "get_transaction",
126
+ description: "Read a single transaction by its `SK` \u2014 the composite key from list_transactions, NOT the short `id` field, which this route does not accept. Errors TRANSACTION_NOT_FOUND if not in scope.",
127
+ inputSchema: {
128
+ type: "object",
129
+ properties: {
130
+ id: {
131
+ type: "string",
132
+ description: 'the transaction\'s `SK` from list_transactions, e.g. "TRANSACTION#TYPE#CHECKING#PROVIDER#leumi#RESOURCE<uuid>#<ulid>". The short `id` field of the same transaction will NOT resolve.'
133
+ }
134
+ },
135
+ required: ["id"]
136
+ },
137
+ run: (a, ctx) => fetchTransaction(ctx.config, ctx.now, requireId(a))
138
+ },
139
+ {
140
+ name: "list_categories",
141
+ description: "The static transaction-category taxonomy (English + Hebrew). Use to interpret transaction category fields. No arguments.",
142
+ inputSchema: NO_ARGS,
143
+ run: async (_a, ctx) => ({ data: await fetchCategories(ctx.config, ctx.now) })
144
+ },
145
+ {
146
+ name: "list_providers",
147
+ description: "Reference data: the list of banks/providers, to resolve providerId values. No arguments.",
148
+ inputSchema: NO_ARGS,
149
+ run: (_a, ctx) => fetchProviders(ctx.config, ctx.now)
150
+ },
151
+ {
152
+ name: "list_bank_branches",
153
+ description: "Reference data: bank branches. No arguments (this route takes no paging).",
154
+ inputSchema: NO_ARGS,
155
+ run: (_a, ctx) => fetchBranches(ctx.config, ctx.now)
156
+ },
157
+ {
158
+ name: "get_status",
159
+ description: "Per-connection freshness rollup: for each connection its status, data-through date, consent expiry, and account count, plus staleThresholdDays. Call this first to decide whether data is fresh enough before answering questions about balances or transactions.",
160
+ inputSchema: NO_ARGS,
161
+ run: async (_a, ctx) => {
162
+ const { data, staleThresholdDays } = await fetchStatus(ctx.config, ctx.now);
163
+ return { data, staleThresholdDays };
164
+ }
165
+ },
166
+ {
167
+ name: "refresh_connections",
168
+ description: "Trigger an on-demand refresh of ALL connections (org-wide). This COSTS 20 credits \u2014 always confirm with the user before calling it. Data lands after a few minutes; poll get_status to watch progress. Returns the refresh outcome (accepted / already_running).",
169
+ inputSchema: NO_ARGS,
170
+ run: (_a, ctx) => fetchRefresh(ctx.config, ctx.now)
171
+ }
172
+ ];
173
+ var BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
174
+ function errorEnvelope(error) {
175
+ const e = error instanceof CliError ? error : new CliError(EXIT.UNEXPECTED, "UNEXPECTED", String(error?.message ?? error));
176
+ return { error: { code: e.code, message: e.message, ...e.extra } };
177
+ }
178
+ async function dispatchTool(byName, name, args, io) {
179
+ try {
180
+ const tool = byName.get(name);
181
+ if (!tool) {
182
+ throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
183
+ }
184
+ let config = io.config;
185
+ if (!config) {
186
+ const result = await loadConfig(io.env);
187
+ if (!result.ok) throw notConfigured(result);
188
+ config = result.config;
189
+ }
190
+ return await tool.run(args ?? {}, { config, now: io.now ?? /* @__PURE__ */ new Date() });
191
+ } catch (error) {
192
+ return errorEnvelope(error);
193
+ }
194
+ }
195
+
196
+ // src/mcp/integration-tools.ts
197
+ var str2 = (v) => v == null ? void 0 : String(v);
198
+ var bool2 = (v) => v === true;
199
+ function required(args, key) {
200
+ const value = args[key];
201
+ if (typeof value !== "string" || value.length === 0) {
202
+ throw new CliError(EXIT.USAGE, "MISSING_ARGUMENT", `the \`${key}\` argument is required`);
203
+ }
204
+ return value;
205
+ }
206
+ function selected(args, keys) {
207
+ return Object.fromEntries(keys.filter((key) => args[key] !== void 0).map((key) => [key, args[key]]));
208
+ }
209
+ function paymentOf(args) {
210
+ return {
211
+ method: args.paymentMethod,
212
+ date: args.paymentDate,
213
+ ...args.paymentReference === void 0 ? {} : { reference: args.paymentReference }
214
+ };
215
+ }
216
+ var AMOUNTS = "Amounts are integers in minor units of their currency (agorot for ILS).";
217
+ var DRAFT = "Create a document DRAFT. Nothing is issued by this call: the user must approve the draft in Financy at the returned approvalUrl, and only then is the document issued in their business system. Show the user the approvalUrl. " + AMOUNTS;
218
+ var PAGING_NOTE = "A page shorter than limit with a non-null nextPage means more may match: call again with that cursor, or pass all.";
219
+ var integrationId = { type: "string", description: "an integration id (intg_\u2026) from list_integrations" };
220
+ var documentId = { type: "string", description: "a document id (idoc_\u2026) from list_invoicing_documents" };
221
+ var counterpartyId = { type: "string", description: "a customer id (icus_\u2026) from list_invoicing_customers" };
222
+ var proposalId = { type: "string", description: "a proposal id (prop_\u2026) returned by a draft tool" };
223
+ var localDate = (description) => ({ type: "string", description });
224
+ var PAGING2 = {
225
+ limit: { type: "number", description: "page size (1\u2013200)" },
226
+ cursor: { type: "string", description: "opaque nextPage cursor from a prior call" },
227
+ all: { type: "boolean", description: "auto-paginate to the end (ignores cursor/limit paging)" }
228
+ };
229
+ var recipients = {
230
+ type: "array",
231
+ items: { type: "string" },
232
+ description: "up to three email recipients; absent or empty sends no email"
233
+ };
234
+ var paymentProperties = {
235
+ paymentMethod: { type: "string", enum: ["bank_transfer", "cash", "cheque", "card", "other"] },
236
+ paymentDate: localDate("payment date, YYYY-MM-DD"),
237
+ paymentReference: { type: "string", description: "e.g. a transfer reference" }
238
+ };
239
+ var invoiceProperties = {
240
+ integrationId,
241
+ counterpartyId,
242
+ lines: {
243
+ type: "array",
244
+ description: "1\u201320 lines",
245
+ items: {
246
+ type: "object",
247
+ properties: {
248
+ description: { type: "string" },
249
+ quantity: { type: "integer" },
250
+ unitPriceMinor: { type: "integer" }
251
+ },
252
+ required: ["description", "quantity", "unitPriceMinor"]
253
+ }
254
+ },
255
+ pricesIncludeVat: { type: "boolean" },
256
+ dueDate: localDate("due date, YYYY-MM-DD"),
257
+ recipients
258
+ };
259
+ var invoiceRequired = ["integrationId", "counterpartyId", "lines", "pricesIncludeVat"];
260
+ var paymentRequired = ["paymentMethod", "paymentDate"];
261
+ var invoiceItemKeys = ["counterpartyId", "lines", "pricesIncludeVat", "dueDate", "recipients"];
262
+ var amountMinor = { type: "integer", description: "amount in minor units; omit for the full remaining amount" };
263
+ var schema = (properties, req) => ({
264
+ type: "object",
265
+ properties,
266
+ ...req ? { required: req } : {}
267
+ });
268
+ var draft = (a, ctx, body) => createDraft(ctx.config, ctx.now, required(a, "integrationId"), body);
269
+ var INTEGRATION_TOOLS = [
270
+ {
271
+ name: "list_integrations",
272
+ description: "List the business systems (invoicing software such as Invoice4U) connected to Financy, with each one's connection health and sync state. Start here to find an integrationId.",
273
+ inputSchema: schema({}),
274
+ run: (_a, ctx) => fetchIntegrations(ctx.config, ctx.now)
275
+ },
276
+ {
277
+ name: "get_integration",
278
+ description: "Get one connected business system: connection health, per-stream sync state and data freshness.",
279
+ inputSchema: schema({ integrationId }, ["integrationId"]),
280
+ run: (a, ctx) => fetchIntegration(ctx.config, ctx.now, required(a, "integrationId"))
281
+ },
282
+ {
283
+ name: "get_receivables",
284
+ description: `Who owes the business money: open receivables across connected business systems, with totals and aging buckets per currency and per customer. Each integration entry says how fresh its data is and whether its first sync is still running. ${AMOUNTS}`,
285
+ inputSchema: schema({
286
+ integrationIds: { type: "array", items: integrationId, description: "limit to these integrations (default: all)" },
287
+ format: { type: "string", enum: ["concise", "detailed"], description: "detailed also lists each customer's open documents" },
288
+ asOf: localDate("the date to age receivables against, YYYY-MM-DD (default today)")
289
+ }),
290
+ run: (a, ctx) => fetchReceivables(ctx.config, ctx.now, {
291
+ integrations: Array.isArray(a.integrationIds) ? a.integrationIds.map(String) : void 0,
292
+ detailed: a.format === "detailed",
293
+ asOf: str2(a.asOf)
294
+ })
295
+ },
296
+ {
297
+ name: "list_invoicing_documents",
298
+ description: `List documents (invoices, receipts, credit notes, proformas and others) of one connected business system, filtered by status, type, customer or issue date. ${PAGING_NOTE} ${AMOUNTS}`,
299
+ inputSchema: schema(
300
+ {
301
+ integrationId,
302
+ status: { type: "string", enum: ["open", "partially_paid", "paid", "partially_credited", "credited", "cancelled", "closed", "unknown"] },
303
+ type: { type: "string", enum: ["invoice", "receipt", "invoice_receipt", "credit_note", "proforma", "quote", "order", "delivery_note", "other"] },
304
+ counterpartyId,
305
+ issuedFrom: localDate("issued on or after, YYYY-MM-DD"),
306
+ issuedTo: localDate("issued on or before, YYYY-MM-DD"),
307
+ receivableOnly: { type: "boolean", description: "only documents that are owed money" },
308
+ ...PAGING2
309
+ },
310
+ ["integrationId"]
311
+ ),
312
+ run: (a, ctx) => fetchInvoicingDocuments(ctx.config, ctx.now, required(a, "integrationId"), {
313
+ status: str2(a.status),
314
+ type: str2(a.type),
315
+ customer: str2(a.counterpartyId),
316
+ from: str2(a.issuedFrom),
317
+ to: str2(a.issuedTo),
318
+ receivableOnly: bool2(a.receivableOnly),
319
+ limit: str2(a.limit),
320
+ cursor: str2(a.cursor),
321
+ all: bool2(a.all)
322
+ })
323
+ },
324
+ {
325
+ name: "get_invoicing_document",
326
+ description: `Get one invoicing document with its amounts, status, links to related documents and what remains owed. ${AMOUNTS}`,
327
+ inputSchema: schema({ integrationId, documentId }, ["integrationId", "documentId"]),
328
+ run: (a, ctx) => fetchInvoicingDocument(ctx.config, ctx.now, required(a, "integrationId"), required(a, "documentId"))
329
+ },
330
+ {
331
+ name: "list_invoicing_customers",
332
+ description: `List the customers of one connected business system, optionally matching part of the name. ${PAGING_NOTE}`,
333
+ inputSchema: schema(
334
+ { integrationId, q: { type: "string", description: "part of the customer name" }, ...PAGING2 },
335
+ ["integrationId"]
336
+ ),
337
+ run: (a, ctx) => fetchInvoicingCustomers(ctx.config, ctx.now, required(a, "integrationId"), {
338
+ search: str2(a.q),
339
+ limit: str2(a.limit),
340
+ cursor: str2(a.cursor),
341
+ all: bool2(a.all)
342
+ })
343
+ },
344
+ {
345
+ name: "get_invoicing_customer",
346
+ description: "Get one customer of a connected business system by id (from list_invoicing_customers).",
347
+ inputSchema: schema({ integrationId, counterpartyId }, ["integrationId", "counterpartyId"]),
348
+ run: (a, ctx) => fetchInvoicingCustomer(ctx.config, ctx.now, required(a, "integrationId"), required(a, "counterpartyId"))
349
+ },
350
+ {
351
+ name: "list_proposals",
352
+ description: "List your document drafts, newest first, with their status (pending_approval until the user approves in Financy). Filter by integrationId or status; limit up to 50 (no cursor).",
353
+ inputSchema: schema({
354
+ integrationId,
355
+ status: { type: "string", enum: ["pending_approval", "approved", "executing", "completed", "partially_failed", "failed", "expired", "cancelled"] },
356
+ limit: { type: "number", description: "how many (1\u201350)" }
357
+ }),
358
+ run: (a, ctx) => fetchProposals(ctx.config, ctx.now, {
359
+ integration: str2(a.integrationId),
360
+ status: str2(a.status),
361
+ limit: str2(a.limit)
362
+ })
363
+ },
364
+ {
365
+ name: "get_proposal",
366
+ description: "Get a document draft, its approval link, previews and current status, including any issued document results once the user has approved it in Financy.",
367
+ inputSchema: schema({ proposalId }, ["proposalId"]),
368
+ run: (a, ctx) => fetchProposal(ctx.config, ctx.now, required(a, "proposalId"))
369
+ },
370
+ {
371
+ name: "draft_invoice",
372
+ description: `${DRAFT} Drafts a tax invoice to an existing customer from list_invoicing_customers.`,
373
+ inputSchema: schema(invoiceProperties, invoiceRequired),
374
+ run: (a, ctx) => draft(a, ctx, { action: "create_tax_invoice", items: [selected(a, invoiceItemKeys)] })
375
+ },
376
+ {
377
+ name: "draft_invoice_receipt",
378
+ description: `${DRAFT} Drafts an invoice-receipt, with its payment, to an existing customer from list_invoicing_customers.`,
379
+ inputSchema: schema({ ...invoiceProperties, ...paymentProperties }, [...invoiceRequired, ...paymentRequired]),
380
+ run: (a, ctx) => draft(a, ctx, {
381
+ action: "create_invoice_receipt",
382
+ items: [{ ...selected(a, invoiceItemKeys), payment: paymentOf(a) }]
383
+ })
384
+ },
385
+ {
386
+ name: "draft_receipt",
387
+ description: `${DRAFT} Drafts a receipt against an existing invoice.`,
388
+ inputSchema: schema(
389
+ {
390
+ integrationId,
391
+ targetDocumentId: documentId,
392
+ amountMinor,
393
+ withholdingMinor: { type: "integer", description: "withholding tax at source, minor units" },
394
+ ...paymentProperties,
395
+ recipients
396
+ },
397
+ ["integrationId", "targetDocumentId", ...paymentRequired]
398
+ ),
399
+ run: (a, ctx) => draft(a, ctx, {
400
+ action: "create_receipt",
401
+ items: [{ ...selected(a, ["targetDocumentId", "amountMinor", "withholdingMinor", "recipients"]), payment: paymentOf(a) }]
402
+ })
403
+ },
404
+ {
405
+ name: "draft_credit_note",
406
+ description: `${DRAFT} Drafts credit notes against existing invoices, each for at most what remains uncredited.`,
407
+ inputSchema: schema(
408
+ {
409
+ integrationId,
410
+ recipients,
411
+ items: {
412
+ type: "array",
413
+ description: "1\u201350 credit notes",
414
+ items: {
415
+ type: "object",
416
+ properties: { targetDocumentId: documentId, amountMinor, reason: { type: "string" } },
417
+ required: ["targetDocumentId"]
418
+ }
419
+ }
420
+ },
421
+ ["integrationId", "items"]
422
+ ),
423
+ run: (a, ctx) => draft(a, ctx, {
424
+ action: "create_credit_note",
425
+ items: Array.isArray(a.items) ? a.items.map((item) => ({
426
+ ...item !== null && typeof item === "object" ? item : {},
427
+ ...a.recipients === void 0 ? {} : { recipients: a.recipients }
428
+ })) : []
429
+ })
430
+ }
431
+ ];
432
+ var LOCAL_TOOLS = [...TOOLS, ...INTEGRATION_TOOLS];
433
+ var LOCAL_BY_NAME = new Map(LOCAL_TOOLS.map((t) => [t.name, t]));
434
+ function callLocalTool(name, args, io) {
435
+ return dispatchTool(LOCAL_BY_NAME, name, args, io);
436
+ }
437
+
438
+ // src/mcp/server.ts
439
+ async function startMcpServer(env) {
440
+ const server = new Server(
441
+ { name: "financy", version: VERSION },
442
+ { capabilities: { tools: {} } }
443
+ );
444
+ server.setRequestHandler(ListToolsRequestSchema, () => ({
445
+ tools: LOCAL_TOOLS.map((t) => ({
446
+ name: t.name,
447
+ description: t.description,
448
+ inputSchema: t.inputSchema
449
+ }))
450
+ }));
451
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
452
+ const result = await callLocalTool(request.params.name, request.params.arguments ?? {}, { env });
453
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
454
+ });
455
+ await server.connect(new StdioServerTransport());
456
+ }
457
+ export {
458
+ startMcpServer
459
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "financy",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Your Open-Finance accounts, balances and transactions in the terminal — with an embedded MCP server and agent skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,6 @@
49
49
  "scripts": {
50
50
  "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
51
51
  "build": "tsup",
52
- "build:lambda": "tsup --config tsup.lambda.config.ts",
53
52
  "test": "vitest run",
54
53
  "test:watch": "vitest",
55
54
  "typecheck": "tsc --noEmit",
@@ -59,8 +58,7 @@
59
58
  },
60
59
  "dependencies": {
61
60
  "@modelcontextprotocol/sdk": "^1.29.0",
62
- "commander": "^12.1.0",
63
- "jose": "^6.2.9"
61
+ "commander": "^12.1.0"
64
62
  },
65
63
  "devDependencies": {
66
64
  "@eslint/js": "^10.0.1",
@@ -45,7 +45,7 @@ These are not optional and they apply to every step below.
45
45
  The financy data API is a paid feature. To finish this skill the user must have:
46
46
 
47
47
  1. A registered Financy account at <https://open-finance.ai>.
48
- 2. A **paid plan** — Starter or Pro. The data API is not available on the free
48
+ 2. A **paid plan** — Starter, Pro, or Ultra. The data API is not available on the free
49
49
  plan.
50
50
  3. Three values from the Financy app → **Settings → API**: `clientId`,
51
51
  `clientSecret`, and `userId`.
@@ -143,6 +143,32 @@ If anything looks stale, do not refresh on your own initiative. Hand off to the
143
143
  `freshness-check` skill, which knows how to weigh staleness against the 20-credit
144
144
  cost.
145
145
 
146
+ ## Business systems (invoicing)
147
+
148
+ The invoicing commands need CLI **0.4.0** or later (`financy --version`; `financy update` if older).
149
+
150
+ Besides bank and card data, the same credentials read a connected invoicing
151
+ system — Invoice4U today. If the user mentions invoices, receipts, customers or
152
+ "who owes me", check what is connected:
153
+
154
+ ```bash
155
+ financy integrations list --json
156
+ ```
157
+
158
+ - An empty list means nothing is connected yet. The user connects Invoice4U in the
159
+ Financy app, on the **Connections** page; the CLI cannot connect it.
160
+ - Exit `4` with `"code":"UPGRADE_REQUIRED"` means invoicing needs a paid plan
161
+ (Starter and above). Treat it like *Handling exit 4* below.
162
+ - An unknown-command error means the installed CLI predates these commands —
163
+ run `financy update`.
164
+
165
+ Once connected: `financy receivables` (who owes what), `financy documents list
166
+ <integrationId>`, `financy customers list <integrationId>`, and
167
+ `financy proposals list|get`. `financy proposals create <integrationId> --file
168
+ draft.json` only creates a **draft**: nothing is issued until the user approves it
169
+ in Financy at the printed approval link. Never describe a draft as an issued
170
+ invoice, and never try to approve it yourself — the CLI cannot.
171
+
146
172
  ## Handling exit 4
147
173
 
148
174
  Exit `4` (`NOT_AVAILABLE_ON_PLAN`) means the account is registered and the
@@ -150,8 +176,8 @@ credentials are correct, but the plan does not include the data API.
150
176
 
151
177
  Tell the user, in plain terms:
152
178
 
153
- > Your credentials are valid, but the data API needs a paid plan (Starter or
154
- > Pro). You can upgrade at <https://open-finance.ai> — the credentials are
179
+ > Your credentials are valid, but the data API needs a paid plan (Starter, Pro,
180
+ > or Ultra). You can upgrade at <https://open-finance.ai> — the credentials are
155
181
  > already saved, so `financy status` will work as soon as the plan is active.
156
182
 
157
183
  Then stop. Do not retry the command, do not ask for different credentials, and
@@ -193,6 +219,9 @@ same surface is available as tools. Register it once:
193
219
  claude mcp add financy -- npx financy mcp
194
220
  ```
195
221
 
196
- The server resolves credentials exactly as the CLI does. Until setup is
222
+ The server resolves credentials exactly as the CLI does. It also serves the
223
+ invoicing tools (`list_integrations`, `get_receivables`, `list_invoicing_documents`,
224
+ `draft_invoice`, …); a `draft_*` tool creates a draft the user must approve in
225
+ Financy before anything is issued. Until setup is
197
226
  complete, every tool returns a structured `NOT_CONFIGURED` error — which is your
198
227
  signal to run this skill.