@frihet/mcp-server 1.16.6 → 2.0.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.
- package/README.md +168 -82
- package/dist/agent-onboarding.d.ts +98 -0
- package/dist/agent-onboarding.js +275 -0
- package/dist/api-origin.d.ts +9 -0
- package/dist/api-origin.js +41 -0
- package/dist/canary/mcp-harness.d.ts +77 -0
- package/dist/canary/mcp-harness.js +552 -0
- package/dist/capability-truth.d.ts +29 -0
- package/dist/capability-truth.js +262 -0
- package/dist/claude-candidate-contract.d.ts +482 -0
- package/dist/claude-candidate-contract.js +87 -0
- package/dist/claude-output-privacy.d.ts +17 -0
- package/dist/claude-output-privacy.js +240 -0
- package/dist/claude-profile.d.ts +18 -0
- package/dist/claude-profile.js +260 -0
- package/dist/client-interface.d.ts +47 -33
- package/dist/client.d.ts +172 -40
- package/dist/client.js +708 -165
- package/dist/demo-client.d.ts +41 -32
- package/dist/demo-client.js +202 -31
- package/dist/demo-fixtures.d.ts +3 -3
- package/dist/demo-fixtures.js +7 -7
- package/dist/deposit-contract.d.ts +29 -0
- package/dist/deposit-contract.js +76 -0
- package/dist/fetch-no-redirect.d.ts +3 -0
- package/dist/fetch-no-redirect.js +16 -0
- package/dist/fiscal-aliases.d.ts +44 -0
- package/dist/fiscal-aliases.js +62 -0
- package/dist/fiscal-period.d.ts +26 -0
- package/dist/fiscal-period.js +31 -0
- package/dist/google-profile.d.ts +19 -0
- package/dist/google-profile.js +122 -0
- package/dist/index.js +68 -59
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +140 -8
- package/dist/muse-profile.d.ts +19 -0
- package/dist/muse-profile.js +122 -0
- package/dist/observability.d.ts +18 -29
- package/dist/observability.js +194 -106
- package/dist/openai-profile.d.ts +26 -22
- package/dist/openai-profile.js +1193 -157
- package/dist/openai-review-contract.d.ts +15 -6
- package/dist/openai-review-contract.js +714 -41
- package/dist/openai-review-oauth.d.ts +149 -2
- package/dist/openai-review-oauth.js +220 -6
- package/dist/prompts/register-all.d.ts +2 -0
- package/dist/prompts/register-all.js +34 -78
- package/dist/public-capability-contract.d.ts +39 -0
- package/dist/public-capability-contract.js +186 -0
- package/dist/redaction.d.ts +15 -0
- package/dist/redaction.js +83 -0
- package/dist/resources/register-all.js +154 -64
- package/dist/retry-after.d.ts +19 -0
- package/dist/retry-after.js +84 -0
- package/dist/server-composition.d.ts +19 -0
- package/dist/server-composition.js +74 -0
- package/dist/tool-exposure.d.ts +31 -27
- package/dist/tool-exposure.js +211 -87
- package/dist/tools/accountingClose.d.ts +14 -5
- package/dist/tools/accountingClose.js +41 -23
- package/dist/tools/audit_gl.js +8 -8
- package/dist/tools/backend-availability.d.ts +8 -29
- package/dist/tools/backend-availability.js +31 -30
- package/dist/tools/bank_rules.js +66 -15
- package/dist/tools/banking.js +5 -13
- package/dist/tools/clients.js +261 -15
- package/dist/tools/crm.js +22 -21
- package/dist/tools/deposits.js +114 -85
- package/dist/tools/einvoice.d.ts +16 -2
- package/dist/tools/einvoice.js +59 -12
- package/dist/tools/expenses.d.ts +16 -0
- package/dist/tools/expenses.js +47 -27
- package/dist/tools/fiscal.d.ts +3 -4
- package/dist/tools/fiscal.js +93 -60
- package/dist/tools/gestoria.js +15 -7
- package/dist/tools/hr.d.ts +2 -3
- package/dist/tools/hr.js +49 -20
- package/dist/tools/igic.d.ts +9 -8
- package/dist/tools/igic.js +34 -64
- package/dist/tools/impuesto_sociedades.d.ts +4 -5
- package/dist/tools/impuesto_sociedades.js +16 -37
- package/dist/tools/intelligence.js +166 -12
- package/dist/tools/invoices.js +224 -54
- package/dist/tools/kitchen.js +19 -9
- package/dist/tools/payroll.d.ts +5 -8
- package/dist/tools/payroll.js +19 -32
- package/dist/tools/permissions.d.ts +4 -7
- package/dist/tools/permissions.js +11 -14
- package/dist/tools/products.js +6 -6
- package/dist/tools/quotes.js +172 -26
- package/dist/tools/recurring.js +26 -25
- package/dist/tools/register-all.d.ts +2 -2
- package/dist/tools/register-all.js +9 -6
- package/dist/tools/search.d.ts +19 -0
- package/dist/tools/search.js +84 -0
- package/dist/tools/shared.d.ts +1445 -109
- package/dist/tools/shared.js +478 -102
- package/dist/tools/team.js +24 -28
- package/dist/tools/time.js +4 -12
- package/dist/tools/vendors.js +249 -15
- package/dist/tools/webhooks.js +35 -30
- package/dist/types.d.ts +31 -5
- package/dist/workflows/daily-operational-brief.d.ts +163 -0
- package/dist/workflows/daily-operational-brief.js +881 -0
- package/docs/agent-onboarding.json +391 -0
- package/package.json +49 -12
- package/dist/tools/onboarding.d.ts +0 -19
- package/dist/tools/onboarding.js +0 -64
package/dist/client.js
CHANGED
|
@@ -11,11 +11,83 @@
|
|
|
11
11
|
* (`req.query.cursor`, functions/src/publicApi.ts in Frihet-ERP). `after`
|
|
12
12
|
* is never sent on the wire. See src/__tests__/pagination-cursor-param.test.ts.
|
|
13
13
|
*/
|
|
14
|
+
import { rejectRedirectResponse } from "./fetch-no-redirect.js";
|
|
15
|
+
import { safeRequestId, sanitizeServerRemediation } from "./redaction.js";
|
|
14
16
|
import { logApiCall, logRetry } from "./logger.js";
|
|
17
|
+
import { parseRetryAfter } from "./retry-after.js";
|
|
18
|
+
import { fiscalModeloQuery } from "./fiscal-period.js";
|
|
19
|
+
import { normalizeApplyDepositBody, normalizeDepositBody } from "./deposit-contract.js";
|
|
15
20
|
const BASE_URL = "https://api.frihet.io/v1";
|
|
16
21
|
const MAX_RETRIES = 3;
|
|
17
22
|
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
18
23
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
24
|
+
/** Total time one logical call may spend sleeping on 429s before it is deferred to the caller. */
|
|
25
|
+
const RETRY_BUDGET_MS = 30000;
|
|
26
|
+
/** setTimeout fires immediately above this (32-bit signed); a larger wait must defer, never sleep. */
|
|
27
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
28
|
+
/**
|
|
29
|
+
* Hard size caps for document responses. Enforced TWICE: precheck on
|
|
30
|
+
* `Content-Length` (so an honest server doesn't waste bandwidth) and after
|
|
31
|
+
* streaming (so a missing/lying `Content-Length` still can't trigger an
|
|
32
|
+
* unbounded allocation).
|
|
33
|
+
*
|
|
34
|
+
* - PDF: 25 MiB — generous for any ERP-issued invoice PDF, including
|
|
35
|
+
* embedded logos, Facturae XML attachments, and stamp signatures.
|
|
36
|
+
* - XML: 5 MiB — UBL / Facturae / PEPPOL documents stay well under 1 MiB
|
|
37
|
+
* in practice; 5 MiB absorbs any historical / annex-laden outlier.
|
|
38
|
+
*
|
|
39
|
+
* Anything larger is rejected with `413 payload_too_large` BEFORE we allocate
|
|
40
|
+
* — the user sees a clean error, the worker doesn't OOM.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_PDF_BYTES = 25 * 1024 * 1024;
|
|
43
|
+
export const MAX_XML_BYTES = 5 * 1024 * 1024;
|
|
44
|
+
const MAX_ERROR_BYTES = 64 * 1024;
|
|
45
|
+
/** One exhaustive translation at the HTTP boundary. Never send the MCP's
|
|
46
|
+
* lowercase convenience values to the strict API. Factur-X Extended, Basic
|
|
47
|
+
* and Minimum are intentionally absent: collapsing them to EN16931 would lie
|
|
48
|
+
* about the generated profile. */
|
|
49
|
+
const E_INVOICE_EXPORT_API_FORMAT = {
|
|
50
|
+
facturae: "Facturae",
|
|
51
|
+
"xrechnung-cii": "XRechnung-CII",
|
|
52
|
+
"xrechnung-ubl": "XRechnung-UBL",
|
|
53
|
+
"facturx-en16931": "Factur-X",
|
|
54
|
+
fatturapa: "FatturaPA",
|
|
55
|
+
"peppol-bis-3": "PEPPOL-BIS-3",
|
|
56
|
+
"fa-2-ksef": "FA-2-KSeF",
|
|
57
|
+
ubl: "UBL",
|
|
58
|
+
cii: "CII",
|
|
59
|
+
};
|
|
60
|
+
// Only the existing guards below can attest that a request was never sent.
|
|
61
|
+
// Provider error codes, copied errors and public properties cannot forge this
|
|
62
|
+
// process-local provenance. Both runtimes use this fetch-only client.
|
|
63
|
+
const LOCAL_VALIDATION_ERRORS = new WeakSet();
|
|
64
|
+
export function isLocalInputValidationError(error) {
|
|
65
|
+
return typeof error === "object" && error !== null && LOCAL_VALIDATION_ERRORS.has(error);
|
|
66
|
+
}
|
|
67
|
+
export function toApiEInvoiceExportFormat(format) {
|
|
68
|
+
if (!Object.hasOwn(E_INVOICE_EXPORT_API_FORMAT, format)) {
|
|
69
|
+
const error = new FrihetApiError(400, "unsupported_einvoice_export_format", "Unsupported e-invoice export format. Factur-X export supports the EN16931 profile only.");
|
|
70
|
+
LOCAL_VALIDATION_ERRORS.add(error);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
return E_INVOICE_EXPORT_API_FORMAT[format];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Encode one caller-supplied path segment. `encodeURIComponent` leaves "." and
|
|
77
|
+
* ".." unchanged and URL parsing resolves them as dot-segments, so either value
|
|
78
|
+
* would change the route a request reaches; an empty value would drop the
|
|
79
|
+
* segment. Every id interpolated into a request path goes through here, and
|
|
80
|
+
* those three values are refused before any request is sent.
|
|
81
|
+
*/
|
|
82
|
+
function pathSegment(value) {
|
|
83
|
+
const segment = String(value);
|
|
84
|
+
if (segment === "" || segment === "." || segment === "..") {
|
|
85
|
+
const error = new FrihetApiError(400, "invalid_path_segment", 'Invalid identifier: an id cannot be empty, "." or "..".');
|
|
86
|
+
LOCAL_VALIDATION_ERRORS.add(error);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
return encodeURIComponent(segment);
|
|
90
|
+
}
|
|
19
91
|
/**
|
|
20
92
|
* HTTP methods for which the backend treats the request as a mutation and
|
|
21
93
|
* therefore accepts (and for some endpoints REQUIRES) an `Idempotency-Key`
|
|
@@ -55,12 +127,11 @@ const SOURCE_USER_AGENT = "frihet-mcp-server";
|
|
|
55
127
|
/**
|
|
56
128
|
* Fresh idempotency key, always a syntactically valid UUID v4.
|
|
57
129
|
*
|
|
58
|
-
* `crypto.randomUUID` is
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* src/__tests__/idempotency-key-contract.test.ts asserts the shape.
|
|
130
|
+
* `crypto.randomUUID` is global on Cloudflare Workers and the supported Node
|
|
131
|
+
* runtime. The fallback remains defense in depth for constrained embedders and
|
|
132
|
+
* tests that intentionally remove the global; it must still produce a UUID,
|
|
133
|
+
* not an ad-hoc string. The backend recommends UUID v4 and the contract test
|
|
134
|
+
* asserts the shape.
|
|
64
135
|
*/
|
|
65
136
|
function newIdempotencyKey() {
|
|
66
137
|
const c = globalThis.crypto;
|
|
@@ -86,31 +157,170 @@ function normalizeIdempotencyKey(key) {
|
|
|
86
157
|
const trimmed = key?.trim();
|
|
87
158
|
return trimmed ? trimmed : undefined;
|
|
88
159
|
}
|
|
160
|
+
function normalizedContentType(contentType) {
|
|
161
|
+
return contentType.split(";", 1)[0].trim().toLowerCase();
|
|
162
|
+
}
|
|
163
|
+
function isXmlContentType(contentType) {
|
|
164
|
+
const normalized = normalizedContentType(contentType);
|
|
165
|
+
return normalized === "application/xml" || normalized === "text/xml" || normalized.endsWith("+xml");
|
|
166
|
+
}
|
|
167
|
+
function hasPdfSignature(bytes) {
|
|
168
|
+
return bytes.byteLength >= 5 &&
|
|
169
|
+
bytes[0] === 0x25 &&
|
|
170
|
+
bytes[1] === 0x50 &&
|
|
171
|
+
bytes[2] === 0x44 &&
|
|
172
|
+
bytes[3] === 0x46 &&
|
|
173
|
+
bytes[4] === 0x2d;
|
|
174
|
+
}
|
|
175
|
+
function attachmentFilename(contentDisposition) {
|
|
176
|
+
if (!contentDisposition)
|
|
177
|
+
return undefined;
|
|
178
|
+
const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
179
|
+
const quoted = contentDisposition.match(/filename="([^"]+)"/i)?.[1];
|
|
180
|
+
const plain = contentDisposition.match(/filename=([^;]+)/i)?.[1];
|
|
181
|
+
const candidate = (encoded ?? quoted ?? plain)?.trim();
|
|
182
|
+
if (!candidate)
|
|
183
|
+
return undefined;
|
|
184
|
+
try {
|
|
185
|
+
return decodeURIComponent(candidate).split(/[\\/]/).pop();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return candidate.split(/[\\/]/).pop();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function isAbortError(error) {
|
|
192
|
+
return error instanceof Error && error.name === "AbortError";
|
|
193
|
+
}
|
|
194
|
+
function decodeUtf8(bytes, label) {
|
|
195
|
+
try {
|
|
196
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new FrihetApiError(200, "invalid_response", `${label} is not valid UTF-8`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
203
|
+
/** Encode bytes without relying on Node's Buffer in browser/Worker runtimes. */
|
|
204
|
+
function encodeBase64(bytes) {
|
|
205
|
+
let encoded = "";
|
|
206
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
207
|
+
const first = bytes[index];
|
|
208
|
+
const hasSecond = index + 1 < bytes.length;
|
|
209
|
+
const hasThird = index + 2 < bytes.length;
|
|
210
|
+
const second = hasSecond ? bytes[index + 1] : 0;
|
|
211
|
+
const third = hasThird ? bytes[index + 2] : 0;
|
|
212
|
+
const chunk = (first << 16) | (second << 8) | third;
|
|
213
|
+
encoded += BASE64_ALPHABET[(chunk >>> 18) & 0x3f];
|
|
214
|
+
encoded += BASE64_ALPHABET[(chunk >>> 12) & 0x3f];
|
|
215
|
+
encoded += hasSecond ? BASE64_ALPHABET[(chunk >>> 6) & 0x3f] : "=";
|
|
216
|
+
encoded += hasThird ? BASE64_ALPHABET[chunk & 0x3f] : "=";
|
|
217
|
+
}
|
|
218
|
+
return encoded;
|
|
219
|
+
}
|
|
89
220
|
export class FrihetApiError extends Error {
|
|
90
221
|
statusCode;
|
|
91
222
|
errorCode;
|
|
92
|
-
|
|
93
|
-
|
|
223
|
+
detail;
|
|
224
|
+
requestId;
|
|
225
|
+
constructor(statusCode, errorCode, message, detail,
|
|
226
|
+
/** Backend correlation id (`meta.requestId` or `X-Request-Id`); carried for diagnostics, never echoed to agents. */
|
|
227
|
+
requestId) {
|
|
228
|
+
super(statusCode === 403
|
|
229
|
+
? sanitizeServerRemediation(message, errorCode) ?? errorCode
|
|
230
|
+
: message ?? errorCode);
|
|
94
231
|
this.statusCode = statusCode;
|
|
95
232
|
this.errorCode = errorCode;
|
|
233
|
+
this.detail = detail;
|
|
234
|
+
this.requestId = requestId;
|
|
96
235
|
this.name = "FrihetApiError";
|
|
97
236
|
}
|
|
98
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Enforce the webhook secret's one-time write echo and normalize the one
|
|
240
|
+
* Firestore Timestamp that the ERP webhook route currently emits without its
|
|
241
|
+
* usual serializer. Invalid timestamp objects are preserved so the strict MCP
|
|
242
|
+
* output schema fails closed instead of fabricating a value.
|
|
243
|
+
*/
|
|
244
|
+
function normalizeWebhookRecord(webhook, allowedSecret) {
|
|
245
|
+
const normalized = { ...webhook };
|
|
246
|
+
if (typeof allowedSecret !== "string" ||
|
|
247
|
+
typeof normalized.secret !== "string" ||
|
|
248
|
+
normalized.secret !== allowedSecret) {
|
|
249
|
+
delete normalized.secret;
|
|
250
|
+
}
|
|
251
|
+
const lastTriggeredAt = normalized.lastTriggeredAt;
|
|
252
|
+
if (lastTriggeredAt !== null &&
|
|
253
|
+
typeof lastTriggeredAt === "object" &&
|
|
254
|
+
!Array.isArray(lastTriggeredAt)) {
|
|
255
|
+
const timestamp = lastTriggeredAt;
|
|
256
|
+
const seconds = timestamp._seconds;
|
|
257
|
+
const nanoseconds = timestamp._nanoseconds;
|
|
258
|
+
if (typeof seconds === "number" &&
|
|
259
|
+
Number.isInteger(seconds) &&
|
|
260
|
+
Number.isFinite(seconds) &&
|
|
261
|
+
typeof nanoseconds === "number" &&
|
|
262
|
+
Number.isInteger(nanoseconds) &&
|
|
263
|
+
nanoseconds >= 0 &&
|
|
264
|
+
nanoseconds < 1_000_000_000) {
|
|
265
|
+
const date = new Date(seconds * 1000 + Math.floor(nanoseconds / 1_000_000));
|
|
266
|
+
if (Number.isFinite(date.getTime())) {
|
|
267
|
+
normalized.lastTriggeredAt = date.toISOString();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return normalized;
|
|
272
|
+
}
|
|
273
|
+
function normalizeApiError(value, statusCode, statusText) {
|
|
274
|
+
const fallbackError = `http_${statusCode}`;
|
|
275
|
+
const record = typeof value === "object" && value !== null
|
|
276
|
+
? value
|
|
277
|
+
: undefined;
|
|
278
|
+
// publicApi error envelopes carry `meta: { requestId }` (Frihet-ERP
|
|
279
|
+
// functions/src/publicApi.ts, e.g. the 401/404 branches).
|
|
280
|
+
const meta = typeof record?.meta === "object" && record.meta !== null
|
|
281
|
+
? record.meta
|
|
282
|
+
: undefined;
|
|
283
|
+
const requestId = safeRequestId(meta?.requestId);
|
|
284
|
+
const error = typeof record?.error === "string" && record.error.trim()
|
|
285
|
+
? record.error
|
|
286
|
+
: fallbackError;
|
|
287
|
+
const message = typeof record?.message === "string"
|
|
288
|
+
? record.message
|
|
289
|
+
: (error !== fallbackError ? error : statusText || fallbackError);
|
|
290
|
+
return {
|
|
291
|
+
error,
|
|
292
|
+
message,
|
|
293
|
+
...(typeof record?.detail === "string" ? { detail: record.detail } : {}),
|
|
294
|
+
...(requestId ? { requestId } : {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
99
297
|
export class FrihetClient {
|
|
100
298
|
apiKey;
|
|
101
299
|
baseUrl;
|
|
102
300
|
timeoutMs;
|
|
301
|
+
retryBudgetMs;
|
|
302
|
+
sleepFn;
|
|
303
|
+
nowFn;
|
|
103
304
|
constructor(apiKey, baseUrl, options) {
|
|
104
305
|
if (!apiKey) {
|
|
105
306
|
throw new Error("FRIHET_API_KEY is required. Set it as an environment variable or pass it to the constructor.");
|
|
106
307
|
}
|
|
308
|
+
const resolvedBaseUrl = baseUrl ?? BASE_URL;
|
|
107
309
|
this.apiKey = apiKey;
|
|
108
|
-
this.baseUrl =
|
|
310
|
+
this.baseUrl = resolvedBaseUrl;
|
|
109
311
|
this.timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
312
|
+
const budget = options?.retryBudgetMs;
|
|
313
|
+
// NaN/negative would make every `> budget` comparison pass or fail silently.
|
|
314
|
+
this.retryBudgetMs =
|
|
315
|
+
typeof budget === "number" && Number.isFinite(budget) && budget >= 0
|
|
316
|
+
? Math.min(budget, MAX_TIMER_MS)
|
|
317
|
+
: RETRY_BUDGET_MS;
|
|
318
|
+
this.sleepFn = options?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
319
|
+
this.nowFn = options?.now ?? Date.now;
|
|
110
320
|
}
|
|
111
321
|
// ------------------------------------------------------------------ HTTP
|
|
112
322
|
// ------------------------------------------------------------------
|
|
113
|
-
async request(method, path, body, query, retryCount = 0, idempotencyKey) {
|
|
323
|
+
async request(method, path, body, query, retryCount = 0, idempotencyKey, waitedMs = 0) {
|
|
114
324
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
115
325
|
// Resolved once, at the top of the call chain: a caller-supplied key wins,
|
|
116
326
|
// otherwise mutations get a freshly minted one. The resolved value is then
|
|
@@ -143,67 +353,251 @@ export class FrihetClient {
|
|
|
143
353
|
try {
|
|
144
354
|
response = await fetch(url.toString(), {
|
|
145
355
|
method,
|
|
356
|
+
redirect: "manual",
|
|
146
357
|
headers,
|
|
147
358
|
body: body ? JSON.stringify(body) : undefined,
|
|
148
359
|
signal: controller.signal,
|
|
149
|
-
});
|
|
360
|
+
}).then(rejectRedirectResponse);
|
|
150
361
|
}
|
|
151
362
|
catch (error) {
|
|
363
|
+
clearTimeout(timeoutId);
|
|
152
364
|
const durationMs = Math.round(Date.now() - startTime);
|
|
153
365
|
if (error instanceof Error && error.name === "AbortError") {
|
|
154
366
|
logApiCall(method, path, 408, durationMs);
|
|
155
367
|
throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
|
|
156
368
|
}
|
|
157
369
|
logApiCall(method, path, 0, durationMs);
|
|
158
|
-
throw
|
|
159
|
-
}
|
|
160
|
-
finally {
|
|
161
|
-
clearTimeout(timeoutId);
|
|
370
|
+
throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
|
|
162
371
|
}
|
|
163
372
|
const durationMs = Math.round(Date.now() - startTime);
|
|
164
373
|
// Rate limit handling
|
|
165
374
|
if (response.status === 429) {
|
|
375
|
+
clearTimeout(timeoutId);
|
|
166
376
|
logApiCall(method, path, 429, durationMs);
|
|
167
377
|
if (retryCount >= MAX_RETRIES) {
|
|
168
378
|
throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
|
|
169
379
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return this.request(method, path, body, query, retryCount + 1, resolvedIdempotencyKey);
|
|
380
|
+
// Release the discarded 429 body before waiting so the socket is freed.
|
|
381
|
+
// (requestDocument has already drained it in readBoundedBody.)
|
|
382
|
+
await response.body?.cancel().catch(() => undefined);
|
|
383
|
+
const delayMs = this.rateLimitDelayMs(response.headers.get("Retry-After"), retryCount, waitedMs, method, path);
|
|
384
|
+
await this.sleepFn(delayMs);
|
|
385
|
+
return this.request(method, path, body, query, retryCount + 1, resolvedIdempotencyKey, waitedMs + delayMs);
|
|
177
386
|
}
|
|
178
387
|
// Error responses
|
|
179
388
|
if (!response.ok) {
|
|
180
389
|
logApiCall(method, path, response.status, durationMs);
|
|
181
390
|
let errorBody;
|
|
182
391
|
try {
|
|
183
|
-
errorBody = (await response.json());
|
|
392
|
+
errorBody = normalizeApiError(await response.json(), response.status, response.statusText);
|
|
184
393
|
}
|
|
185
394
|
catch {
|
|
186
|
-
errorBody =
|
|
187
|
-
error: `http_${response.status}`,
|
|
188
|
-
message: response.statusText,
|
|
189
|
-
};
|
|
395
|
+
errorBody = normalizeApiError(undefined, response.status, response.statusText);
|
|
190
396
|
}
|
|
191
|
-
|
|
397
|
+
clearTimeout(timeoutId);
|
|
398
|
+
throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined, errorBody.requestId ?? safeRequestId(response.headers.get("x-request-id")));
|
|
192
399
|
}
|
|
193
400
|
logApiCall(method, path, response.status, durationMs);
|
|
194
401
|
// 204 No Content (e.g. DELETE)
|
|
195
402
|
if (response.status === 204) {
|
|
403
|
+
clearTimeout(timeoutId);
|
|
196
404
|
return undefined;
|
|
197
405
|
}
|
|
198
|
-
|
|
406
|
+
let data;
|
|
407
|
+
try {
|
|
408
|
+
// Keep the request AbortSignal live through body consumption. A server
|
|
409
|
+
// can send 2xx headers after committing a write and then stall or truncate
|
|
410
|
+
// the JSON body; clearing the timer at headers would let this hang forever
|
|
411
|
+
// and make a committed mutation look safely retryable.
|
|
412
|
+
data = await response.json();
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
clearTimeout(timeoutId);
|
|
416
|
+
const timedOut = error instanceof Error && error.name === "AbortError";
|
|
417
|
+
throw new FrihetApiError(timedOut ? 408 : response.status, timedOut ? "response_timeout" : "invalid_response_after_success", timedOut
|
|
418
|
+
? `Response body timed out after ${this.timeoutMs / 1000} seconds`
|
|
419
|
+
: "Frihet returned an unreadable success response after processing the request.");
|
|
420
|
+
}
|
|
421
|
+
clearTimeout(timeoutId);
|
|
199
422
|
// Basic response validation
|
|
200
423
|
if (data === null || data === undefined) {
|
|
201
|
-
throw new FrihetApiError(response.status,
|
|
424
|
+
throw new FrihetApiError(response.status, "invalid_response_after_success", "Frihet returned an empty success response after processing the request.");
|
|
202
425
|
}
|
|
203
426
|
return data;
|
|
204
427
|
}
|
|
205
|
-
|
|
206
|
-
|
|
428
|
+
/** Fetch a raw response. The caller owns timeout and body consumption. */
|
|
429
|
+
async fetchRaw(method, path, body, query, idempotencyKey, signal) {
|
|
430
|
+
const resolvedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) ??
|
|
431
|
+
(MUTATING_METHODS.has(method) ? newIdempotencyKey() : undefined);
|
|
432
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
433
|
+
if (query) {
|
|
434
|
+
for (const [key, value] of Object.entries(query)) {
|
|
435
|
+
if (value !== undefined)
|
|
436
|
+
url.searchParams.set(key, String(value));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const headers = {
|
|
440
|
+
"X-API-Key": this.apiKey,
|
|
441
|
+
"Content-Type": "application/json",
|
|
442
|
+
// CRITICAL: do NOT set `Accept: application/json` here. The PDF / XML
|
|
443
|
+
// endpoints must be allowed to return their native content type;
|
|
444
|
+
// forcing `Accept: application/json` would make the server negotiate
|
|
445
|
+
// an error envelope instead of the document bytes.
|
|
446
|
+
Accept: "*/*",
|
|
447
|
+
"X-Frihet-Source": SOURCE_MARKER,
|
|
448
|
+
"User-Agent": SOURCE_USER_AGENT,
|
|
449
|
+
};
|
|
450
|
+
if (resolvedIdempotencyKey) {
|
|
451
|
+
headers["Idempotency-Key"] = resolvedIdempotencyKey;
|
|
452
|
+
}
|
|
453
|
+
return fetch(url.toString(), {
|
|
454
|
+
method,
|
|
455
|
+
redirect: "manual",
|
|
456
|
+
headers,
|
|
457
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
458
|
+
signal,
|
|
459
|
+
}).then(rejectRedirectResponse);
|
|
460
|
+
}
|
|
461
|
+
/** Consume one response body without ever retaining more than `maxBytes`. */
|
|
462
|
+
async readBoundedBody(response, maxBytes, controller) {
|
|
463
|
+
const declaredLengthRaw = response.headers.get("content-length")?.trim();
|
|
464
|
+
const declaredLength = declaredLengthRaw && /^\d+$/.test(declaredLengthRaw)
|
|
465
|
+
? Number(declaredLengthRaw)
|
|
466
|
+
: Number.NaN;
|
|
467
|
+
if (Number.isSafeInteger(declaredLength) && declaredLength > maxBytes) {
|
|
468
|
+
controller.abort();
|
|
469
|
+
try {
|
|
470
|
+
await response.body?.cancel();
|
|
471
|
+
}
|
|
472
|
+
catch { /* best effort */ }
|
|
473
|
+
throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes (Content-Length: ${declaredLength})`);
|
|
474
|
+
}
|
|
475
|
+
if (!response.body) {
|
|
476
|
+
throw new FrihetApiError(response.status, "invalid_response", "Response body is null");
|
|
477
|
+
}
|
|
478
|
+
const reader = response.body.getReader();
|
|
479
|
+
const chunks = [];
|
|
480
|
+
let totalBytes = 0;
|
|
481
|
+
try {
|
|
482
|
+
while (true) {
|
|
483
|
+
const { value, done } = await reader.read();
|
|
484
|
+
if (done)
|
|
485
|
+
break;
|
|
486
|
+
if (!value)
|
|
487
|
+
continue;
|
|
488
|
+
totalBytes += value.byteLength;
|
|
489
|
+
if (totalBytes > maxBytes) {
|
|
490
|
+
controller.abort();
|
|
491
|
+
try {
|
|
492
|
+
await reader.cancel("document response exceeded size limit");
|
|
493
|
+
}
|
|
494
|
+
catch { /* best effort */ }
|
|
495
|
+
throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes during streaming`);
|
|
496
|
+
}
|
|
497
|
+
chunks.push(value);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error instanceof FrihetApiError || isAbortError(error))
|
|
502
|
+
throw error;
|
|
503
|
+
try {
|
|
504
|
+
await reader.cancel("document stream failed");
|
|
505
|
+
}
|
|
506
|
+
catch { /* best effort */ }
|
|
507
|
+
controller.abort();
|
|
508
|
+
throw new FrihetApiError(response.status, "stream_failed", `Failed while reading document response: ${error instanceof Error ? error.message : String(error)}`);
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
reader.releaseLock();
|
|
512
|
+
}
|
|
513
|
+
const flat = new Uint8Array(totalBytes);
|
|
514
|
+
let offset = 0;
|
|
515
|
+
for (const chunk of chunks) {
|
|
516
|
+
flat.set(chunk, offset);
|
|
517
|
+
offset += chunk.byteLength;
|
|
518
|
+
}
|
|
519
|
+
return flat;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Bounded document fetch — ONE call, ONE Response, dispatch on Content-Type.
|
|
523
|
+
*
|
|
524
|
+
* Success and error bodies use the same bounded reader. The abort timer stays
|
|
525
|
+
* active until the body is complete, and a normal success performs one GET.
|
|
526
|
+
* A 429 may retry, matching the generic JSON client path.
|
|
527
|
+
*/
|
|
528
|
+
async requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount = 0, waitedMs = 0) {
|
|
529
|
+
const controller = new AbortController();
|
|
530
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
531
|
+
const startedAt = Date.now();
|
|
532
|
+
try {
|
|
533
|
+
const response = await this.fetchRaw(method, path, body, query, idempotencyKey, controller.signal);
|
|
534
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
535
|
+
const maxBytes = response.ok ? maxBytesForContentType(contentType) : MAX_ERROR_BYTES;
|
|
536
|
+
const bytes = await this.readBoundedBody(response, maxBytes, controller);
|
|
537
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
538
|
+
if (response.status === 429) {
|
|
539
|
+
logApiCall(method, path, 429, durationMs);
|
|
540
|
+
if (retryCount >= MAX_RETRIES) {
|
|
541
|
+
throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
|
|
542
|
+
}
|
|
543
|
+
clearTimeout(timeoutId);
|
|
544
|
+
const delayMs = this.rateLimitDelayMs(response.headers.get("Retry-After"), retryCount, waitedMs, method, path);
|
|
545
|
+
await this.sleepFn(delayMs);
|
|
546
|
+
return this.requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount + 1, waitedMs + delayMs);
|
|
547
|
+
}
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
logApiCall(method, path, response.status, durationMs);
|
|
550
|
+
let errorBody = normalizeApiError(undefined, response.status, response.statusText);
|
|
551
|
+
try {
|
|
552
|
+
errorBody = normalizeApiError(JSON.parse(decodeUtf8(bytes, "API error response")), response.status, response.statusText);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (error instanceof FrihetApiError && error.errorCode !== "invalid_response")
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined, errorBody.requestId ?? safeRequestId(response.headers.get("x-request-id")));
|
|
559
|
+
}
|
|
560
|
+
logApiCall(method, path, response.status, durationMs);
|
|
561
|
+
const filename = attachmentFilename(response.headers.get("content-disposition"));
|
|
562
|
+
return {
|
|
563
|
+
contentType,
|
|
564
|
+
bytes,
|
|
565
|
+
sizeBytes: bytes.byteLength,
|
|
566
|
+
...(filename ? { filename } : {}),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
if (isAbortError(error)) {
|
|
571
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
572
|
+
logApiCall(method, path, 408, durationMs);
|
|
573
|
+
throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
|
|
574
|
+
}
|
|
575
|
+
if (error instanceof FrihetApiError)
|
|
576
|
+
throw error;
|
|
577
|
+
const durationMs = Math.round(Date.now() - startedAt);
|
|
578
|
+
logApiCall(method, path, 0, durationMs);
|
|
579
|
+
throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
clearTimeout(timeoutId);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Wait before the next 429 retry: the server's `Retry-After` when it parses
|
|
587
|
+
* (RFC 9110 §10.2.3), otherwise exponential backoff. Either wait that does
|
|
588
|
+
* not fit the remaining cumulative budget is deferred to the caller; a server
|
|
589
|
+
* wait is never shortened into an early retry.
|
|
590
|
+
*/
|
|
591
|
+
rateLimitDelayMs(retryAfter, retryCount, waitedMs, method, path) {
|
|
592
|
+
const serverMs = parseRetryAfter(retryAfter, this.nowFn());
|
|
593
|
+
const delayMs = serverMs ?? DEFAULT_RETRY_DELAY_MS * Math.pow(2, retryCount);
|
|
594
|
+
if (waitedMs + delayMs > this.retryBudgetMs) {
|
|
595
|
+
throw new FrihetApiError(429, "rate_limit_deferred", serverMs !== null
|
|
596
|
+
? `Rate limited: the server asks to wait ${Math.ceil(serverMs / 1000)} seconds, which exceeds this operation's retry budget. Retry after that time; nothing was retried early.`
|
|
597
|
+
: "Rate limited: the next backoff wait exceeds this operation's retry budget. Retry later.");
|
|
598
|
+
}
|
|
599
|
+
logRetry(method, path, retryCount, delayMs, serverMs === null ? "backoff" : "retry_after");
|
|
600
|
+
return delayMs;
|
|
207
601
|
}
|
|
208
602
|
/**
|
|
209
603
|
* Wrapper for endpoints whose backend wraps the payload in a `{ data, meta }`
|
|
@@ -220,15 +614,20 @@ export class FrihetClient {
|
|
|
220
614
|
* output schema (id/clientName/items/success all read as `undefined`). This
|
|
221
615
|
* unwraps to `body.data`. Every create/update/action mutation routes through
|
|
222
616
|
* here for exactly this reason; single-object reads (`getInvoice`/…) have since
|
|
223
|
-
* #64. `deleteX` methods
|
|
224
|
-
*
|
|
225
|
-
*
|
|
617
|
+
* #64. Most `deleteX` methods keep `request` (they return `void` — the tool
|
|
618
|
+
* discards the body and synthesizes `{ success, id }`, so there is nothing to
|
|
619
|
+
* unwrap). `deleteInvoice`/`deleteQuote` are the EXCEPTION and route through
|
|
620
|
+
* here: their backend answers 204 for a real delete but 200 + `{ data, meta }`
|
|
621
|
+
* when it soft-CANCELS a non-draft document instead (see those methods).
|
|
622
|
+
* Only unwraps a non-array-object `data` (see guard below), so an
|
|
226
623
|
* array-`data` list envelope and any non-enveloped body pass through unchanged.
|
|
624
|
+
* A 204 yields `undefined` from `request`, which falls through untouched — that
|
|
625
|
+
* `undefined` is what tells the delete tools "destroyed, not cancelled".
|
|
227
626
|
*
|
|
228
627
|
* Only unwraps when the body is an object carrying a `data` property that is
|
|
229
628
|
* itself a (non-array) object — i.e. a genuine single-object envelope. It is
|
|
230
629
|
* deliberately distinct from {@link requestPaginated}, which keeps the
|
|
231
|
-
*
|
|
630
|
+
* list rows and pagination fields while omitting transport metadata. If the body is
|
|
232
631
|
* not an envelope (legacy endpoints that return the item directly), it is
|
|
233
632
|
* returned unchanged so existing callers keep working.
|
|
234
633
|
*/
|
|
@@ -248,13 +647,21 @@ export class FrihetClient {
|
|
|
248
647
|
}
|
|
249
648
|
return raw;
|
|
250
649
|
}
|
|
251
|
-
/**
|
|
650
|
+
/** Validate list rows and emit only the declared MCP pagination envelope. */
|
|
252
651
|
async requestPaginated(method, path, body, query) {
|
|
253
652
|
const result = await this.request(method, path, body, query);
|
|
254
653
|
if (!result || !Array.isArray(result.data)) {
|
|
255
654
|
throw new FrihetApiError(200, 'invalid_response', 'API returned invalid paginated response');
|
|
256
655
|
}
|
|
257
|
-
|
|
656
|
+
// API transport metadata is not part of the closed MCP page contract.
|
|
657
|
+
// Keep rows untouched and preserve every declared pagination field.
|
|
658
|
+
return {
|
|
659
|
+
data: result.data,
|
|
660
|
+
total: result.total,
|
|
661
|
+
limit: result.limit,
|
|
662
|
+
offset: result.offset,
|
|
663
|
+
...(result.nextCursor !== undefined ? { nextCursor: result.nextCursor } : {}),
|
|
664
|
+
};
|
|
258
665
|
}
|
|
259
666
|
// ---------------------------------------------------------------- Public
|
|
260
667
|
// ----------------------------------------------------------------
|
|
@@ -274,16 +681,30 @@ export class FrihetClient {
|
|
|
274
681
|
});
|
|
275
682
|
}
|
|
276
683
|
async getInvoice(id) {
|
|
277
|
-
return this.requestUnwrapped("GET", `/invoices/${
|
|
684
|
+
return this.requestUnwrapped("GET", `/invoices/${pathSegment(id)}`);
|
|
278
685
|
}
|
|
279
686
|
async createInvoice(data) {
|
|
280
687
|
return this.requestUnwrapped("POST", "/invoices", data);
|
|
281
688
|
}
|
|
282
689
|
async updateInvoice(id, data) {
|
|
283
|
-
return this.requestUnwrapped("PATCH", `/invoices/${
|
|
690
|
+
return this.requestUnwrapped("PATCH", `/invoices/${pathSegment(id)}`, data);
|
|
284
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* DELETE /invoices/{id} — NOT an unconditional destroy.
|
|
694
|
+
*
|
|
695
|
+
* The backend refuses to destroy a non-draft invoice (VeriFactu hash-chain
|
|
696
|
+
* integrity) and soft-CANCELS it instead, and it distinguishes the two
|
|
697
|
+
* outcomes on the wire (`publicApi.ts` DELETE branch):
|
|
698
|
+
* - 204 No Content → the draft row was really removed
|
|
699
|
+
* - 200 + `{ data: { id, status: 'cancelled', previousStatus,
|
|
700
|
+
* cancelledVia }, meta }` → the document still exists, now cancelled
|
|
701
|
+
*
|
|
702
|
+
* This used to be typed `Promise<void>` and the body was thrown away, so the
|
|
703
|
+
* tool reported "deleted successfully" for a document that was still there.
|
|
704
|
+
* Resolves to the unwrapped soft-cancel payload on 200, `undefined` on 204.
|
|
705
|
+
*/
|
|
285
706
|
async deleteInvoice(id) {
|
|
286
|
-
return this.
|
|
707
|
+
return this.requestUnwrapped("DELETE", `/invoices/${pathSegment(id)}`);
|
|
287
708
|
}
|
|
288
709
|
async searchInvoices(query, params) {
|
|
289
710
|
return this.requestPaginated("GET", "/invoices", undefined, {
|
|
@@ -312,16 +733,16 @@ export class FrihetClient {
|
|
|
312
733
|
});
|
|
313
734
|
}
|
|
314
735
|
async getExpense(id) {
|
|
315
|
-
return this.requestUnwrapped("GET", `/expenses/${
|
|
736
|
+
return this.requestUnwrapped("GET", `/expenses/${pathSegment(id)}`);
|
|
316
737
|
}
|
|
317
738
|
async createExpense(data) {
|
|
318
739
|
return this.requestUnwrapped("POST", "/expenses", data);
|
|
319
740
|
}
|
|
320
741
|
async updateExpense(id, data) {
|
|
321
|
-
return this.requestUnwrapped("PATCH", `/expenses/${
|
|
742
|
+
return this.requestUnwrapped("PATCH", `/expenses/${pathSegment(id)}`, data);
|
|
322
743
|
}
|
|
323
744
|
async deleteExpense(id) {
|
|
324
|
-
return this.request("DELETE", `/expenses/${
|
|
745
|
+
return this.request("DELETE", `/expenses/${pathSegment(id)}`);
|
|
325
746
|
}
|
|
326
747
|
// ---------------------------------------------------------------- Clients
|
|
327
748
|
// ----------------------------------------------------------------
|
|
@@ -336,16 +757,16 @@ export class FrihetClient {
|
|
|
336
757
|
});
|
|
337
758
|
}
|
|
338
759
|
async getClient(id) {
|
|
339
|
-
return this.requestUnwrapped("GET", `/clients/${
|
|
760
|
+
return this.requestUnwrapped("GET", `/clients/${pathSegment(id)}`);
|
|
340
761
|
}
|
|
341
762
|
async createClient(data) {
|
|
342
763
|
return this.requestUnwrapped("POST", "/clients", data);
|
|
343
764
|
}
|
|
344
765
|
async updateClient(id, data) {
|
|
345
|
-
return this.requestUnwrapped("PATCH", `/clients/${
|
|
766
|
+
return this.requestUnwrapped("PATCH", `/clients/${pathSegment(id)}`, data);
|
|
346
767
|
}
|
|
347
768
|
async deleteClient(id) {
|
|
348
|
-
return this.request("DELETE", `/clients/${
|
|
769
|
+
return this.request("DELETE", `/clients/${pathSegment(id)}`);
|
|
349
770
|
}
|
|
350
771
|
// ---------------------------------------------------------------- Products
|
|
351
772
|
// ----------------------------------------------------------------
|
|
@@ -356,20 +777,22 @@ export class FrihetClient {
|
|
|
356
777
|
cursor: params?.after,
|
|
357
778
|
fields: params?.fields,
|
|
358
779
|
q: params?.q,
|
|
359
|
-
|
|
780
|
+
// Public API accepts the literal strings "true"/"false". Numeric 1/0
|
|
781
|
+
// makes `isActive=true` evaluate as false in the backend parser.
|
|
782
|
+
isActive: params?.isActive !== undefined ? String(params.isActive) : undefined,
|
|
360
783
|
});
|
|
361
784
|
}
|
|
362
785
|
async getProduct(id) {
|
|
363
|
-
return this.requestUnwrapped("GET", `/products/${
|
|
786
|
+
return this.requestUnwrapped("GET", `/products/${pathSegment(id)}`);
|
|
364
787
|
}
|
|
365
788
|
async createProduct(data) {
|
|
366
789
|
return this.requestUnwrapped("POST", "/products", data);
|
|
367
790
|
}
|
|
368
791
|
async updateProduct(id, data) {
|
|
369
|
-
return this.requestUnwrapped("PATCH", `/products/${
|
|
792
|
+
return this.requestUnwrapped("PATCH", `/products/${pathSegment(id)}`, data);
|
|
370
793
|
}
|
|
371
794
|
async deleteProduct(id) {
|
|
372
|
-
return this.request("DELETE", `/products/${
|
|
795
|
+
return this.request("DELETE", `/products/${pathSegment(id)}`);
|
|
373
796
|
}
|
|
374
797
|
// ---------------------------------------------------------------- Quotes
|
|
375
798
|
// ----------------------------------------------------------------
|
|
@@ -387,16 +810,22 @@ export class FrihetClient {
|
|
|
387
810
|
});
|
|
388
811
|
}
|
|
389
812
|
async getQuote(id) {
|
|
390
|
-
return this.requestUnwrapped("GET", `/quotes/${
|
|
813
|
+
return this.requestUnwrapped("GET", `/quotes/${pathSegment(id)}`);
|
|
391
814
|
}
|
|
392
815
|
async createQuote(data) {
|
|
393
816
|
return this.requestUnwrapped("POST", "/quotes", data);
|
|
394
817
|
}
|
|
395
818
|
async updateQuote(id, data) {
|
|
396
|
-
return this.requestUnwrapped("PATCH", `/quotes/${
|
|
819
|
+
return this.requestUnwrapped("PATCH", `/quotes/${pathSegment(id)}`, data);
|
|
397
820
|
}
|
|
821
|
+
/**
|
|
822
|
+
* DELETE /quotes/{id} — same two-outcome contract as {@link deleteInvoice}:
|
|
823
|
+
* the backend soft-CANCELS a non-draft quote (200 + body), destroys only a
|
|
824
|
+
* clean draft with no delivery/response/attachment/conversion evidence (204),
|
|
825
|
+
* and refuses a protected draft with 409.
|
|
826
|
+
*/
|
|
398
827
|
async deleteQuote(id) {
|
|
399
|
-
return this.
|
|
828
|
+
return this.requestUnwrapped("DELETE", `/quotes/${pathSegment(id)}`);
|
|
400
829
|
}
|
|
401
830
|
// ---------------------------------------------------------------- Vendors
|
|
402
831
|
// ----------------------------------------------------------------
|
|
@@ -410,35 +839,96 @@ export class FrihetClient {
|
|
|
410
839
|
});
|
|
411
840
|
}
|
|
412
841
|
async getVendor(id) {
|
|
413
|
-
return this.requestUnwrapped("GET", `/vendors/${
|
|
842
|
+
return this.requestUnwrapped("GET", `/vendors/${pathSegment(id)}`);
|
|
414
843
|
}
|
|
415
844
|
async createVendor(data) {
|
|
416
845
|
return this.requestUnwrapped("POST", "/vendors", data);
|
|
417
846
|
}
|
|
418
847
|
async updateVendor(id, data) {
|
|
419
|
-
return this.requestUnwrapped("PATCH", `/vendors/${
|
|
848
|
+
return this.requestUnwrapped("PATCH", `/vendors/${pathSegment(id)}`, data);
|
|
420
849
|
}
|
|
421
850
|
async deleteVendor(id) {
|
|
422
|
-
return this.request("DELETE", `/vendors/${
|
|
851
|
+
return this.request("DELETE", `/vendors/${pathSegment(id)}`);
|
|
423
852
|
}
|
|
424
853
|
// ---------------------------------------------------------------- Invoice Actions
|
|
425
854
|
// ----------------------------------------------------------------
|
|
426
855
|
async sendInvoice(id, to) {
|
|
427
|
-
return this.requestUnwrapped("POST", `/invoices/${
|
|
856
|
+
return this.requestUnwrapped("POST", `/invoices/${pathSegment(id)}/send`, to ? { recipientEmail: to } : undefined);
|
|
428
857
|
}
|
|
429
858
|
async markInvoicePaid(id, paidDate) {
|
|
430
|
-
return this.requestUnwrapped("POST", `/invoices/${
|
|
859
|
+
return this.requestUnwrapped("POST", `/invoices/${pathSegment(id)}/paid`, paidDate ? { paidDate } : undefined);
|
|
431
860
|
}
|
|
432
861
|
async getInvoicePdf(id) {
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
862
|
+
// One fetch, one Response: validate MIME and signature only after the
|
|
863
|
+
// bounded stream completes. The ERP endpoint has always returned raw bytes
|
|
864
|
+
// (functions/src/publicApi.ts:6149-6167); there is no pre-signed-URL or JSON
|
|
865
|
+
// success contract to emulate.
|
|
866
|
+
const url = `/invoices/${pathSegment(id)}/pdf`;
|
|
867
|
+
const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => {
|
|
868
|
+
const norm = normalizedContentType(contentType);
|
|
869
|
+
return norm === "application/pdf"
|
|
870
|
+
|| norm === "application/octet-stream"
|
|
871
|
+
|| norm === "binary/octet-stream"
|
|
872
|
+
? MAX_PDF_BYTES
|
|
873
|
+
: MAX_ERROR_BYTES;
|
|
874
|
+
});
|
|
875
|
+
const normType = normalizedContentType(doc.contentType);
|
|
876
|
+
if (normType !== "application/pdf" && normType !== "application/octet-stream" && normType !== "binary/octet-stream") {
|
|
877
|
+
throw new FrihetApiError(200, "invalid_response", `Invoice PDF endpoint returned unexpected Content-Type: ${doc.contentType}`);
|
|
878
|
+
}
|
|
879
|
+
if (!hasPdfSignature(doc.bytes)) {
|
|
880
|
+
throw new FrihetApiError(200, "invalid_response", "Invoice PDF response is malformed");
|
|
881
|
+
}
|
|
882
|
+
if (doc.sizeBytes > MAX_PDF_BYTES) {
|
|
883
|
+
throw new FrihetApiError(413, "payload_too_large", `Invoice PDF exceeds maximum allowed size of ${MAX_PDF_BYTES} bytes`);
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
id,
|
|
887
|
+
contentType: "application/pdf",
|
|
888
|
+
sizeBytes: doc.sizeBytes,
|
|
889
|
+
base64: encodeBase64(doc.bytes),
|
|
890
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
891
|
+
};
|
|
436
892
|
}
|
|
437
893
|
async getInvoiceEInvoice(invoiceId) {
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
894
|
+
// Stored e-invoices are either XML or Factur-X PDF. Select the size cap
|
|
895
|
+
// from the actual MIME before reading and preserve the request identity.
|
|
896
|
+
const url = `/invoices/${pathSegment(invoiceId)}/xml`;
|
|
897
|
+
const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => {
|
|
898
|
+
const normalized = normalizedContentType(contentType);
|
|
899
|
+
if (normalized === "application/pdf")
|
|
900
|
+
return MAX_PDF_BYTES;
|
|
901
|
+
if (isXmlContentType(contentType))
|
|
902
|
+
return MAX_XML_BYTES;
|
|
903
|
+
return MAX_ERROR_BYTES;
|
|
904
|
+
});
|
|
905
|
+
const normalized = normalizedContentType(doc.contentType);
|
|
906
|
+
if (normalized === "application/pdf") {
|
|
907
|
+
if (!hasPdfSignature(doc.bytes)) {
|
|
908
|
+
throw new FrihetApiError(200, "invalid_response", "Factur-X PDF response is malformed");
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
id: invoiceId,
|
|
912
|
+
contentType: doc.contentType,
|
|
913
|
+
sizeBytes: doc.sizeBytes,
|
|
914
|
+
base64: encodeBase64(doc.bytes),
|
|
915
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
if (!isXmlContentType(doc.contentType)) {
|
|
919
|
+
throw new FrihetApiError(200, "invalid_response", `E-invoice endpoint returned unexpected Content-Type: ${doc.contentType}`);
|
|
920
|
+
}
|
|
921
|
+
const xmlText = decodeUtf8(doc.bytes, "E-invoice XML response");
|
|
922
|
+
if (!xmlText.trimStart().startsWith("<")) {
|
|
923
|
+
throw new FrihetApiError(200, "invalid_response", "E-invoice XML response is malformed");
|
|
924
|
+
}
|
|
925
|
+
return {
|
|
926
|
+
id: invoiceId,
|
|
927
|
+
contentType: doc.contentType,
|
|
928
|
+
xml: xmlText,
|
|
929
|
+
sizeBytes: doc.sizeBytes,
|
|
930
|
+
...(doc.filename ? { filename: doc.filename } : {}),
|
|
931
|
+
};
|
|
442
932
|
}
|
|
443
933
|
/**
|
|
444
934
|
* `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
|
|
@@ -453,74 +943,117 @@ export class FrihetClient {
|
|
|
453
943
|
* which is the property that matters here.
|
|
454
944
|
*/
|
|
455
945
|
async createCreditNote(invoiceId, data, idempotencyKey) {
|
|
456
|
-
return this.requestUnwrapped("POST", `/invoices/${
|
|
946
|
+
return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/credit-note`, data, undefined, idempotencyKey);
|
|
457
947
|
}
|
|
458
948
|
async applyLateFee(invoiceId, data) {
|
|
459
|
-
return this.requestUnwrapped("POST", `/invoices/${
|
|
949
|
+
return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/late-fee`, data ?? {});
|
|
460
950
|
}
|
|
461
951
|
// ---------------------------------------------------------------- Quote Actions
|
|
462
952
|
// ----------------------------------------------------------------
|
|
463
953
|
async sendQuote(id, to) {
|
|
464
|
-
return this.requestUnwrapped("POST", `/quotes/${
|
|
954
|
+
return this.requestUnwrapped("POST", `/quotes/${pathSegment(id)}/send`, to ? { recipientEmail: to } : undefined);
|
|
465
955
|
}
|
|
466
956
|
// ---------------------------------------------------------------- Webhooks
|
|
467
957
|
// ----------------------------------------------------------------
|
|
468
|
-
async listWebhooks(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
958
|
+
async listWebhooks() {
|
|
959
|
+
const result = await this.request("GET", "/webhooks");
|
|
960
|
+
if (result === null ||
|
|
961
|
+
typeof result !== "object" ||
|
|
962
|
+
!("data" in result) ||
|
|
963
|
+
!("total" in result) ||
|
|
964
|
+
!Array.isArray(result.data) ||
|
|
965
|
+
typeof result.total !== "number") {
|
|
966
|
+
throw new FrihetApiError(200, "invalid_response", "API returned invalid webhook list response");
|
|
967
|
+
}
|
|
968
|
+
// `/webhooks` is deliberately unpaginated. Keep its `{ data, total }`
|
|
969
|
+
// contract and discard only the transport-level `meta` sibling.
|
|
970
|
+
return {
|
|
971
|
+
data: result.data.map((webhook) => normalizeWebhookRecord(webhook)),
|
|
972
|
+
total: result.total,
|
|
973
|
+
};
|
|
473
974
|
}
|
|
474
975
|
async getWebhook(id) {
|
|
475
|
-
|
|
976
|
+
const result = await this.requestUnwrapped("GET", `/webhooks/${pathSegment(id)}`);
|
|
977
|
+
return normalizeWebhookRecord(result);
|
|
476
978
|
}
|
|
477
979
|
async createWebhook(data) {
|
|
478
|
-
|
|
980
|
+
const result = await this.requestUnwrapped("POST", "/webhooks", data);
|
|
981
|
+
return normalizeWebhookRecord(result, typeof data.secret === "string" ? data.secret : undefined);
|
|
479
982
|
}
|
|
480
983
|
async updateWebhook(id, data) {
|
|
481
|
-
|
|
984
|
+
const result = await this.requestUnwrapped("PATCH", `/webhooks/${pathSegment(id)}`, data);
|
|
985
|
+
return normalizeWebhookRecord(result);
|
|
482
986
|
}
|
|
483
987
|
async deleteWebhook(id) {
|
|
484
|
-
return this.request("DELETE", `/webhooks/${
|
|
988
|
+
return this.request("DELETE", `/webhooks/${pathSegment(id)}`);
|
|
989
|
+
}
|
|
990
|
+
// ---------------------------------------------------------------- Cross-resource search
|
|
991
|
+
// ----------------------------------------------------------------
|
|
992
|
+
//
|
|
993
|
+
// GET /v1/search/global?q=<term>&types=a,b,c&limit=&offset= (publicApi.ts:4476)
|
|
994
|
+
//
|
|
995
|
+
// Read-only fan-out across invoices / expenses / vendors / clients / products.
|
|
996
|
+
// All five collections are workspace-scoped via users/{userId}/..., so the
|
|
997
|
+
// tenant boundary is preserved by Firestore subcollection paths — same as
|
|
998
|
+
// every other list/search endpoint on the server. No mutation counterpart;
|
|
999
|
+
// no auth boundary different from any other read tool.
|
|
1000
|
+
async globalSearch(params) {
|
|
1001
|
+
const typesCsv = params.types && params.types.length > 0 ? params.types.join(",") : undefined;
|
|
1002
|
+
const result = await this.request("GET", "/search/global", undefined, {
|
|
1003
|
+
q: params.q,
|
|
1004
|
+
types: typesCsv,
|
|
1005
|
+
limit: params.limit,
|
|
1006
|
+
offset: params.offset,
|
|
1007
|
+
});
|
|
1008
|
+
return {
|
|
1009
|
+
data: result.data,
|
|
1010
|
+
total: result.total,
|
|
1011
|
+
limit: result.limit,
|
|
1012
|
+
offset: result.offset,
|
|
1013
|
+
hasMore: result.hasMore,
|
|
1014
|
+
query: result.query,
|
|
1015
|
+
types: result.types,
|
|
1016
|
+
...(result.truncated !== undefined ? { truncated: result.truncated } : {}),
|
|
1017
|
+
};
|
|
485
1018
|
}
|
|
486
1019
|
// ---------------------------------------------------------------- CRM: Contacts
|
|
487
1020
|
// ----------------------------------------------------------------
|
|
488
1021
|
async listClientContacts(clientId, params) {
|
|
489
|
-
return this.requestPaginated("GET", `/clients/${
|
|
1022
|
+
return this.requestPaginated("GET", `/clients/${pathSegment(clientId)}/contacts`, undefined, {
|
|
490
1023
|
limit: params?.limit,
|
|
491
1024
|
offset: params?.offset,
|
|
492
1025
|
});
|
|
493
1026
|
}
|
|
494
1027
|
async createClientContact(clientId, data) {
|
|
495
|
-
return this.requestUnwrapped("POST", `/clients/${
|
|
1028
|
+
return this.requestUnwrapped("POST", `/clients/${pathSegment(clientId)}/contacts`, data);
|
|
496
1029
|
}
|
|
497
1030
|
async deleteClientContact(clientId, contactId) {
|
|
498
|
-
return this.request("DELETE", `/clients/${
|
|
1031
|
+
return this.request("DELETE", `/clients/${pathSegment(clientId)}/contacts/${pathSegment(contactId)}`);
|
|
499
1032
|
}
|
|
500
1033
|
// ---------------------------------------------------------------- CRM: Activities
|
|
501
1034
|
// ----------------------------------------------------------------
|
|
502
1035
|
async listClientActivities(clientId, params) {
|
|
503
|
-
return this.requestPaginated("GET", `/clients/${
|
|
1036
|
+
return this.requestPaginated("GET", `/clients/${pathSegment(clientId)}/activities`, undefined, {
|
|
504
1037
|
limit: params?.limit,
|
|
505
1038
|
offset: params?.offset,
|
|
506
1039
|
});
|
|
507
1040
|
}
|
|
508
1041
|
async logClientActivity(clientId, data) {
|
|
509
|
-
return this.requestUnwrapped("POST", `/clients/${
|
|
1042
|
+
return this.requestUnwrapped("POST", `/clients/${pathSegment(clientId)}/activities`, data);
|
|
510
1043
|
}
|
|
511
1044
|
// ---------------------------------------------------------------- CRM: Notes
|
|
512
1045
|
// ----------------------------------------------------------------
|
|
513
1046
|
async listClientNotes(clientId, params) {
|
|
514
|
-
return this.requestPaginated("GET", `/clients/${
|
|
1047
|
+
return this.requestPaginated("GET", `/clients/${pathSegment(clientId)}/notes`, undefined, {
|
|
515
1048
|
limit: params?.limit,
|
|
516
1049
|
offset: params?.offset,
|
|
517
1050
|
});
|
|
518
1051
|
}
|
|
519
1052
|
async createClientNote(clientId, data) {
|
|
520
|
-
return this.requestUnwrapped("POST", `/clients/${
|
|
1053
|
+
return this.requestUnwrapped("POST", `/clients/${pathSegment(clientId)}/notes`, data);
|
|
521
1054
|
}
|
|
522
1055
|
async deleteClientNote(clientId, noteId) {
|
|
523
|
-
return this.request("DELETE", `/clients/${
|
|
1056
|
+
return this.request("DELETE", `/clients/${pathSegment(clientId)}/notes/${pathSegment(noteId)}`);
|
|
524
1057
|
}
|
|
525
1058
|
// ---------------------------------------------------------------- Deposits
|
|
526
1059
|
// ----------------------------------------------------------------
|
|
@@ -537,22 +1070,22 @@ export class FrihetClient {
|
|
|
537
1070
|
});
|
|
538
1071
|
}
|
|
539
1072
|
async getDeposit(id) {
|
|
540
|
-
return this.requestUnwrapped("GET", `/deposits/${
|
|
1073
|
+
return this.requestUnwrapped("GET", `/deposits/${pathSegment(id)}`);
|
|
541
1074
|
}
|
|
542
1075
|
async createDeposit(data) {
|
|
543
|
-
return this.requestUnwrapped("POST", "/deposits", data);
|
|
1076
|
+
return this.requestUnwrapped("POST", "/deposits", normalizeDepositBody(data, false));
|
|
544
1077
|
}
|
|
545
1078
|
async updateDeposit(id, data) {
|
|
546
|
-
return this.requestUnwrapped("PATCH", `/deposits/${
|
|
1079
|
+
return this.requestUnwrapped("PATCH", `/deposits/${pathSegment(id)}`, normalizeDepositBody(data, true));
|
|
547
1080
|
}
|
|
548
1081
|
async deleteDeposit(id) {
|
|
549
|
-
return this.request("DELETE", `/deposits/${
|
|
1082
|
+
return this.request("DELETE", `/deposits/${pathSegment(id)}`);
|
|
550
1083
|
}
|
|
551
1084
|
async applyDeposit(id, data) {
|
|
552
|
-
return this.requestUnwrapped("POST", `/deposits/${
|
|
1085
|
+
return this.requestUnwrapped("POST", `/deposits/${pathSegment(id)}/apply`, normalizeApplyDepositBody(data));
|
|
553
1086
|
}
|
|
554
1087
|
async refundDeposit(id, data) {
|
|
555
|
-
return this.requestUnwrapped("POST", `/deposits/${
|
|
1088
|
+
return this.requestUnwrapped("POST", `/deposits/${pathSegment(id)}/refund`, data ?? {});
|
|
556
1089
|
}
|
|
557
1090
|
// ---------------------------------------------------------------- E-Invoicing
|
|
558
1091
|
// ----------------------------------------------------------------
|
|
@@ -563,7 +1096,7 @@ export class FrihetClient {
|
|
|
563
1096
|
// get_einvoice_status's outputSchema (eInvoiceStatusOutput) expects the
|
|
564
1097
|
// flat status object, same single-object envelope convention as the rest
|
|
565
1098
|
// of the /v1 REST surface.
|
|
566
|
-
return this.requestUnwrapped("GET", `/einvoice/status/${
|
|
1099
|
+
return this.requestUnwrapped("GET", `/einvoice/status/${pathSegment(workflowRunId)}`);
|
|
567
1100
|
}
|
|
568
1101
|
async validateEInvoiceXml(params) {
|
|
569
1102
|
return this.requestUnwrapped("POST", "/einvoice/validate", params);
|
|
@@ -576,22 +1109,26 @@ export class FrihetClient {
|
|
|
576
1109
|
// New per-invoice endpoints: /v1/invoices/:id/einvoice/export, /face/*, /ticketbai/*
|
|
577
1110
|
// 404 responses propagate to tool handlers which fall back to stub responses.
|
|
578
1111
|
async exportEInvoice(params) {
|
|
579
|
-
const { invoiceId,
|
|
580
|
-
|
|
1112
|
+
const { invoiceId, format, signed } = params;
|
|
1113
|
+
const body = {
|
|
1114
|
+
format: toApiEInvoiceExportFormat(format),
|
|
1115
|
+
...(signed === undefined ? {} : { signed }),
|
|
1116
|
+
};
|
|
1117
|
+
return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/einvoice/export`, body);
|
|
581
1118
|
}
|
|
582
1119
|
async faceSubmit(params) {
|
|
583
1120
|
const { invoiceId, mode } = params;
|
|
584
|
-
return this.requestUnwrapped("POST", `/invoices/${
|
|
1121
|
+
return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/face/submit`, { mode });
|
|
585
1122
|
}
|
|
586
1123
|
async faceStatus(params) {
|
|
587
|
-
return this.request("GET", `/invoices/${
|
|
1124
|
+
return this.request("GET", `/invoices/${pathSegment(params.invoiceId)}/face/status`);
|
|
588
1125
|
}
|
|
589
1126
|
async ticketbaiSubmit(params) {
|
|
590
1127
|
const { invoiceId, sandbox } = params;
|
|
591
|
-
return this.requestUnwrapped("POST", `/invoices/${
|
|
1128
|
+
return this.requestUnwrapped("POST", `/invoices/${pathSegment(invoiceId)}/ticketbai/submit`, { sandbox });
|
|
592
1129
|
}
|
|
593
1130
|
async ticketbaiStatus(params) {
|
|
594
|
-
return this.request("GET", `/invoices/${
|
|
1131
|
+
return this.request("GET", `/invoices/${pathSegment(params.invoiceId)}/ticketbai/status`);
|
|
595
1132
|
}
|
|
596
1133
|
// kSeFSubmit intentionally omitted — ksef_submit tool is always-stub (public KSeF endpoint not yet exposed; transport infra-ready in Frihet-ERP).
|
|
597
1134
|
// ---------------------------------------------------------------- Stay (Vacation Rental)
|
|
@@ -612,7 +1149,7 @@ export class FrihetClient {
|
|
|
612
1149
|
});
|
|
613
1150
|
}
|
|
614
1151
|
async getReservation(id) {
|
|
615
|
-
return this.requestUnwrapped("GET", `/stay/reservations/${
|
|
1152
|
+
return this.requestUnwrapped("GET", `/stay/reservations/${pathSegment(id)}`);
|
|
616
1153
|
}
|
|
617
1154
|
async createReservation(data) {
|
|
618
1155
|
return this.requestUnwrapped("POST", "/stay/reservations", data);
|
|
@@ -628,7 +1165,7 @@ export class FrihetClient {
|
|
|
628
1165
|
});
|
|
629
1166
|
}
|
|
630
1167
|
async syncChannel(channelId, direction) {
|
|
631
|
-
return this.requestUnwrapped("POST", `/stay/channels/${
|
|
1168
|
+
return this.requestUnwrapped("POST", `/stay/channels/${pathSegment(channelId)}/sync`, { direction });
|
|
632
1169
|
}
|
|
633
1170
|
// ---------------------------------------------------------------- POS (Point of Sale)
|
|
634
1171
|
// ----------------------------------------------------------------
|
|
@@ -641,7 +1178,7 @@ export class FrihetClient {
|
|
|
641
1178
|
});
|
|
642
1179
|
}
|
|
643
1180
|
async getSale(id) {
|
|
644
|
-
return this.requestUnwrapped("GET", `/pos/sales/${
|
|
1181
|
+
return this.requestUnwrapped("GET", `/pos/sales/${pathSegment(id)}`);
|
|
645
1182
|
}
|
|
646
1183
|
async listSales(params) {
|
|
647
1184
|
return this.requestPaginated("GET", "/pos/sales", undefined, {
|
|
@@ -655,7 +1192,7 @@ export class FrihetClient {
|
|
|
655
1192
|
});
|
|
656
1193
|
}
|
|
657
1194
|
async refundSale(id, data) {
|
|
658
|
-
return this.requestUnwrapped("POST", `/pos/sales/${
|
|
1195
|
+
return this.requestUnwrapped("POST", `/pos/sales/${pathSegment(id)}/refund`, data ?? {});
|
|
659
1196
|
}
|
|
660
1197
|
// ---------------------------------------------------------------- Kitchen (KDS)
|
|
661
1198
|
// ----------------------------------------------------------------
|
|
@@ -670,10 +1207,13 @@ export class FrihetClient {
|
|
|
670
1207
|
});
|
|
671
1208
|
}
|
|
672
1209
|
async getKitchenTicket(id) {
|
|
673
|
-
return this.requestUnwrapped("GET", `/kitchen/tickets/${
|
|
1210
|
+
return this.requestUnwrapped("GET", `/kitchen/tickets/${pathSegment(id)}`);
|
|
674
1211
|
}
|
|
675
1212
|
async updateKitchenTicket(id, data) {
|
|
676
|
-
|
|
1213
|
+
// The public tool accepts one station; the strict REST contract stores an array.
|
|
1214
|
+
const { stationId, ...patch } = data;
|
|
1215
|
+
const body = stationId === undefined ? patch : { ...patch, stationIds: [stationId] };
|
|
1216
|
+
return this.requestUnwrapped("PATCH", `/kitchen/tickets/${pathSegment(id)}`, body);
|
|
677
1217
|
}
|
|
678
1218
|
async listKitchenStations(params) {
|
|
679
1219
|
return this.requestPaginated("GET", "/kitchen/stations", undefined, {
|
|
@@ -716,7 +1256,7 @@ export class FrihetClient {
|
|
|
716
1256
|
});
|
|
717
1257
|
}
|
|
718
1258
|
async getBankAccount(id) {
|
|
719
|
-
return this.requestUnwrapped("GET", `/banking/accounts/${
|
|
1259
|
+
return this.requestUnwrapped("GET", `/banking/accounts/${pathSegment(id)}`);
|
|
720
1260
|
}
|
|
721
1261
|
async listTransactions(params) {
|
|
722
1262
|
return this.requestPaginated("GET", "/banking/transactions", undefined, {
|
|
@@ -731,10 +1271,10 @@ export class FrihetClient {
|
|
|
731
1271
|
});
|
|
732
1272
|
}
|
|
733
1273
|
async categorizeTransaction(id, data) {
|
|
734
|
-
return this.requestUnwrapped("PATCH", `/banking/transactions/${
|
|
1274
|
+
return this.requestUnwrapped("PATCH", `/banking/transactions/${pathSegment(id)}/categorize`, data);
|
|
735
1275
|
}
|
|
736
1276
|
async matchTransactionToDocument(transactionId, data) {
|
|
737
|
-
return this.requestUnwrapped("POST", `/banking/transactions/${
|
|
1277
|
+
return this.requestUnwrapped("POST", `/banking/transactions/${pathSegment(transactionId)}/match`, data);
|
|
738
1278
|
}
|
|
739
1279
|
// ---------------------------------------------------------------- Fiscal
|
|
740
1280
|
// RESOLVED (was: "planned — 404 propagates until backend ships"). The
|
|
@@ -746,18 +1286,19 @@ export class FrihetClient {
|
|
|
746
1286
|
// NOTE: /v1/fiscal/verifactu/* + /ticketbai/* below remain pending and 404
|
|
747
1287
|
// until those backends ship (they keep the raw `request` path for now).
|
|
748
1288
|
async getFiscalModeloSummary(modeloCode, period) {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1289
|
+
// The backend reads `?quarter=` (303/130) or `?year=` (390/347) and
|
|
1290
|
+
// silently answers the CURRENT period for any other param, so the param
|
|
1291
|
+
// name comes from the shared rule table, never a generic `?period=`.
|
|
1292
|
+
return this.requestUnwrapped("GET", `/fiscal/modelo/${pathSegment(modeloCode)}`, undefined, fiscalModeloQuery(modeloCode, period));
|
|
752
1293
|
}
|
|
753
1294
|
async getVerifactuStatus(invoiceId) {
|
|
754
|
-
return this.requestUnwrapped("GET", `/fiscal/verifactu/${
|
|
1295
|
+
return this.requestUnwrapped("GET", `/fiscal/verifactu/${pathSegment(invoiceId)}/status`);
|
|
755
1296
|
}
|
|
756
1297
|
async resubmitVerifactu(invoiceId) {
|
|
757
|
-
return this.requestUnwrapped("POST", `/fiscal/verifactu/${
|
|
1298
|
+
return this.requestUnwrapped("POST", `/fiscal/verifactu/${pathSegment(invoiceId)}/resubmit`, {});
|
|
758
1299
|
}
|
|
759
1300
|
async getTicketbaiStatus(invoiceId) {
|
|
760
|
-
return this.request("GET", `/fiscal/ticketbai/${
|
|
1301
|
+
return this.request("GET", `/fiscal/ticketbai/${pathSegment(invoiceId)}/status`);
|
|
761
1302
|
}
|
|
762
1303
|
// ---------------------------------------------------------------- Time Tracking
|
|
763
1304
|
// Backend: /v1/time/* endpoints live as of Wave 4-A (Frihet-ERP functions/src/publicApi.ts).
|
|
@@ -774,16 +1315,16 @@ export class FrihetClient {
|
|
|
774
1315
|
});
|
|
775
1316
|
}
|
|
776
1317
|
async getTimeEntry(id) {
|
|
777
|
-
return this.requestUnwrapped("GET", `/time/entries/${
|
|
1318
|
+
return this.requestUnwrapped("GET", `/time/entries/${pathSegment(id)}`);
|
|
778
1319
|
}
|
|
779
1320
|
async createTimeEntry(data) {
|
|
780
1321
|
return this.requestUnwrapped("POST", "/time/entries", data);
|
|
781
1322
|
}
|
|
782
1323
|
async updateTimeEntry(id, data) {
|
|
783
|
-
return this.requestUnwrapped("PATCH", `/time/entries/${
|
|
1324
|
+
return this.requestUnwrapped("PATCH", `/time/entries/${pathSegment(id)}`, data);
|
|
784
1325
|
}
|
|
785
1326
|
async deleteTimeEntry(id) {
|
|
786
|
-
return this.request("DELETE", `/time/entries/${
|
|
1327
|
+
return this.request("DELETE", `/time/entries/${pathSegment(id)}`);
|
|
787
1328
|
}
|
|
788
1329
|
async getTimeSummary(params) {
|
|
789
1330
|
return this.requestUnwrapped("GET", "/time/summary", undefined, {
|
|
@@ -804,25 +1345,25 @@ export class FrihetClient {
|
|
|
804
1345
|
});
|
|
805
1346
|
}
|
|
806
1347
|
async getRecurringInvoice(id) {
|
|
807
|
-
return this.requestUnwrapped("GET", `/recurring/invoices/${
|
|
1348
|
+
return this.requestUnwrapped("GET", `/recurring/invoices/${pathSegment(id)}`);
|
|
808
1349
|
}
|
|
809
1350
|
async createRecurringInvoice(data) {
|
|
810
1351
|
return this.requestUnwrapped("POST", "/recurring/invoices", data);
|
|
811
1352
|
}
|
|
812
1353
|
async updateRecurringInvoice(id, data) {
|
|
813
|
-
return this.requestUnwrapped("PATCH", `/recurring/invoices/${
|
|
1354
|
+
return this.requestUnwrapped("PATCH", `/recurring/invoices/${pathSegment(id)}`, data);
|
|
814
1355
|
}
|
|
815
1356
|
async pauseRecurringInvoice(id) {
|
|
816
|
-
return this.requestUnwrapped("POST", `/recurring/invoices/${
|
|
1357
|
+
return this.requestUnwrapped("POST", `/recurring/invoices/${pathSegment(id)}/pause`, {});
|
|
817
1358
|
}
|
|
818
1359
|
async resumeRecurringInvoice(id) {
|
|
819
|
-
return this.requestUnwrapped("POST", `/recurring/invoices/${
|
|
1360
|
+
return this.requestUnwrapped("POST", `/recurring/invoices/${pathSegment(id)}/resume`, {});
|
|
820
1361
|
}
|
|
821
1362
|
async deleteRecurringInvoice(id) {
|
|
822
|
-
return this.request("DELETE", `/recurring/invoices/${
|
|
1363
|
+
return this.request("DELETE", `/recurring/invoices/${pathSegment(id)}`);
|
|
823
1364
|
}
|
|
824
1365
|
async runRecurringNow(templateId, options) {
|
|
825
|
-
return this.requestUnwrapped("POST", `/recurring/invoices/${
|
|
1366
|
+
return this.requestUnwrapped("POST", `/recurring/invoices/${pathSegment(templateId)}/run`, {
|
|
826
1367
|
draftOnly: options?.draftOnly ?? true,
|
|
827
1368
|
});
|
|
828
1369
|
}
|
|
@@ -840,10 +1381,10 @@ export class FrihetClient {
|
|
|
840
1381
|
return this.requestUnwrapped("POST", "/team/members/invite", data);
|
|
841
1382
|
}
|
|
842
1383
|
async updateTeamMemberRole(memberId, role) {
|
|
843
|
-
return this.requestUnwrapped("PATCH", `/team/members/${
|
|
1384
|
+
return this.requestUnwrapped("PATCH", `/team/members/${pathSegment(memberId)}/role`, { role });
|
|
844
1385
|
}
|
|
845
1386
|
async removeTeamMember(memberId) {
|
|
846
|
-
return this.request("DELETE", `/team/members/${
|
|
1387
|
+
return this.request("DELETE", `/team/members/${pathSegment(memberId)}`);
|
|
847
1388
|
}
|
|
848
1389
|
// ---------------------------------------------------------------- Gestoria (Wave Fase 1)
|
|
849
1390
|
// Backend: /v1/gestoria/* endpoints land with Frihet-ERP Wave Fase 1 closure.
|
|
@@ -869,20 +1410,22 @@ export class FrihetClient {
|
|
|
869
1410
|
return this.requestUnwrapped("POST", "/gestoria/templates/bulk-send", data);
|
|
870
1411
|
}
|
|
871
1412
|
async getGestoriaAgingConsolidated(params) {
|
|
872
|
-
return this.
|
|
873
|
-
|
|
1413
|
+
return this.requestUnwrapped("POST", "/gestoria/aging", {
|
|
1414
|
+
workspaceIds: params?.workspaceIds,
|
|
1415
|
+
asOf: params?.asOf,
|
|
1416
|
+
bustCache: params?.bustCache,
|
|
874
1417
|
});
|
|
875
1418
|
}
|
|
876
1419
|
// ---------------------------------------------------------------- Audit GL
|
|
877
1420
|
// NOTE: /v1/gl/* endpoints proxy Firebase callables (PR #395). 404 until REST shell ships.
|
|
878
1421
|
async approveGLEntry(entryId, notes) {
|
|
879
|
-
return this.requestUnwrapped("POST", `/gl/entries/${
|
|
1422
|
+
return this.requestUnwrapped("POST", `/gl/entries/${pathSegment(entryId)}/approve`, { notes });
|
|
880
1423
|
}
|
|
881
|
-
async rejectGLEntry(entryId,
|
|
882
|
-
return this.requestUnwrapped("POST", `/gl/entries/${
|
|
1424
|
+
async rejectGLEntry(entryId, note) {
|
|
1425
|
+
return this.requestUnwrapped("POST", `/gl/entries/${pathSegment(entryId)}/reject`, { note });
|
|
883
1426
|
}
|
|
884
1427
|
async getGLEntryAuditLog(entryId) {
|
|
885
|
-
return this.request("GET", `/gl/entries/${
|
|
1428
|
+
return this.request("GET", `/gl/entries/${pathSegment(entryId)}/audit-log`);
|
|
886
1429
|
}
|
|
887
1430
|
// ---------------------------------------------------------------- White-label Portal Domain
|
|
888
1431
|
// NOTE: /v1/portal/domain/* endpoints proxy Firebase callables (PR #397). 404 until REST shell ships.
|
|
@@ -890,10 +1433,10 @@ export class FrihetClient {
|
|
|
890
1433
|
return this.requestUnwrapped("POST", "/portal/domain", data);
|
|
891
1434
|
}
|
|
892
1435
|
async verifyCustomPortalDomain(data) {
|
|
893
|
-
return this.requestUnwrapped("POST", `/portal/domain/${
|
|
1436
|
+
return this.requestUnwrapped("POST", `/portal/domain/${pathSegment(data.domain)}/verify`, {});
|
|
894
1437
|
}
|
|
895
1438
|
async removeCustomPortalDomain(data) {
|
|
896
|
-
return this.request("DELETE", `/portal/domain/${
|
|
1439
|
+
return this.request("DELETE", `/portal/domain/${pathSegment(data.domain)}`);
|
|
897
1440
|
}
|
|
898
1441
|
// ---------------------------------------------------------------- Self-onboard + VIES
|
|
899
1442
|
// NOTE: /v1/portal/onboard/* endpoints proxy Firebase callables (PR #398). 404 until REST shell ships.
|
|
@@ -909,7 +1452,7 @@ export class FrihetClient {
|
|
|
909
1452
|
// ---------------------------------------------------------------- IGIC (Canary Islands)
|
|
910
1453
|
// NOTE: /v1/igic/* endpoints (PR #390). 404 until REST shell ships.
|
|
911
1454
|
async getIgicModeloSummary(modeloCode, params) {
|
|
912
|
-
return this.request("GET", `/igic/modelo/${
|
|
1455
|
+
return this.request("GET", `/igic/modelo/${pathSegment(modeloCode)}`, undefined, {
|
|
913
1456
|
year: params?.year,
|
|
914
1457
|
period: params?.period,
|
|
915
1458
|
});
|
|
@@ -920,7 +1463,7 @@ export class FrihetClient {
|
|
|
920
1463
|
// ---------------------------------------------------------------- Impuesto Sociedades (IS)
|
|
921
1464
|
// NOTE: /v1/is/* endpoints (PR #392). 404 until REST shell ships.
|
|
922
1465
|
async getISSummary(modeloCode, params) {
|
|
923
|
-
return this.request("GET", `/is/modelo/${
|
|
1466
|
+
return this.request("GET", `/is/modelo/${pathSegment(modeloCode)}`, undefined, {
|
|
924
1467
|
year: params?.year,
|
|
925
1468
|
installment: params?.installment,
|
|
926
1469
|
});
|
|
@@ -928,7 +1471,7 @@ export class FrihetClient {
|
|
|
928
1471
|
// ---------------------------------------------------------------- Bank Rules
|
|
929
1472
|
// NOTE: /v1/banking/rules Q3-flagged (PR #394). 404 until callable wrapper ships.
|
|
930
1473
|
async listBankRules(params) {
|
|
931
|
-
return this.
|
|
1474
|
+
return this.requestPaginated("GET", "/banking/rules", undefined, {
|
|
932
1475
|
isActive: params?.isActive !== undefined ? String(params.isActive) : undefined,
|
|
933
1476
|
limit: params?.limit,
|
|
934
1477
|
offset: params?.offset,
|
|
@@ -938,7 +1481,8 @@ export class FrihetClient {
|
|
|
938
1481
|
return this.requestUnwrapped("POST", "/banking/rules", data);
|
|
939
1482
|
}
|
|
940
1483
|
// ---------------------------------------------------------------- HR (Leaves + Attendance + Anomalies)
|
|
941
|
-
//
|
|
1484
|
+
// The ERP routes are live. Single-object family reports use the standard
|
|
1485
|
+
// `{ data, meta }` response envelope and must be unwrapped exactly once.
|
|
942
1486
|
async listLeaves(params) {
|
|
943
1487
|
return this.requestPaginated("GET", "/leaves", undefined, {
|
|
944
1488
|
employeeId: params?.employeeId,
|
|
@@ -954,22 +1498,22 @@ export class FrihetClient {
|
|
|
954
1498
|
return this.requestUnwrapped("POST", "/leaves", data);
|
|
955
1499
|
}
|
|
956
1500
|
async approveLeave(leaveId, data) {
|
|
957
|
-
return this.requestUnwrapped("POST", `/leaves/${
|
|
1501
|
+
return this.requestUnwrapped("POST", `/leaves/${pathSegment(leaveId)}/approve`, data ?? {});
|
|
958
1502
|
}
|
|
959
1503
|
async rejectLeave(leaveId, data) {
|
|
960
|
-
return this.requestUnwrapped("POST", `/leaves/${
|
|
1504
|
+
return this.requestUnwrapped("POST", `/leaves/${pathSegment(leaveId)}/reject`, data);
|
|
961
1505
|
}
|
|
962
1506
|
async cancelLeave(leaveId) {
|
|
963
|
-
return this.requestUnwrapped("POST", `/leaves/${
|
|
1507
|
+
return this.requestUnwrapped("POST", `/leaves/${pathSegment(leaveId)}/cancel`, {});
|
|
964
1508
|
}
|
|
965
|
-
async attendanceClockIn(data) {
|
|
966
|
-
return this.requestUnwrapped("POST", "/time-entries/clock-in", data);
|
|
1509
|
+
async attendanceClockIn(data, idempotencyKey) {
|
|
1510
|
+
return this.requestUnwrapped("POST", "/time-entries/clock-in", data, undefined, idempotencyKey);
|
|
967
1511
|
}
|
|
968
|
-
async attendanceClockOut(entryId) {
|
|
969
|
-
return this.requestUnwrapped("PATCH", `/time-entries/${
|
|
1512
|
+
async attendanceClockOut(entryId, idempotencyKey) {
|
|
1513
|
+
return this.requestUnwrapped("PATCH", `/time-entries/${pathSegment(entryId)}/clock-out`, {}, undefined, idempotencyKey);
|
|
970
1514
|
}
|
|
971
1515
|
async getOvertimeReport(params) {
|
|
972
|
-
return this.
|
|
1516
|
+
return this.requestUnwrapped("GET", "/time-entries/overtime", undefined, {
|
|
973
1517
|
period: params.period,
|
|
974
1518
|
employeeId: params.employeeId,
|
|
975
1519
|
});
|
|
@@ -987,50 +1531,49 @@ export class FrihetClient {
|
|
|
987
1531
|
// ---------------------------------------------------------------- Webhook Trust-Area Extensions
|
|
988
1532
|
// NOTE: /v1/webhooks/:id/test — D4-A parallel deploy. 404 propagates until backend ships.
|
|
989
1533
|
async testWebhook(id, data) {
|
|
990
|
-
return this.requestUnwrapped("POST", `/webhooks/${
|
|
1534
|
+
return this.requestUnwrapped("POST", `/webhooks/${pathSegment(id)}/test`, data ?? {});
|
|
991
1535
|
}
|
|
992
1536
|
// ---------------------------------------------------------------- Payroll
|
|
993
|
-
//
|
|
1537
|
+
// These live read routes return normalized payroll-preparation records in
|
|
1538
|
+
// the standard single-object `{ data, meta }` envelope.
|
|
994
1539
|
async exportPayroll(params) {
|
|
995
|
-
return this.
|
|
1540
|
+
return this.requestUnwrapped("GET", "/payroll/prep/export", undefined, {
|
|
996
1541
|
format: params.format,
|
|
997
1542
|
month: params.month,
|
|
998
1543
|
});
|
|
999
1544
|
}
|
|
1000
1545
|
async getPayrollChecklist(params) {
|
|
1001
|
-
return this.
|
|
1546
|
+
return this.requestUnwrapped("GET", "/payroll/prep/employees", undefined, {
|
|
1002
1547
|
month: params.month,
|
|
1003
1548
|
});
|
|
1004
1549
|
}
|
|
1005
|
-
// ---------------------------------------------------------------- Onboarding
|
|
1006
|
-
// NOTE: /v1/onboarding/* — D4-A parallel deploy. 404 propagates until backend ships.
|
|
1007
|
-
async getOnboardingStatus() {
|
|
1008
|
-
return this.request("GET", "/onboarding/status");
|
|
1009
|
-
}
|
|
1010
|
-
async setOnboardingPersona(data) {
|
|
1011
|
-
return this.requestUnwrapped("PATCH", "/onboarding/persona", data);
|
|
1012
|
-
}
|
|
1013
1550
|
// ---------------------------------------------------------------- Permissions
|
|
1014
|
-
//
|
|
1551
|
+
// These are standard ERP family responses: unwrap their single-object
|
|
1552
|
+
// `{ data, meta }` envelope before handing the payload to MCP output schemas.
|
|
1553
|
+
//
|
|
1554
|
+
// Note: /v1/onboarding/* methods (getOnboardingStatus, setOnboardingPersona)
|
|
1555
|
+
// were removed when the onboarding family was withdrawn via issue #124.
|
|
1556
|
+
// See src/tools/igic.ts for the rationale and the inline-stub precedent.
|
|
1015
1557
|
async getPermissionsMatrix() {
|
|
1016
|
-
return this.
|
|
1558
|
+
return this.requestUnwrapped("GET", "/permissions/matrix");
|
|
1017
1559
|
}
|
|
1018
1560
|
async getMyPermissions() {
|
|
1019
|
-
return this.
|
|
1561
|
+
return this.requestUnwrapped("GET", "/permissions/me");
|
|
1020
1562
|
}
|
|
1021
1563
|
// ---------------------------------------------------------------- Period Close
|
|
1022
|
-
//
|
|
1564
|
+
// Period reads are live and enveloped. Fiscal close/reopen writes remain
|
|
1565
|
+
// honest ERP 501 responses and are intentionally unchanged here.
|
|
1023
1566
|
async getCurrentPeriod(params) {
|
|
1024
|
-
if (params?.
|
|
1025
|
-
return this.
|
|
1567
|
+
if (params?.fiscalYear) {
|
|
1568
|
+
return this.requestUnwrapped("GET", `/periods/${pathSegment(params.fiscalYear)}`);
|
|
1026
1569
|
}
|
|
1027
|
-
return this.
|
|
1570
|
+
return this.requestUnwrapped("GET", "/periods/current");
|
|
1028
1571
|
}
|
|
1029
1572
|
async closePeriod(data) {
|
|
1030
1573
|
return this.requestUnwrapped("POST", "/periods/close", data);
|
|
1031
1574
|
}
|
|
1032
1575
|
async reopenPeriod(data) {
|
|
1033
|
-
return this.requestUnwrapped("POST", `/periods/${
|
|
1576
|
+
return this.requestUnwrapped("POST", `/periods/${pathSegment(data.periodId)}/reopen`, { reason: data.reason });
|
|
1034
1577
|
}
|
|
1035
1578
|
}
|
|
1036
1579
|
//# sourceMappingURL=client.js.map
|