@yolk-sdk/emulators 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { answeredOutsideCore, handlerFailedResponse } from "../emulator-http.mjs";
|
|
2
|
+
import { buildRow, compareNumbers, defaultEmailInformation, formatQuantity, fortnoxEmulatorCountries, fortnoxEmulatorVatCodes, invoiceFromSeed, invoiceTotals, newRowDefaults } from "./state.mjs";
|
|
3
|
+
import { Predicate, Result } from "effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
//#region src/fortnox/api.ts
|
|
6
|
+
/**
|
|
7
|
+
* `ErrorInformation` codes the emulator answers with. The two customer-not-found codes are copied
|
|
8
|
+
* from the rejection fixture; the `2999xxx` codes are synthetic emulator codes.
|
|
9
|
+
*/
|
|
10
|
+
const fortnoxEmulatorErrorCodes = {
|
|
11
|
+
customerNotFound: 2000204,
|
|
12
|
+
invoiceCustomerNotFound: 2000433,
|
|
13
|
+
unauthorized: 2999001,
|
|
14
|
+
invalidBody: 2999002,
|
|
15
|
+
invalidField: 2999003,
|
|
16
|
+
readOnlyField: 2999004,
|
|
17
|
+
notEditable: 2999005,
|
|
18
|
+
invoiceNotFound: 2999006,
|
|
19
|
+
unknownRoute: 2999007,
|
|
20
|
+
unsupportedQuery: 2999008,
|
|
21
|
+
rateLimited: 2999009,
|
|
22
|
+
upstreamError: 2999010
|
|
23
|
+
};
|
|
24
|
+
/** Fortnox API base path. */
|
|
25
|
+
const fortnoxEmulatorBasePath = "/3";
|
|
26
|
+
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
|
|
27
|
+
status,
|
|
28
|
+
headers: { "content-type": "application/json" }
|
|
29
|
+
});
|
|
30
|
+
/** A Fortnox `ErrorInformation` response with the lowercase fields of the rejection fixture. */
|
|
31
|
+
const errorInformation = (status, code, message) => jsonResponse(status, { ErrorInformation: {
|
|
32
|
+
error: 1,
|
|
33
|
+
message,
|
|
34
|
+
code
|
|
35
|
+
} });
|
|
36
|
+
const codes = fortnoxEmulatorErrorCodes;
|
|
37
|
+
const customerNotFound = () => errorInformation(404, codes.customerNotFound, "Synthetic placeholder: customer not found.");
|
|
38
|
+
const invoiceCustomerNotFound = () => errorInformation(400, codes.invoiceCustomerNotFound, "Synthetic placeholder: customer not found.");
|
|
39
|
+
const invoiceNotFound = () => errorInformation(404, codes.invoiceNotFound, "Synthetic: invoice not found.");
|
|
40
|
+
const invalidBody = (message) => errorInformation(400, codes.invalidBody, `Synthetic: ${message}`);
|
|
41
|
+
const invalidField = (message) => errorInformation(400, codes.invalidField, `Synthetic: ${message}`);
|
|
42
|
+
const noReferencedEntity = (kind, value) => invalidField(`${kind} ${value} does not exist in the emulated company.`);
|
|
43
|
+
const readOnlyField = (message) => errorInformation(400, codes.readOnlyField, `Synthetic: ${message}`);
|
|
44
|
+
const notEditable = (message) => errorInformation(400, codes.notEditable, `Synthetic: ${message}`);
|
|
45
|
+
const unsupportedQuery = (key) => errorInformation(400, codes.unsupportedQuery, key === "lastmodified" ? "Synthetic: query parameter lastmodified is not emulated (the emulator does not track modification times)." : `Synthetic: query parameter ${key} is not emulated.`);
|
|
46
|
+
const previewPdfBase64 = "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFtdIC9Db3VudCAwID4+CmVuZG9iagp0cmFpbGVyCjw8IC9Sb290IDEgMCBSID4+CiUlRU9GCg==";
|
|
47
|
+
const previewPdfBytes = () => Uint8Array.from(atob(previewPdfBase64), (character) => character.charCodeAt(0));
|
|
48
|
+
const isJsonObject = (value) => value !== void 0 && value !== null && Predicate.isObject(value) && !Array.isArray(value);
|
|
49
|
+
const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
50
|
+
/** A real `YYYY-MM-DD` calendar date (no JavaScript rollover such as `2026-02-30`). */
|
|
51
|
+
const isIsoDate = (value) => {
|
|
52
|
+
const parsed = Date.parse(`${value}T00:00:00.000Z`);
|
|
53
|
+
return isoDatePattern.test(value) && !Number.isNaN(parsed) && new Date(parsed).toISOString().slice(0, 10) === value;
|
|
54
|
+
};
|
|
55
|
+
const today = (env) => new Date(env.now()).toISOString().slice(0, 10);
|
|
56
|
+
/** `date` plus `days`, or `undefined` when the result is not a representable `YYYY-MM-DD` date. */
|
|
57
|
+
const addDays = (date, days) => {
|
|
58
|
+
const result = new Date(Date.parse(`${date}T00:00:00.000Z`) + days * 24 * 60 * 60 * 1e3);
|
|
59
|
+
if (Number.isNaN(result.getTime())) return void 0;
|
|
60
|
+
const iso = result.toISOString().slice(0, 10);
|
|
61
|
+
return isIsoDate(iso) ? iso : void 0;
|
|
62
|
+
};
|
|
63
|
+
const maxTermsOfPaymentDays = 365;
|
|
64
|
+
/**
|
|
65
|
+
* `TermsOfPayment` as a whole number of days (`0`–`365`, no sign or leading zeros), or
|
|
66
|
+
* `undefined` when the value is not emulated. Fortnox also knows named terms (such as `K`); the
|
|
67
|
+
* emulated company has day terms only.
|
|
68
|
+
*/
|
|
69
|
+
const termsOfPaymentDays = (value) => {
|
|
70
|
+
if (!/^(?:0|[1-9]\d{0,2})$/.test(value)) return void 0;
|
|
71
|
+
const days = Number(value);
|
|
72
|
+
return days <= maxTermsOfPaymentDays ? days : void 0;
|
|
73
|
+
};
|
|
74
|
+
const unsupportedTermsOfPayment = (value) => invalidField(`TermsOfPayment ${value} is not emulated (whole days from 0 to ${maxTermsOfPaymentDays} only).`);
|
|
75
|
+
/**
|
|
76
|
+
* The customer categorical values the emulator deliberately models. This is the emulated subset,
|
|
77
|
+
* not Fortnox's full enum: other values (for example export or EU reverse-charge VAT) would change
|
|
78
|
+
* invoice VAT, which the emulator does not model, so they are rejected.
|
|
79
|
+
*/
|
|
80
|
+
const emulatedCustomerVatTypes = ["SEVAT"];
|
|
81
|
+
const emulatedCustomerTypes = ["COMPANY", "PRIVATE"];
|
|
82
|
+
const unsupportedVatType = (value) => invalidField(`VATType ${value} is not emulated (supported: ${emulatedCustomerVatTypes.join(", ")}).`);
|
|
83
|
+
const customerUrl = (env, customerNumber) => `${env.linkOrigin}/3/customers/${encodeURIComponent(customerNumber)}`;
|
|
84
|
+
const invoiceUrl = (env, documentNumber) => `${env.linkOrigin}/3/invoices/${encodeURIComponent(documentNumber)}`;
|
|
85
|
+
const renderCustomer = (env, customer) => ({
|
|
86
|
+
"@url": customerUrl(env, customer.CustomerNumber),
|
|
87
|
+
...customer
|
|
88
|
+
});
|
|
89
|
+
const renderCustomerRow = (env, customer) => ({
|
|
90
|
+
"@url": customerUrl(env, customer.CustomerNumber),
|
|
91
|
+
Address1: customer.Address1,
|
|
92
|
+
Address2: customer.Address2,
|
|
93
|
+
City: customer.City,
|
|
94
|
+
CustomerNumber: customer.CustomerNumber,
|
|
95
|
+
Email: customer.Email,
|
|
96
|
+
Name: customer.Name,
|
|
97
|
+
OrganisationNumber: customer.OrganisationNumber,
|
|
98
|
+
Phone: customer.Phone1,
|
|
99
|
+
ZipCode: customer.ZipCode
|
|
100
|
+
});
|
|
101
|
+
/**
|
|
102
|
+
* A single-invoice read: JSON-number amounts. `dropEmptyCopies` leaves empty CC/BCC addresses out
|
|
103
|
+
* of `EmailInformation`, as the email-send fixture response does.
|
|
104
|
+
*/
|
|
105
|
+
const renderInvoice = (env, invoice, dropEmptyCopies = false) => {
|
|
106
|
+
const source = invoice.EmailInformation;
|
|
107
|
+
const email = {
|
|
108
|
+
EmailAddressFrom: source.EmailAddressFrom,
|
|
109
|
+
EmailAddressTo: source.EmailAddressTo
|
|
110
|
+
};
|
|
111
|
+
if (!dropEmptyCopies || source.EmailAddressCC !== "") email.EmailAddressCC = source.EmailAddressCC;
|
|
112
|
+
if (!dropEmptyCopies || source.EmailAddressBCC !== "") email.EmailAddressBCC = source.EmailAddressBCC;
|
|
113
|
+
email.EmailSubject = source.EmailSubject;
|
|
114
|
+
email.EmailBody = source.EmailBody;
|
|
115
|
+
return {
|
|
116
|
+
"@url": invoiceUrl(env, invoice.DocumentNumber),
|
|
117
|
+
...invoice,
|
|
118
|
+
EmailInformation: email
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
/** An invoice list row, in the list fixture's shape: `CurrencyRate` is a numeric string. */
|
|
122
|
+
const renderInvoiceRow = (env, invoice) => ({
|
|
123
|
+
"@url": invoiceUrl(env, invoice.DocumentNumber),
|
|
124
|
+
Balance: invoice.Balance,
|
|
125
|
+
Booked: invoice.Booked,
|
|
126
|
+
Cancelled: invoice.Cancelled,
|
|
127
|
+
CostCenter: invoice.CostCenter,
|
|
128
|
+
Currency: invoice.Currency,
|
|
129
|
+
CurrencyRate: String(invoice.CurrencyRate),
|
|
130
|
+
CurrencyUnit: invoice.CurrencyUnit,
|
|
131
|
+
CustomerName: invoice.CustomerName,
|
|
132
|
+
CustomerNumber: invoice.CustomerNumber,
|
|
133
|
+
DocumentNumber: invoice.DocumentNumber,
|
|
134
|
+
DueDate: invoice.DueDate,
|
|
135
|
+
ExternalInvoiceReference1: invoice.ExternalInvoiceReference1,
|
|
136
|
+
ExternalInvoiceReference2: invoice.ExternalInvoiceReference2,
|
|
137
|
+
FinalPayDate: invoice.FinalPayDate,
|
|
138
|
+
InvoiceDate: invoice.InvoiceDate,
|
|
139
|
+
InvoiceType: invoice.InvoiceType,
|
|
140
|
+
NoxFinans: invoice.NoxFinans,
|
|
141
|
+
OCR: invoice.OCR,
|
|
142
|
+
VoucherNumber: invoice.VoucherNumber,
|
|
143
|
+
VoucherSeries: invoice.VoucherSeries,
|
|
144
|
+
VoucherYear: invoice.VoucherYear,
|
|
145
|
+
WayOfDelivery: invoice.WayOfDelivery,
|
|
146
|
+
TermsOfPayment: invoice.TermsOfPayment,
|
|
147
|
+
Project: invoice.Project,
|
|
148
|
+
Sent: invoice.Sent,
|
|
149
|
+
Total: invoice.Total
|
|
150
|
+
});
|
|
151
|
+
/** The 400 for the first query key the route does not emulate, or `undefined`. */
|
|
152
|
+
const unsupportedQueryKey = (query, allowed) => {
|
|
153
|
+
const key = [...query.keys()].find((candidate) => !allowed.includes(candidate));
|
|
154
|
+
return key === void 0 ? void 0 : unsupportedQuery(key);
|
|
155
|
+
};
|
|
156
|
+
const positiveInt = (query, key, fallback, maximum) => {
|
|
157
|
+
const raw = query.get(key);
|
|
158
|
+
if (raw === null) return fallback;
|
|
159
|
+
const value = Number(raw);
|
|
160
|
+
return /^\d+$/.test(raw) && value >= 1 && value <= maximum ? value : invalidField(`query parameter ${key} must be an integer from 1 to ${maximum}.`);
|
|
161
|
+
};
|
|
162
|
+
/** Fortnox default and maximum page sizes. */
|
|
163
|
+
const defaultLimit = 100;
|
|
164
|
+
const maxLimit = 500;
|
|
165
|
+
/** One page plus `MetaInformation` (`@TotalPages` is at least 1, as the empty-list fixture shows). */
|
|
166
|
+
const paginate = (items, query) => {
|
|
167
|
+
const limit = positiveInt(query, "limit", defaultLimit, maxLimit);
|
|
168
|
+
const page = positiveInt(query, "page", 1, Number.MAX_SAFE_INTEGER);
|
|
169
|
+
if (limit instanceof Response) return limit;
|
|
170
|
+
if (page instanceof Response) return page;
|
|
171
|
+
return {
|
|
172
|
+
items: items.slice((page - 1) * limit, page * limit),
|
|
173
|
+
meta: {
|
|
174
|
+
"@CurrentPage": page,
|
|
175
|
+
"@TotalPages": Math.max(1, Math.ceil(items.length / limit)),
|
|
176
|
+
"@TotalResources": items.length
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
const contains = (value, search) => value.toLowerCase().includes(search.toLowerCase());
|
|
181
|
+
const customerSearch = {
|
|
182
|
+
customernumber: (customer, value) => customer.CustomerNumber === value,
|
|
183
|
+
name: (customer, value) => contains(customer.Name, value),
|
|
184
|
+
organisationnumber: (customer, value) => contains(customer.OrganisationNumber, value),
|
|
185
|
+
email: (customer, value) => contains(customer.Email, value),
|
|
186
|
+
city: (customer, value) => contains(customer.City, value),
|
|
187
|
+
phone: (customer, value) => contains(customer.Phone1, value) || contains(customer.Phone2, value)
|
|
188
|
+
};
|
|
189
|
+
const customerListQueryKeys = [
|
|
190
|
+
"page",
|
|
191
|
+
"limit",
|
|
192
|
+
"filter",
|
|
193
|
+
...Object.keys(customerSearch)
|
|
194
|
+
];
|
|
195
|
+
const listCustomers = (state, { query }, env) => {
|
|
196
|
+
const filter = query.get("filter");
|
|
197
|
+
if (filter !== null && filter !== "active" && filter !== "inactive") return invalidField(`unknown customer filter ${filter}.`);
|
|
198
|
+
const page = paginate(state.customers.filter((customer) => (filter === null || customer.Active === (filter === "active")) && Object.entries(customerSearch).every(([key, matches]) => {
|
|
199
|
+
const value = query.get(key);
|
|
200
|
+
return value === null || matches(customer, value);
|
|
201
|
+
})), query);
|
|
202
|
+
if (page instanceof Response) return page;
|
|
203
|
+
return jsonResponse(200, {
|
|
204
|
+
Customers: page.items.map((customer) => renderCustomerRow(env, customer)),
|
|
205
|
+
MetaInformation: page.meta
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
const findCustomer = (state, customerNumber) => state.customers.find((customer) => customer.CustomerNumber === customerNumber);
|
|
209
|
+
const findInvoice = (state, documentNumber) => state.invoices.find((invoice) => invoice.DocumentNumber === documentNumber);
|
|
210
|
+
const getCustomer = (state, { params }, env) => {
|
|
211
|
+
const customer = findCustomer(state, params.CustomerNumber ?? "");
|
|
212
|
+
return customer === void 0 ? customerNotFound() : jsonResponse(200, { Customer: renderCustomer(env, customer) });
|
|
213
|
+
};
|
|
214
|
+
/** The single resource object of a `{ <envelope>: { ... } }` write body. */
|
|
215
|
+
const envelopeObject = (body, envelope) => {
|
|
216
|
+
const value = isJsonObject(body) ? body[envelope] : void 0;
|
|
217
|
+
return isJsonObject(body) && Object.keys(body).every((key) => key === envelope) && isJsonObject(value) ? value : invalidBody(`the request body must be { "${envelope}": { ... } }.`);
|
|
218
|
+
};
|
|
219
|
+
const writableCustomerStrings = [
|
|
220
|
+
"Name",
|
|
221
|
+
"OrganisationNumber",
|
|
222
|
+
"Email",
|
|
223
|
+
"Phone1",
|
|
224
|
+
"Phone2",
|
|
225
|
+
"Address1",
|
|
226
|
+
"Address2",
|
|
227
|
+
"City",
|
|
228
|
+
"ZipCode",
|
|
229
|
+
"CountryCode",
|
|
230
|
+
"Currency",
|
|
231
|
+
"VATNumber",
|
|
232
|
+
"VATType",
|
|
233
|
+
"TermsOfPayment",
|
|
234
|
+
"OurReference",
|
|
235
|
+
"YourReference",
|
|
236
|
+
"Comments",
|
|
237
|
+
"EmailInvoice",
|
|
238
|
+
"Type",
|
|
239
|
+
"CostCenter"
|
|
240
|
+
];
|
|
241
|
+
const isCustomerStringField = (key) => writableCustomerStrings.some((field) => field === key);
|
|
242
|
+
/**
|
|
243
|
+
* Why a customer field value is not emulated (fail closed), or `undefined`. The emulated company
|
|
244
|
+
* has only SEK, no cost centers, terms of payment in whole days (0–365), `VATType` `SEVAT`, and
|
|
245
|
+
* `Type` `COMPANY` or `PRIVATE`.
|
|
246
|
+
*/
|
|
247
|
+
const unsupportedCustomerValue = (key, value) => {
|
|
248
|
+
switch (key) {
|
|
249
|
+
case "Currency": return value === "SEK" ? void 0 : noReferencedEntity("Currency", value);
|
|
250
|
+
case "CostCenter": return value === "" ? void 0 : noReferencedEntity("CostCenter", value);
|
|
251
|
+
case "TermsOfPayment": return termsOfPaymentDays(value) === void 0 ? unsupportedTermsOfPayment(value) : void 0;
|
|
252
|
+
case "VATType": return emulatedCustomerVatTypes.includes(value) ? void 0 : unsupportedVatType(value);
|
|
253
|
+
case "Type": return emulatedCustomerTypes.includes(value) ? void 0 : invalidField(`Type ${value} is not emulated (supported: ${emulatedCustomerTypes.join(", ")}).`);
|
|
254
|
+
default: return;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Quirk 2: an empty string keeps the stored value (unless the `emptyStringClears` drill knob is
|
|
259
|
+
* set); omitted fields keep theirs. Quirk 5: `Country` is read-only. Unknown fields, wrong types,
|
|
260
|
+
* and values the emulator does not support (non-SEK `Currency`, a `CostCenter`, `TermsOfPayment`
|
|
261
|
+
* other than 0–365 days, `VATType` other than `SEVAT`, `Type` other than `COMPANY`/`PRIVATE`) are
|
|
262
|
+
* rejected; the update is atomic.
|
|
263
|
+
*/
|
|
264
|
+
const customerUpdates = (fields, customer, env) => {
|
|
265
|
+
const updates = {};
|
|
266
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
267
|
+
if (key === "Country") return readOnlyField("Country is read-only; set CountryCode instead.");
|
|
268
|
+
if (key === "CustomerNumber") {
|
|
269
|
+
if (value !== customer.CustomerNumber) return invalidField("CustomerNumber cannot be changed.");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (key === "Active") {
|
|
273
|
+
if (!Predicate.isBoolean(value)) return invalidField("Active must be a boolean.");
|
|
274
|
+
updates.Active = value;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!isCustomerStringField(key)) return invalidField(`unknown customer field ${key}.`);
|
|
278
|
+
if (!Predicate.isString(value)) return invalidField(`${key} must be a string.`);
|
|
279
|
+
if (value === "" && !env.quirks.emptyStringClears) continue;
|
|
280
|
+
const unsupported = unsupportedCustomerValue(key, value);
|
|
281
|
+
if (unsupported !== void 0) return unsupported;
|
|
282
|
+
if (key === "CountryCode" && value !== "") {
|
|
283
|
+
const country = fortnoxEmulatorCountries.get(value);
|
|
284
|
+
if (country === void 0) return invalidField(`unknown CountryCode ${value}.`);
|
|
285
|
+
updates.Country = country;
|
|
286
|
+
}
|
|
287
|
+
updates[key] = value;
|
|
288
|
+
}
|
|
289
|
+
return updates;
|
|
290
|
+
};
|
|
291
|
+
const updateCustomer = (state, { params, body }, env) => {
|
|
292
|
+
const customer = findCustomer(state, params.CustomerNumber ?? "");
|
|
293
|
+
if (customer === void 0) return customerNotFound();
|
|
294
|
+
const fields = envelopeObject(body, "Customer");
|
|
295
|
+
if (fields instanceof Response) return fields;
|
|
296
|
+
const updates = customerUpdates(fields, customer, env);
|
|
297
|
+
if (updates instanceof Response) return updates;
|
|
298
|
+
const updated = {
|
|
299
|
+
...customer,
|
|
300
|
+
...updates
|
|
301
|
+
};
|
|
302
|
+
state.customers = state.customers.map((existing) => existing.CustomerNumber === updated.CustomerNumber ? updated : existing);
|
|
303
|
+
return jsonResponse(200, { Customer: renderCustomer(env, updated) });
|
|
304
|
+
};
|
|
305
|
+
const invoiceFilters = [
|
|
306
|
+
"cancelled",
|
|
307
|
+
"fullypaid",
|
|
308
|
+
"unpaid",
|
|
309
|
+
"unpaidoverdue",
|
|
310
|
+
"unbooked"
|
|
311
|
+
];
|
|
312
|
+
const isInvoiceFilter = (value) => invoiceFilters.some((filter) => filter === value);
|
|
313
|
+
/**
|
|
314
|
+
* Quirk 3: the payment-status filters consider booked invoices only (unless the
|
|
315
|
+
* `paymentFiltersIncludeUnbooked` drill knob is set); `unbooked` returns unbooked, uncancelled
|
|
316
|
+
* invoices; `unpaidoverdue` needs a `DueDate` before today (UTC, from the emulator clock).
|
|
317
|
+
*/
|
|
318
|
+
const matchesInvoiceFilter = (invoice, filter, env) => {
|
|
319
|
+
const considered = (invoice.Booked || env.quirks.paymentFiltersIncludeUnbooked) && !invoice.Cancelled;
|
|
320
|
+
switch (filter) {
|
|
321
|
+
case "cancelled": return invoice.Cancelled;
|
|
322
|
+
case "unbooked": return !invoice.Booked && !invoice.Cancelled;
|
|
323
|
+
case "unpaid": return considered && invoice.Balance > 0;
|
|
324
|
+
case "unpaidoverdue": return considered && invoice.Balance > 0 && invoice.DueDate !== null && isIsoDate(invoice.DueDate) && invoice.DueDate < today(env);
|
|
325
|
+
case "fullypaid": return considered && invoice.Balance === 0;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const invoiceSearch = {
|
|
329
|
+
customernumber: (invoice, value) => invoice.CustomerNumber === value,
|
|
330
|
+
customername: (invoice, value) => contains(invoice.CustomerName, value),
|
|
331
|
+
documentnumber: (invoice, value) => invoice.DocumentNumber === value,
|
|
332
|
+
ocr: (invoice, value) => invoice.OCR === value
|
|
333
|
+
};
|
|
334
|
+
const invoiceListQueryKeys = [
|
|
335
|
+
"page",
|
|
336
|
+
"limit",
|
|
337
|
+
"filter",
|
|
338
|
+
"fromdate",
|
|
339
|
+
"todate",
|
|
340
|
+
...Object.keys(invoiceSearch)
|
|
341
|
+
];
|
|
342
|
+
const listInvoices = (state, { query }, env) => {
|
|
343
|
+
const filter = query.get("filter");
|
|
344
|
+
if (filter !== null && !isInvoiceFilter(filter)) return invalidField(`unknown invoice filter ${filter}.`);
|
|
345
|
+
const fromDate = query.get("fromdate");
|
|
346
|
+
const toDate = query.get("todate");
|
|
347
|
+
for (const [key, value] of [["fromdate", fromDate], ["todate", toDate]]) if (value !== null && !isIsoDate(value)) return invalidField(`query parameter ${key} must be a YYYY-MM-DD date.`);
|
|
348
|
+
const page = paginate(state.invoices.filter((invoice) => (filter === null || matchesInvoiceFilter(invoice, filter, env)) && (fromDate === null || invoice.InvoiceDate >= fromDate) && (toDate === null || invoice.InvoiceDate <= toDate) && Object.entries(invoiceSearch).every(([key, matches]) => {
|
|
349
|
+
const value = query.get(key);
|
|
350
|
+
return value === null || matches(invoice, value);
|
|
351
|
+
})), query);
|
|
352
|
+
if (page instanceof Response) return page;
|
|
353
|
+
return jsonResponse(200, {
|
|
354
|
+
Invoices: page.items.map((invoice) => renderInvoiceRow(env, invoice)),
|
|
355
|
+
MetaInformation: page.meta
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
const getInvoice = (state, { params }, env) => {
|
|
359
|
+
const invoice = findInvoice(state, params.DocumentNumber ?? "");
|
|
360
|
+
return invoice === void 0 ? invoiceNotFound() : jsonResponse(200, { Invoice: renderInvoice(env, invoice) });
|
|
361
|
+
};
|
|
362
|
+
const vatRates = [...fortnoxEmulatorVatCodes.keys()];
|
|
363
|
+
/**
|
|
364
|
+
* One row of an `InvoiceRows` write. `null` means omitted. Referenced articles, cost centers, and
|
|
365
|
+
* projects must exist (the emulated company has none); accounts must be in 1000-8999.
|
|
366
|
+
*/
|
|
367
|
+
const rowPatch = (value, index) => {
|
|
368
|
+
if (!isJsonObject(value)) return invalidField(`InvoiceRows[${index}] must be an object.`);
|
|
369
|
+
const patch = {};
|
|
370
|
+
const label = (key) => `InvoiceRows[${index}].${key}`;
|
|
371
|
+
for (const [key, field] of Object.entries(value)) {
|
|
372
|
+
if (field === null) continue;
|
|
373
|
+
switch (key) {
|
|
374
|
+
case "RowId":
|
|
375
|
+
if (!Predicate.isNumber(field) || !Number.isSafeInteger(field) || field < 1) return invalidField(`${label(key)} must be a positive integer.`);
|
|
376
|
+
patch.RowId = field;
|
|
377
|
+
break;
|
|
378
|
+
case "AccountNumber":
|
|
379
|
+
if (!Predicate.isNumber(field) || !Number.isInteger(field) || field < 1e3 || field > 8999) return invalidField(`${label(key)} must be an account number from 1000 to 8999.`);
|
|
380
|
+
patch.AccountNumber = field;
|
|
381
|
+
break;
|
|
382
|
+
case "ArticleNumber":
|
|
383
|
+
case "CostCenter":
|
|
384
|
+
case "Project":
|
|
385
|
+
if (!Predicate.isString(field)) return invalidField(`${label(key)} must be a string.`);
|
|
386
|
+
if (field !== "") return noReferencedEntity(key, field);
|
|
387
|
+
patch[key] = field;
|
|
388
|
+
break;
|
|
389
|
+
case "Description":
|
|
390
|
+
case "Unit":
|
|
391
|
+
if (!Predicate.isString(field)) return invalidField(`${label(key)} must be a string.`);
|
|
392
|
+
patch[key] = field;
|
|
393
|
+
break;
|
|
394
|
+
case "DeliveredQuantity": {
|
|
395
|
+
const quantity = Predicate.isString(field) ? Number(field.trim()) : field;
|
|
396
|
+
if (!Predicate.isNumber(quantity) || !Number.isFinite(quantity) || quantity < 0 || Predicate.isString(field) && field.trim() === "") return invalidField(`${label(key)} must be a non-negative number.`);
|
|
397
|
+
patch.DeliveredQuantity = formatQuantity(quantity);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case "Price":
|
|
401
|
+
if (!Predicate.isNumber(field) || !Number.isFinite(field)) return invalidField(`${label(key)} must be a finite number.`);
|
|
402
|
+
patch.Price = field;
|
|
403
|
+
break;
|
|
404
|
+
case "Discount":
|
|
405
|
+
if (!Predicate.isNumber(field) || !Number.isFinite(field) || field < 0) return invalidField(`${label(key)} must be a non-negative number.`);
|
|
406
|
+
patch.Discount = field;
|
|
407
|
+
break;
|
|
408
|
+
case "DiscountType":
|
|
409
|
+
if (field !== "PERCENT" && field !== "AMOUNT") return invalidField(`${label(key)} must be PERCENT or AMOUNT.`);
|
|
410
|
+
patch.DiscountType = field;
|
|
411
|
+
break;
|
|
412
|
+
case "VAT":
|
|
413
|
+
if (!Predicate.isNumber(field) || !vatRates.includes(field)) return invalidField(`${label(key)} must be one of ${vatRates.join(", ")}.`);
|
|
414
|
+
patch.VAT = field;
|
|
415
|
+
break;
|
|
416
|
+
default: return invalidField(`${label(key)} is not a writable invoice row field.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return patch;
|
|
420
|
+
};
|
|
421
|
+
const rowPatches = (value) => {
|
|
422
|
+
if (!Array.isArray(value)) return invalidField("InvoiceRows must be an array.");
|
|
423
|
+
const patches = [];
|
|
424
|
+
for (const [index, row] of value.entries()) {
|
|
425
|
+
const patch = rowPatch(row, index);
|
|
426
|
+
if (patch instanceof Response) return patch;
|
|
427
|
+
patches.push(patch);
|
|
428
|
+
}
|
|
429
|
+
return patches;
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* Quirk 1: `InvoiceRows` replaces the row list. Without any `RowId`, rows match existing rows by
|
|
433
|
+
* position; with a `RowId`, that row is updated and rows without one are added. A matched row
|
|
434
|
+
* keeps every omitted field, including `Discount`/`DiscountType` (unless the `stickyRowDiscount`
|
|
435
|
+
* drill knob is off, which resets them); `Discount: 0` clears. RowIds are regenerated for every
|
|
436
|
+
* row, and totals follow from the fields.
|
|
437
|
+
*/
|
|
438
|
+
const mergeRows = (existing, patches, ids, env) => {
|
|
439
|
+
const byRowId = patches.some((patch) => patch.RowId !== void 0);
|
|
440
|
+
const rows = [];
|
|
441
|
+
for (const [index, patch] of patches.entries()) {
|
|
442
|
+
const base = byRowId ? existing.find((row) => patch.RowId !== void 0 && row.RowId === patch.RowId) : existing[index];
|
|
443
|
+
if (byRowId && patch.RowId !== void 0 && base === void 0) return invalidField(`InvoiceRows[${index}].RowId ${patch.RowId} does not exist.`);
|
|
444
|
+
const start = {
|
|
445
|
+
...newRowDefaults,
|
|
446
|
+
...base
|
|
447
|
+
};
|
|
448
|
+
if (base !== void 0 && !env.quirks.stickyRowDiscount) {
|
|
449
|
+
start.Discount = 0;
|
|
450
|
+
start.DiscountType = "PERCENT";
|
|
451
|
+
}
|
|
452
|
+
rows.push(buildRow({
|
|
453
|
+
...start,
|
|
454
|
+
...patch,
|
|
455
|
+
RowId: ids.next++
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
return rows;
|
|
459
|
+
};
|
|
460
|
+
const invoiceTextFields = [
|
|
461
|
+
"Comments",
|
|
462
|
+
"OurReference",
|
|
463
|
+
"YourReference",
|
|
464
|
+
"OCR"
|
|
465
|
+
];
|
|
466
|
+
const isInvoiceTextField = (key) => invoiceTextFields.some((field) => field === key);
|
|
467
|
+
const invoiceReadOnlyFields = [
|
|
468
|
+
"Balance",
|
|
469
|
+
"Booked",
|
|
470
|
+
"Cancelled",
|
|
471
|
+
"CustomerName",
|
|
472
|
+
"FinalPayDate",
|
|
473
|
+
"Gross",
|
|
474
|
+
"Net",
|
|
475
|
+
"Sent",
|
|
476
|
+
"Total",
|
|
477
|
+
"TotalToPay",
|
|
478
|
+
"TotalVAT",
|
|
479
|
+
"VoucherNumber",
|
|
480
|
+
"VoucherSeries",
|
|
481
|
+
"VoucherYear"
|
|
482
|
+
];
|
|
483
|
+
/**
|
|
484
|
+
* The writable invoice fields of a create or update body (the connector's invoice mutation
|
|
485
|
+
* fields). Quirk 4: an unknown `CustomerNumber` is rejected with the rejection fixture's 400
|
|
486
|
+
* `ErrorInformation`; other invalid or read-only fields are rejected with 400 too.
|
|
487
|
+
*/
|
|
488
|
+
const invoiceUpdates = (fields, state, existing) => {
|
|
489
|
+
const scalars = {};
|
|
490
|
+
let customer;
|
|
491
|
+
let rows;
|
|
492
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
493
|
+
if (key === "DocumentNumber") {
|
|
494
|
+
if (existing === void 0 || value !== existing.DocumentNumber) return readOnlyField("DocumentNumber is assigned by Fortnox and cannot be changed.");
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (key === "InvoiceRows") {
|
|
498
|
+
const patches = rowPatches(value);
|
|
499
|
+
if (patches instanceof Response) return patches;
|
|
500
|
+
if (existing === void 0 && patches.some((patch) => patch.RowId !== void 0)) return readOnlyField("RowId is assigned by Fortnox.");
|
|
501
|
+
rows = patches;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (invoiceReadOnlyFields.includes(key)) return readOnlyField(`${key} is read-only.`);
|
|
505
|
+
if (!Predicate.isString(value)) return invalidField(key === "CustomerNumber" || key === "Currency" || isInvoiceTextField(key) ? `${key} must be a string.` : `unknown or unsupported invoice field ${key}.`);
|
|
506
|
+
switch (key) {
|
|
507
|
+
case "CustomerNumber":
|
|
508
|
+
customer = findCustomer(state, value);
|
|
509
|
+
if (customer === void 0) return invoiceCustomerNotFound();
|
|
510
|
+
scalars.CustomerNumber = value;
|
|
511
|
+
break;
|
|
512
|
+
case "Currency":
|
|
513
|
+
if (value !== "SEK") return noReferencedEntity("Currency", value);
|
|
514
|
+
scalars.Currency = value;
|
|
515
|
+
break;
|
|
516
|
+
case "InvoiceDate":
|
|
517
|
+
case "DueDate":
|
|
518
|
+
if (!isIsoDate(value)) return invalidField(`${key} must be a YYYY-MM-DD date.`);
|
|
519
|
+
scalars[key] = value;
|
|
520
|
+
break;
|
|
521
|
+
case "CostCenter":
|
|
522
|
+
case "Project":
|
|
523
|
+
if (value !== "") return noReferencedEntity(key, value);
|
|
524
|
+
scalars[key] = value;
|
|
525
|
+
break;
|
|
526
|
+
default:
|
|
527
|
+
if (!isInvoiceTextField(key)) return invalidField(`unknown or unsupported invoice field ${key}.`);
|
|
528
|
+
scalars[key] = value;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
scalars,
|
|
533
|
+
customer,
|
|
534
|
+
rows
|
|
535
|
+
};
|
|
536
|
+
};
|
|
537
|
+
const replaceInvoice = (state, updated) => {
|
|
538
|
+
state.invoices = state.invoices.map((invoice) => invoice.DocumentNumber === updated.DocumentNumber ? updated : invoice);
|
|
539
|
+
};
|
|
540
|
+
const updateInvoice = (state, { params, body }, env) => {
|
|
541
|
+
const invoice = findInvoice(state, params.DocumentNumber ?? "");
|
|
542
|
+
if (invoice === void 0) return invoiceNotFound();
|
|
543
|
+
const fields = envelopeObject(body, "Invoice");
|
|
544
|
+
if (fields instanceof Response) return fields;
|
|
545
|
+
if (invoice.Booked) return notEditable("a booked invoice cannot be updated.");
|
|
546
|
+
if (invoice.Cancelled) return notEditable("a cancelled invoice cannot be updated.");
|
|
547
|
+
const updates = invoiceUpdates(fields, state, invoice);
|
|
548
|
+
if (updates instanceof Response) return updates;
|
|
549
|
+
let updated = {
|
|
550
|
+
...invoice,
|
|
551
|
+
...updates.scalars,
|
|
552
|
+
CustomerName: updates.customer?.Name ?? invoice.CustomerName
|
|
553
|
+
};
|
|
554
|
+
if (updates.rows !== void 0) {
|
|
555
|
+
const ids = { next: state.counters.nextRowId };
|
|
556
|
+
const rows = mergeRows(invoice.InvoiceRows, updates.rows, ids, env);
|
|
557
|
+
if (rows instanceof Response) return rows;
|
|
558
|
+
const totals = invoiceTotals(rows);
|
|
559
|
+
updated = {
|
|
560
|
+
...updated,
|
|
561
|
+
InvoiceRows: rows,
|
|
562
|
+
...totals,
|
|
563
|
+
Balance: totals.Total
|
|
564
|
+
};
|
|
565
|
+
state.counters = {
|
|
566
|
+
...state.counters,
|
|
567
|
+
nextRowId: ids.next
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
replaceInvoice(state, updated);
|
|
571
|
+
return jsonResponse(200, { Invoice: renderInvoice(env, updated) });
|
|
572
|
+
};
|
|
573
|
+
const createInvoice = (state, { body }, env) => {
|
|
574
|
+
const fields = envelopeObject(body, "Invoice");
|
|
575
|
+
if (fields instanceof Response) return fields;
|
|
576
|
+
const updates = invoiceUpdates(fields, state, void 0);
|
|
577
|
+
if (updates instanceof Response) return updates;
|
|
578
|
+
const { customer } = updates;
|
|
579
|
+
if (customer === void 0) return invalidField("CustomerNumber is required.");
|
|
580
|
+
const currency = updates.scalars.Currency ?? customer.Currency;
|
|
581
|
+
if (currency !== "SEK") return noReferencedEntity("Currency", currency);
|
|
582
|
+
if (!emulatedCustomerVatTypes.includes(customer.VATType)) return unsupportedVatType(customer.VATType);
|
|
583
|
+
const terms = termsOfPaymentDays(customer.TermsOfPayment);
|
|
584
|
+
if (terms === void 0) return unsupportedTermsOfPayment(customer.TermsOfPayment);
|
|
585
|
+
const invoiceDate = updates.scalars.InvoiceDate ?? today(env);
|
|
586
|
+
const dueDate = updates.scalars.DueDate ?? addDays(invoiceDate, terms);
|
|
587
|
+
if (dueDate === void 0) return invalidField(`the due date ${invoiceDate} + ${terms} days is not a representable YYYY-MM-DD date.`);
|
|
588
|
+
const ids = { next: state.counters.nextRowId };
|
|
589
|
+
const invoice = invoiceFromSeed({
|
|
590
|
+
...updates.scalars,
|
|
591
|
+
DocumentNumber: String(state.counters.nextDocumentNumber),
|
|
592
|
+
CustomerNumber: customer.CustomerNumber,
|
|
593
|
+
InvoiceDate: invoiceDate,
|
|
594
|
+
DueDate: dueDate,
|
|
595
|
+
EmailInformation: defaultEmailInformation(customer, state.company),
|
|
596
|
+
InvoiceRows: updates.rows ?? []
|
|
597
|
+
}, customer, state.company, ids);
|
|
598
|
+
state.invoices = [...state.invoices, invoice].sort((left, right) => compareNumbers(left.DocumentNumber, right.DocumentNumber));
|
|
599
|
+
state.counters = {
|
|
600
|
+
nextRowId: ids.next,
|
|
601
|
+
nextDocumentNumber: state.counters.nextDocumentNumber + 1
|
|
602
|
+
};
|
|
603
|
+
return jsonResponse(201, { Invoice: renderInvoice(env, invoice) });
|
|
604
|
+
};
|
|
605
|
+
const previewInvoice = (state, { params }) => findInvoice(state, params.DocumentNumber ?? "") === void 0 ? invoiceNotFound() : new Response(previewPdfBytes(), {
|
|
606
|
+
status: 200,
|
|
607
|
+
headers: { "content-type": "application/pdf" }
|
|
608
|
+
});
|
|
609
|
+
/** Marks the invoice `Sent` and records an outbox entry. Nothing is ever delivered. */
|
|
610
|
+
const emailInvoice = (state, { params }, env) => {
|
|
611
|
+
const invoice = findInvoice(state, params.DocumentNumber ?? "");
|
|
612
|
+
if (invoice === void 0) return invoiceNotFound();
|
|
613
|
+
if (invoice.Cancelled) return notEditable("a cancelled invoice cannot be sent.");
|
|
614
|
+
const email = invoice.EmailInformation;
|
|
615
|
+
if (email.EmailAddressTo.trim() === "") return invalidField("the invoice has no EmailInformation.EmailAddressTo.");
|
|
616
|
+
const updated = {
|
|
617
|
+
...invoice,
|
|
618
|
+
Sent: true
|
|
619
|
+
};
|
|
620
|
+
replaceInvoice(state, updated);
|
|
621
|
+
state.outbox = [...state.outbox, {
|
|
622
|
+
DocumentNumber: invoice.DocumentNumber,
|
|
623
|
+
EmailAddressTo: email.EmailAddressTo,
|
|
624
|
+
EmailAddressCC: email.EmailAddressCC,
|
|
625
|
+
EmailAddressBCC: email.EmailAddressBCC,
|
|
626
|
+
EmailSubject: email.EmailSubject.replaceAll("{no}", invoice.DocumentNumber),
|
|
627
|
+
sentAt: new Date(env.now()).toISOString()
|
|
628
|
+
}];
|
|
629
|
+
return jsonResponse(200, { Invoice: renderInvoice(env, updated, true) });
|
|
630
|
+
};
|
|
631
|
+
const companyInformation = (state) => jsonResponse(200, { CompanyInformation: state.company });
|
|
632
|
+
const connectorRoute = (method, path, write, caseIds, handler, queryKeys = []) => ({
|
|
633
|
+
method,
|
|
634
|
+
path: `/3${path}`,
|
|
635
|
+
kind: "connector",
|
|
636
|
+
write,
|
|
637
|
+
caseIds,
|
|
638
|
+
evidence: "unverified",
|
|
639
|
+
queryKeys,
|
|
640
|
+
handler
|
|
641
|
+
});
|
|
642
|
+
const listCase = "fortnox.invoice.list-populated";
|
|
643
|
+
const previewCase = "fortnox.invoice.preview-pdf";
|
|
644
|
+
const filtersCase = "fortnox.invoice.payment-filters-exclude-unbooked";
|
|
645
|
+
const discountCase = "fortnox.invoice.row-discount-sticky";
|
|
646
|
+
const emptyStringCase = "fortnox.customer.empty-string-keeps-value";
|
|
647
|
+
const rejectionCase = "fortnox.write.rejection-error-information";
|
|
648
|
+
const emailCase = "fortnox.invoice.send-email";
|
|
649
|
+
/** The route table: evidence plus handler. `fortnoxEmulatorRoutes` is its evidence part. */
|
|
650
|
+
const fortnoxApiRoutes = [
|
|
651
|
+
connectorRoute("GET", "/companyinformation", false, [], companyInformation),
|
|
652
|
+
connectorRoute("GET", "/customers", false, [], listCustomers, customerListQueryKeys),
|
|
653
|
+
connectorRoute("GET", "/customers/{CustomerNumber}", false, [emptyStringCase, rejectionCase], getCustomer),
|
|
654
|
+
connectorRoute("PUT", "/customers/{CustomerNumber}", true, [emptyStringCase], updateCustomer),
|
|
655
|
+
connectorRoute("GET", "/invoices", false, [listCase, filtersCase], listInvoices, invoiceListQueryKeys),
|
|
656
|
+
connectorRoute("POST", "/invoices", true, [rejectionCase], createInvoice),
|
|
657
|
+
connectorRoute("GET", "/invoices/{DocumentNumber}", false, [discountCase, emailCase], getInvoice),
|
|
658
|
+
connectorRoute("PUT", "/invoices/{DocumentNumber}", true, [discountCase], updateInvoice),
|
|
659
|
+
connectorRoute("GET", "/invoices/{DocumentNumber}/preview", false, [previewCase], previewInvoice),
|
|
660
|
+
connectorRoute("GET", "/invoices/{DocumentNumber}/email", true, [emailCase], emailInvoice)
|
|
661
|
+
];
|
|
662
|
+
/** The observed quirks, each tied to its conformance case id. */
|
|
663
|
+
const fortnoxEmulatorQuirks = [
|
|
664
|
+
{
|
|
665
|
+
id: "row-discount-sticky",
|
|
666
|
+
route: "PUT /3/invoices/{DocumentNumber}",
|
|
667
|
+
caseId: discountCase,
|
|
668
|
+
knob: "stickyRowDiscount",
|
|
669
|
+
summary: "InvoiceRows replaces the rows; rows without RowId match by position; an omitted Discount/DiscountType keeps its value; Discount 0 clears; RowIds regenerate; totals are recomputed."
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
id: "empty-string-keeps-value",
|
|
673
|
+
route: "PUT /3/customers/{CustomerNumber}",
|
|
674
|
+
caseId: emptyStringCase,
|
|
675
|
+
knob: "emptyStringClears",
|
|
676
|
+
summary: "A customer update with \"\" for a string field keeps the stored value."
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
id: "payment-filters-exclude-unbooked",
|
|
680
|
+
route: "GET /3/invoices",
|
|
681
|
+
caseId: filtersCase,
|
|
682
|
+
knob: "paymentFiltersIncludeUnbooked",
|
|
683
|
+
summary: "unpaid, unpaidoverdue, and fullypaid consider booked invoices only; unbooked lists unbooked, uncancelled invoices; unpaidoverdue needs DueDate before today."
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
id: "rejection-error-information",
|
|
687
|
+
route: "POST /3/invoices",
|
|
688
|
+
caseId: rejectionCase,
|
|
689
|
+
knob: void 0,
|
|
690
|
+
summary: "Writes for an unknown customer, or with invalid fields, answer 400 with a lowercase ErrorInformation { error, message, code }."
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
id: "customer-country-read-only",
|
|
694
|
+
route: "PUT /3/customers/{CustomerNumber}",
|
|
695
|
+
caseId: void 0,
|
|
696
|
+
knob: void 0,
|
|
697
|
+
summary: "Customer Country is read-only (derived from CountryCode); sending it answers 400 ErrorInformation. No conformance case covers it yet."
|
|
698
|
+
}
|
|
699
|
+
];
|
|
700
|
+
const templatePattern = (template) => new RegExp(`^${template.split(/(\{[A-Za-z]+\})/).map((part) => /^\{[A-Za-z]+\}$/.test(part) ? "([^/]+)" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("")}$`);
|
|
701
|
+
const compiledRoutes = fortnoxApiRoutes.map((route) => ({
|
|
702
|
+
route,
|
|
703
|
+
pattern: templatePattern(route.path)
|
|
704
|
+
}));
|
|
705
|
+
/** The route answering `method` + concrete `path`, or `undefined` (fail closed). */
|
|
706
|
+
const matchFortnoxRoute = (method, path) => compiledRoutes.find((candidate) => candidate.route.method === method.toUpperCase() && candidate.pattern.test(path))?.route;
|
|
707
|
+
const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json));
|
|
708
|
+
/** Parsed JSON, or `undefined` for invalid JSON. */
|
|
709
|
+
const parseJsonText = (text) => {
|
|
710
|
+
const result = decodeJsonText(text);
|
|
711
|
+
return Result.isSuccess(result) ? result.success : void 0;
|
|
712
|
+
};
|
|
713
|
+
/** `{Name}` path templates become `:Name` core route parameters. */
|
|
714
|
+
const corePath = (template) => template.replace(/\{([A-Za-z]+)\}/g, ":$1");
|
|
715
|
+
/**
|
|
716
|
+
* Register every route of the table on the core app, over the generation's state. Each request is
|
|
717
|
+
* planned, then faulted, then committed: the handler runs on a draft of the state (handlers replace
|
|
718
|
+
* whole lists and counters, never edit them in place, so a shallow copy keeps every write off the
|
|
719
|
+
* state); a refusal (any answer that is not 2xx) is sent as is and uses up no fault; a successful
|
|
720
|
+
* answer asks `decideFault`, and only an unfaulted one commits the draft. The three steps run
|
|
721
|
+
* synchronously together, so no other request interleaves. A handler that throws answers
|
|
722
|
+
* `handlerFailedResponse()` (nothing written), which the wrapper turns into its `ErrorInformation`
|
|
723
|
+
* 500 with `responseError` in the ledger.
|
|
724
|
+
*/
|
|
725
|
+
const registerFortnoxApi = (app, state, env, decideFault) => {
|
|
726
|
+
for (const route of fortnoxApiRoutes) app.on(route.method, corePath(route.path), async (context) => {
|
|
727
|
+
try {
|
|
728
|
+
const query = new URL(context.req.url).searchParams;
|
|
729
|
+
const unsupported = unsupportedQueryKey(query, route.queryKeys);
|
|
730
|
+
if (unsupported !== void 0) return unsupported;
|
|
731
|
+
const text = await context.req.text();
|
|
732
|
+
const draft = { ...state };
|
|
733
|
+
const response = route.handler(draft, {
|
|
734
|
+
params: context.req.param(),
|
|
735
|
+
query,
|
|
736
|
+
body: text === "" ? void 0 : parseJsonText(text)
|
|
737
|
+
}, env);
|
|
738
|
+
if (!response.ok) return response;
|
|
739
|
+
if (decideFault(context.req.header("x-emulator-job-id"))) return answeredOutsideCore();
|
|
740
|
+
Object.assign(state, draft);
|
|
741
|
+
return response;
|
|
742
|
+
} catch {
|
|
743
|
+
return handlerFailedResponse();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
};
|
|
747
|
+
//#endregion
|
|
748
|
+
export { errorInformation, fortnoxApiRoutes, fortnoxEmulatorBasePath, fortnoxEmulatorErrorCodes, fortnoxEmulatorQuirks, matchFortnoxRoute, parseJsonText, registerFortnoxApi };
|
|
749
|
+
|
|
750
|
+
//# sourceMappingURL=api.mjs.map
|