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.
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "financy",
6
- version: "0.2.0",
6
+ version: "0.4.0",
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: {
@@ -52,7 +52,6 @@ var package_default = {
52
52
  scripts: {
53
53
  prebuild: `node -e "require('node:fs').rmSync('dist',{recursive:true,force:true})"`,
54
54
  build: "tsup",
55
- "build:lambda": "tsup --config tsup.lambda.config.ts",
56
55
  test: "vitest run",
57
56
  "test:watch": "vitest",
58
57
  typecheck: "tsc --noEmit",
@@ -62,8 +61,7 @@ var package_default = {
62
61
  },
63
62
  dependencies: {
64
63
  "@modelcontextprotocol/sdk": "^1.29.0",
65
- commander: "^12.1.0",
66
- jose: "^6.2.9"
64
+ commander: "^12.1.0"
67
65
  },
68
66
  devDependencies: {
69
67
  "@eslint/js": "^10.0.1",
@@ -111,7 +109,7 @@ var authFailed = (message = "authentication failed \u2014 check your credentials
111
109
  var planNotEligible = () => new CliError(
112
110
  EXIT.PLAN,
113
111
  "NOT_AVAILABLE_ON_PLAN",
114
- "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"
115
113
  );
116
114
  var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
117
115
  var insufficientCredits = (cost, balance) => new CliError(
@@ -122,6 +120,29 @@ var insufficientCredits = (cost, balance) => new CliError(
122
120
  );
123
121
  var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
124
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
+ }
125
146
 
126
147
  // src/config.ts
127
148
  import { homedir } from "os";
@@ -143,6 +164,16 @@ function sanitizeCredential(raw) {
143
164
  var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
144
165
  var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
145
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
+ }
146
177
  function resolveConfigDir(env) {
147
178
  if (env.FINANCY_CONFIG_DIR) return env.FINANCY_CONFIG_DIR;
148
179
  const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
@@ -153,6 +184,7 @@ function resolveEndpoints(env) {
153
184
  authTokenUrl: env.FINANCY_AUTH_URL ?? DEFAULT_AUTH_TOKEN_URL,
154
185
  apiBaseUrl: env.FINANCY_API_URL ?? DEFAULT_API_BASE_URL,
155
186
  chatBaseUrl: env.FINANCY_CHAT_URL ?? DEFAULT_CHAT_BASE_URL,
187
+ integrationsBaseUrl: resolveIntegrationsBaseUrl(env),
156
188
  configDir: resolveConfigDir(env)
157
189
  };
158
190
  }
@@ -201,16 +233,6 @@ async function writeCredentialsFile(configDir, credentials) {
201
233
  { mode: 384 }
202
234
  );
203
235
  }
204
- function bearerConfig(env, bearerToken, abortSignal) {
205
- return {
206
- ...resolveEndpoints(env),
207
- clientId: "",
208
- clientSecret: "",
209
- userId: "",
210
- bearerToken,
211
- abortSignal
212
- };
213
- }
214
236
  function pick(envValue, fileValue) {
215
237
  const raw = envValue ?? fileValue;
216
238
  if (raw === void 0) return void 0;
@@ -332,13 +354,14 @@ async function getAccessToken(config, now) {
332
354
  }
333
355
  return mintAndCache(config);
334
356
  }
335
- async function authorized(config, now, request) {
357
+ var isAuthFailure = (error) => error.code === "AUTH_FAILED";
358
+ async function authorized(config, now, request, remintOn = isAuthFailure) {
336
359
  if (config.bearerToken) return request(config.bearerToken);
337
360
  const token = await getAccessToken(config, now);
338
361
  try {
339
362
  return await request(token);
340
363
  } catch (error) {
341
- if (error instanceof CliError && error.code === "AUTH_FAILED") {
364
+ if (error instanceof CliError && remintOn(error)) {
342
365
  return await request(await mintAndCache(config));
343
366
  }
344
367
  throw error;
@@ -422,6 +445,195 @@ async function getConnections(config, token) {
422
445
  return getPage(config, token, "/connections");
423
446
  }
424
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 yet \u2014 integrations need a Starter plan or above, and the key may need refreshing: rotate it in Financy \u2192 Settings \u2192 API, 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
+
425
637
  // src/core/status.ts
426
638
  var STALE_THRESHOLD_DAYS = 2;
427
639
  function toRow(c) {
@@ -565,7 +777,6 @@ export {
565
777
  resolveEndpoints,
566
778
  readCredentialsFile,
567
779
  writeCredentialsFile,
568
- bearerConfig,
569
780
  loadConfig,
570
781
  notConfigured,
571
782
  VERSION,
@@ -582,5 +793,15 @@ export {
582
793
  fetchTransaction,
583
794
  fetchCategories,
584
795
  fetchProviders,
585
- fetchBranches
796
+ fetchBranches,
797
+ fetchIntegrations,
798
+ fetchIntegration,
799
+ fetchReceivables,
800
+ fetchInvoicingDocuments,
801
+ fetchInvoicingDocument,
802
+ fetchInvoicingCustomers,
803
+ fetchInvoicingCustomer,
804
+ fetchProposals,
805
+ fetchProposal,
806
+ createDraft
586
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.2.0",
169
+ version: "0.4.0",
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: {
@@ -204,7 +215,6 @@ var package_default = {
204
215
  scripts: {
205
216
  prebuild: `node -e "require('node:fs').rmSync('dist',{recursive:true,force:true})"`,
206
217
  build: "tsup",
207
- "build:lambda": "tsup --config tsup.lambda.config.ts",
208
218
  test: "vitest run",
209
219
  "test:watch": "vitest",
210
220
  typecheck: "tsc --noEmit",
@@ -214,8 +224,7 @@ var package_default = {
214
224
  },
215
225
  dependencies: {
216
226
  "@modelcontextprotocol/sdk": "^1.29.0",
217
- commander: "^12.1.0",
218
- jose: "^6.2.9"
227
+ commander: "^12.1.0"
219
228
  },
220
229
  devDependencies: {
221
230
  "@eslint/js": "^10.0.1",
@@ -322,13 +331,14 @@ async function getAccessToken(config, now) {
322
331
  }
323
332
  return mintAndCache(config);
324
333
  }
325
- async function authorized(config, now, request) {
334
+ var isAuthFailure = (error) => error.code === "AUTH_FAILED";
335
+ async function authorized(config, now, request, remintOn = isAuthFailure) {
326
336
  if (config.bearerToken) return request(config.bearerToken);
327
337
  const token = await getAccessToken(config, now);
328
338
  try {
329
339
  return await request(token);
330
340
  } catch (error) {
331
- if (error instanceof CliError && error.code === "AUTH_FAILED") {
341
+ if (error instanceof CliError && remintOn(error)) {
332
342
  return await request(await mintAndCache(config));
333
343
  }
334
344
  throw error;
@@ -688,8 +698,11 @@ function errorEnvelope(error) {
688
698
  return { error: { code: e.code, message: e.message, ...e.extra } };
689
699
  }
690
700
  async function callTool(name, args, io) {
701
+ return dispatchTool(BY_NAME, name, args, io);
702
+ }
703
+ async function dispatchTool(byName, name, args, io) {
691
704
  try {
692
- const tool = BY_NAME.get(name);
705
+ const tool = byName.get(name);
693
706
  if (!tool) {
694
707
  throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
695
708
  }