financy 0.3.0 → 0.4.1

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.
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "financy",
6
- version: "0.3.0",
6
+ version: "0.4.1",
7
7
  description: "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
8
8
  type: "module",
9
9
  bin: {
@@ -109,7 +109,7 @@ var authFailed = (message = "authentication failed \u2014 check your credentials
109
109
  var planNotEligible = () => new CliError(
110
110
  EXIT.PLAN,
111
111
  "NOT_AVAILABLE_ON_PLAN",
112
- "the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
112
+ "the API is available on every paid plan, including Starter, Pro, and Ultra \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
113
113
  );
114
114
  var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
115
115
  var insufficientCredits = (cost, balance) => new CliError(
@@ -120,6 +120,29 @@ var insufficientCredits = (cost, balance) => new CliError(
120
120
  );
121
121
  var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
122
122
  var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
123
+ var PLAN_TYPES = /* @__PURE__ */ new Set([
124
+ "UPGRADE_REQUIRED",
125
+ "INTEGRATION_LIMIT_REACHED",
126
+ "NOT_ENTITLED",
127
+ "ENTITLEMENT_NOT_PROVISIONED"
128
+ ]);
129
+ function integrationsExitCode(type, status) {
130
+ if (type === "SIGN_IN_AGAIN") return EXIT.AUTH;
131
+ if (PLAN_TYPES.has(type) || status === 403) return EXIT.PLAN;
132
+ if (status === 404) return EXIT.NOT_FOUND;
133
+ if (status === 400 || status === 422) return EXIT.USAGE;
134
+ if (status === 429 || status >= 500) return EXIT.API;
135
+ return EXIT.UNEXPECTED;
136
+ }
137
+ function integrationsError(body, status) {
138
+ const hint = body.action?.url ? ` \u2014 ${body.action.url}` : body.type === "UPGRADE_REQUIRED" ? " \u2014 upgrade in Financy \u2192 Settings \u2192 Plan" : "";
139
+ return new CliError(integrationsExitCode(body.type, status), body.type, `${body.message}${hint}`, {
140
+ retryable: body.retryable === true,
141
+ userActionRequired: body.userActionRequired === true,
142
+ ...body.action ? { action: body.action } : {},
143
+ ...body.requestId ? { requestId: body.requestId } : {}
144
+ });
145
+ }
123
146
 
124
147
  // src/config.ts
125
148
  import { homedir } from "os";
@@ -141,6 +164,16 @@ function sanitizeCredential(raw) {
141
164
  var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
142
165
  var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
143
166
  var DEFAULT_CHAT_BASE_URL = "https://api.open-finance.ai/chat/chat";
167
+ var DEFAULT_INTEGRATIONS_BASE_URL = "https://api.open-finance.ai/integrations";
168
+ function resolveIntegrationsBaseUrl(env) {
169
+ if (env.FINANCY_INTEGRATIONS_URL) return env.FINANCY_INTEGRATIONS_URL;
170
+ if (!env.FINANCY_API_URL) return DEFAULT_INTEGRATIONS_BASE_URL;
171
+ try {
172
+ return `${new URL(env.FINANCY_API_URL).origin}/integrations`;
173
+ } catch {
174
+ return DEFAULT_INTEGRATIONS_BASE_URL;
175
+ }
176
+ }
144
177
  function resolveConfigDir(env) {
145
178
  if (env.FINANCY_CONFIG_DIR) return env.FINANCY_CONFIG_DIR;
146
179
  const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
@@ -151,6 +184,7 @@ function resolveEndpoints(env) {
151
184
  authTokenUrl: env.FINANCY_AUTH_URL ?? DEFAULT_AUTH_TOKEN_URL,
152
185
  apiBaseUrl: env.FINANCY_API_URL ?? DEFAULT_API_BASE_URL,
153
186
  chatBaseUrl: env.FINANCY_CHAT_URL ?? DEFAULT_CHAT_BASE_URL,
187
+ integrationsBaseUrl: resolveIntegrationsBaseUrl(env),
154
188
  configDir: resolveConfigDir(env)
155
189
  };
156
190
  }
@@ -320,13 +354,14 @@ async function getAccessToken(config, now) {
320
354
  }
321
355
  return mintAndCache(config);
322
356
  }
323
- async function authorized(config, now, request) {
357
+ var isAuthFailure = (error) => error.code === "AUTH_FAILED";
358
+ async function authorized(config, now, request, remintOn = isAuthFailure) {
324
359
  if (config.bearerToken) return request(config.bearerToken);
325
360
  const token = await getAccessToken(config, now);
326
361
  try {
327
362
  return await request(token);
328
363
  } catch (error) {
329
- if (error instanceof CliError && error.code === "AUTH_FAILED") {
364
+ if (error instanceof CliError && remintOn(error)) {
330
365
  return await request(await mintAndCache(config));
331
366
  }
332
367
  throw error;
@@ -410,6 +445,195 @@ async function getConnections(config, token) {
410
445
  return getPage(config, token, "/connections");
411
446
  }
412
447
 
448
+ // src/integrations-api.ts
449
+ function isApiError(value) {
450
+ return value !== null && typeof value === "object" && typeof value.type === "string" && typeof value.message === "string";
451
+ }
452
+ function integrationsUrl(config, path, query) {
453
+ const url = new URL(config.integrationsBaseUrl.replace(/\/+$/, "") + path);
454
+ for (const [key, value] of Object.entries(query)) {
455
+ if (value !== void 0) url.searchParams.set(key, value);
456
+ }
457
+ return url.toString();
458
+ }
459
+ async function send(config, token, method, path, options = {}) {
460
+ const res = await httpFetch(integrationsUrl(config, path, options.query ?? {}), {
461
+ method,
462
+ headers: {
463
+ authorization: `Bearer ${token}`,
464
+ accept: "application/json",
465
+ "user-agent": USER_AGENT,
466
+ ...options.body === void 0 ? {} : { "content-type": "application/json" },
467
+ ...options.headers
468
+ },
469
+ ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) },
470
+ signal: config.abortSignal
471
+ });
472
+ const parsed = await res.json().catch(() => void 0);
473
+ if (res.ok) {
474
+ debugBody(path, parsed);
475
+ return { body: parsed ?? {}, replayed: res.headers.get("idempotent-replayed") === "true" };
476
+ }
477
+ if (isApiError(parsed)) throw integrationsError(parsed, res.status);
478
+ if (res.status === 401) throw authFailed();
479
+ throw apiUnavailable(`the integrations API returned HTTP ${res.status}`);
480
+ }
481
+ async function integrationsGet(config, token, path, query = {}) {
482
+ return (await send(config, token, "GET", path, { query })).body;
483
+ }
484
+ async function integrationsPost(config, token, path, body, headers = {}) {
485
+ return send(config, token, "POST", path, { body, headers });
486
+ }
487
+
488
+ // src/core/integrations.ts
489
+ var PATH_SEGMENT = /^[A-Za-z0-9_-]{1,128}$/;
490
+ function segment(name, value) {
491
+ if (!PATH_SEGMENT.test(value)) {
492
+ throw new CliError(EXIT.USAGE, "INVALID_ARGUMENT", `'${value}' is not a valid ${name}`);
493
+ }
494
+ return value;
495
+ }
496
+ var isScopeError = (error) => error.code === "INSUFFICIENT_SCOPE";
497
+ async function integrationsAuthorized(config, now, request) {
498
+ try {
499
+ return await authorized(config, now, request, (e) => e.code === "AUTH_FAILED" || isScopeError(e));
500
+ } catch (error) {
501
+ if (error instanceof CliError && isScopeError(error)) {
502
+ throw new CliError(
503
+ EXIT.PLAN,
504
+ "INSUFFICIENT_SCOPE",
505
+ `this API key doesn't have integration access \u2014 if the key is set to read-only, turn off "\u05DE\u05D9\u05D3\u05E2 \u05D1\u05DC\u05D1\u05D3" (information only) in Financy \u2192 Settings \u2192 API access; otherwise integrations need a Starter plan or above, and the key may need refreshing: rotate it there, then run financy setup`,
506
+ error.extra
507
+ );
508
+ }
509
+ throw error;
510
+ }
511
+ }
512
+ var get = (config, now, path, query = {}) => integrationsAuthorized(config, now, (token) => integrationsGet(config, token, path, query));
513
+ async function cursorList(config, now, path, query, all) {
514
+ const data = [];
515
+ let cursor = query.cursor;
516
+ let freshness;
517
+ do {
518
+ const page = await get(config, now, path, { ...query, cursor });
519
+ data.push(...Array.isArray(page.items) ? page.items : []);
520
+ freshness = page.freshness;
521
+ cursor = page.nextCursor ?? void 0;
522
+ } while (all && cursor);
523
+ return {
524
+ data,
525
+ count: data.length,
526
+ nextPage: all ? null : cursor ?? null,
527
+ ...freshness === void 0 ? {} : { freshness }
528
+ };
529
+ }
530
+ function fetchIntegrations(config, now) {
531
+ return cursorList(config, now, "/", {}, false);
532
+ }
533
+ async function fetchIntegration(config, now, id) {
534
+ const body = await get(config, now, `/${segment("integration id", id)}`);
535
+ return { data: body.integration };
536
+ }
537
+ async function fetchReceivables(config, now, args) {
538
+ const ids = (args.integrations ?? []).flatMap((v) => v.split(",")).filter((v) => v.length > 0);
539
+ const body = await get(config, now, "/receivables", {
540
+ integrationIds: ids.length > 0 ? ids.map((id) => segment("integration id", id)).join(",") : void 0,
541
+ asOf: args.asOf,
542
+ format: args.detailed ? "detailed" : void 0,
543
+ customerLimit: args.customerLimit,
544
+ includeProforma: args.includeProforma === void 0 ? void 0 : String(args.includeProforma)
545
+ });
546
+ return { data: body };
547
+ }
548
+ function fetchInvoicingDocuments(config, now, integrationId, args) {
549
+ return cursorList(
550
+ config,
551
+ now,
552
+ `/${segment("integration id", integrationId)}/documents`,
553
+ {
554
+ status: args.status,
555
+ type: args.type,
556
+ counterpartyId: args.customer,
557
+ issuedFrom: args.from,
558
+ issuedTo: args.to,
559
+ receivableOnly: args.receivableOnly ? "true" : void 0,
560
+ number: args.number,
561
+ limit: args.limit,
562
+ cursor: args.cursor
563
+ },
564
+ !!args.all
565
+ );
566
+ }
567
+ async function fetchInvoicingDocument(config, now, integrationId, documentId) {
568
+ const body = await get(
569
+ config,
570
+ now,
571
+ `/${segment("integration id", integrationId)}/documents/${segment("document id", documentId)}`
572
+ );
573
+ return { data: body.document, ...body.freshness === void 0 ? {} : { freshness: body.freshness } };
574
+ }
575
+ function fetchInvoicingCustomers(config, now, integrationId, args) {
576
+ return cursorList(
577
+ config,
578
+ now,
579
+ `/${segment("integration id", integrationId)}/customers`,
580
+ { q: args.search, limit: args.limit, cursor: args.cursor },
581
+ !!args.all
582
+ );
583
+ }
584
+ async function fetchInvoicingCustomer(config, now, integrationId, customerId) {
585
+ const body = await get(
586
+ config,
587
+ now,
588
+ `/${segment("integration id", integrationId)}/customers/${segment("customer id", customerId)}`
589
+ );
590
+ return { data: body.customer, ...body.freshness === void 0 ? {} : { freshness: body.freshness } };
591
+ }
592
+ function fetchProposals(config, now, args) {
593
+ return cursorList(
594
+ config,
595
+ now,
596
+ "/proposals",
597
+ {
598
+ integrationId: args.integration === void 0 ? void 0 : segment("integration id", args.integration),
599
+ status: args.status,
600
+ clientReference: args.reference,
601
+ limit: args.limit
602
+ },
603
+ false
604
+ );
605
+ }
606
+ async function fetchProposal(config, now, id) {
607
+ const body = await get(config, now, `/proposals/${segment("proposal id", id)}`);
608
+ return { data: body.proposal };
609
+ }
610
+ function assertDraftBody(body) {
611
+ const obj = body;
612
+ if (body === null || typeof body !== "object" || Array.isArray(body) || typeof obj.action !== "string" || !Array.isArray(obj.items)) {
613
+ throw new CliError(
614
+ EXIT.USAGE,
615
+ "INVALID_DRAFT",
616
+ 'a draft is a JSON object with an "action" (e.g. "create_tax_invoice") and an "items" array'
617
+ );
618
+ }
619
+ return body;
620
+ }
621
+ async function createDraft(config, now, integrationId, body, idempotencyKey) {
622
+ const draft = assertDraftBody(body);
623
+ const path = `/${segment("integration id", integrationId)}/proposals`;
624
+ const headers = idempotencyKey ? { "idempotency-key": idempotencyKey } : {};
625
+ const res = await integrationsAuthorized(
626
+ config,
627
+ now,
628
+ (token) => integrationsPost(config, token, path, draft, headers)
629
+ );
630
+ return {
631
+ data: res.body.proposal,
632
+ replayed: res.replayed,
633
+ ...idempotencyKey ? { idempotencyKey } : {}
634
+ };
635
+ }
636
+
413
637
  // src/core/status.ts
414
638
  var STALE_THRESHOLD_DAYS = 2;
415
639
  function toRow(c) {
@@ -569,5 +793,15 @@ export {
569
793
  fetchTransaction,
570
794
  fetchCategories,
571
795
  fetchProviders,
572
- fetchBranches
796
+ fetchBranches,
797
+ fetchIntegrations,
798
+ fetchIntegration,
799
+ fetchReceivables,
800
+ fetchInvoicingDocuments,
801
+ fetchInvoicingDocument,
802
+ fetchInvoicingCustomers,
803
+ fetchInvoicingCustomer,
804
+ fetchProposals,
805
+ fetchProposal,
806
+ createDraft
573
807
  };
package/dist/lib.d.ts CHANGED
@@ -7,6 +7,7 @@ interface Endpoints {
7
7
  authTokenUrl: string;
8
8
  apiBaseUrl: string;
9
9
  chatBaseUrl: string;
10
+ integrationsBaseUrl: string;
10
11
  configDir: string;
11
12
  }
12
13
  type Config = Credentials & Endpoints & {
package/dist/lib.js CHANGED
@@ -43,7 +43,7 @@ var authFailed = (message = "authentication failed \u2014 check your credentials
43
43
  var planNotEligible = () => new CliError(
44
44
  EXIT.PLAN,
45
45
  "NOT_AVAILABLE_ON_PLAN",
46
- "the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
46
+ "the API is available on every paid plan, including Starter, Pro, and Ultra \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
47
47
  );
48
48
  var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
49
49
  var insufficientCredits = (cost, balance) => new CliError(
@@ -59,6 +59,16 @@ var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 chec
59
59
  var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
60
60
  var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
61
61
  var DEFAULT_CHAT_BASE_URL = "https://api.open-finance.ai/chat/chat";
62
+ var DEFAULT_INTEGRATIONS_BASE_URL = "https://api.open-finance.ai/integrations";
63
+ function resolveIntegrationsBaseUrl(env) {
64
+ if (env.FINANCY_INTEGRATIONS_URL) return env.FINANCY_INTEGRATIONS_URL;
65
+ if (!env.FINANCY_API_URL) return DEFAULT_INTEGRATIONS_BASE_URL;
66
+ try {
67
+ return `${new URL(env.FINANCY_API_URL).origin}/integrations`;
68
+ } catch {
69
+ return DEFAULT_INTEGRATIONS_BASE_URL;
70
+ }
71
+ }
62
72
  function resolveConfigDir(env) {
63
73
  if (env.FINANCY_CONFIG_DIR) return env.FINANCY_CONFIG_DIR;
64
74
  const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
@@ -69,6 +79,7 @@ function resolveEndpoints(env) {
69
79
  authTokenUrl: env.FINANCY_AUTH_URL ?? DEFAULT_AUTH_TOKEN_URL,
70
80
  apiBaseUrl: env.FINANCY_API_URL ?? DEFAULT_API_BASE_URL,
71
81
  chatBaseUrl: env.FINANCY_CHAT_URL ?? DEFAULT_CHAT_BASE_URL,
82
+ integrationsBaseUrl: resolveIntegrationsBaseUrl(env),
72
83
  configDir: resolveConfigDir(env)
73
84
  };
74
85
  }
@@ -155,7 +166,7 @@ function notConfigured(result) {
155
166
  // package.json
156
167
  var package_default = {
157
168
  name: "financy",
158
- version: "0.3.0",
169
+ version: "0.4.1",
159
170
  description: "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
160
171
  type: "module",
161
172
  bin: {
@@ -320,13 +331,14 @@ async function getAccessToken(config, now) {
320
331
  }
321
332
  return mintAndCache(config);
322
333
  }
323
- async function authorized(config, now, request) {
334
+ var isAuthFailure = (error) => error.code === "AUTH_FAILED";
335
+ async function authorized(config, now, request, remintOn = isAuthFailure) {
324
336
  if (config.bearerToken) return request(config.bearerToken);
325
337
  const token = await getAccessToken(config, now);
326
338
  try {
327
339
  return await request(token);
328
340
  } catch (error) {
329
- if (error instanceof CliError && error.code === "AUTH_FAILED") {
341
+ if (error instanceof CliError && remintOn(error)) {
330
342
  return await request(await mintAndCache(config));
331
343
  }
332
344
  throw error;
@@ -686,8 +698,11 @@ function errorEnvelope(error) {
686
698
  return { error: { code: e.code, message: e.message, ...e.extra } };
687
699
  }
688
700
  async function callTool(name, args, io) {
701
+ return dispatchTool(BY_NAME, name, args, io);
702
+ }
703
+ async function dispatchTool(byName, name, args, io) {
689
704
  try {
690
- const tool = BY_NAME.get(name);
705
+ const tool = byName.get(name);
691
706
  if (!tool) {
692
707
  throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
693
708
  }