diff --git a/bun.lock b/bun.lock
index 144f665ca5..f98ae8bc9c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -2032,6 +2032,22 @@
"typescript": "^5",
},
},
+ "integrations/business-central": {
+ "name": "@slates-integrations/business-central",
+ "version": "0.1.1-rc.2",
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "@vercel/ncc": "^0.38.4",
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
"integrations/businessmap": {
"name": "@slates-integrations/businessmap",
"version": "0.2.0-rc.5",
@@ -4585,6 +4601,21 @@
"typescript": "^5",
},
},
+ "integrations/fiken": {
+ "name": "@slates-integrations/fiken",
+ "version": "0.1.1-rc.2",
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
"integrations/files-com": {
"name": "@slates-integrations/filescom",
"version": "0.2.0-rc.5",
@@ -4623,7 +4654,7 @@
},
"integrations/finago": {
"name": "@slates-integrations/finago",
- "version": "0.1.0-rc.2",
+ "version": "0.1.0-rc.3",
"dependencies": {
"@lowerdeck/error": "^1.1.0",
"@types/node": "^20",
@@ -6449,6 +6480,21 @@
"typescript": "^5",
},
},
+ "integrations/ifs-applications": {
+ "name": "@slates-integrations/ifs-applications",
+ "version": "0.1.1-rc.2",
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
"integrations/ifttt": {
"name": "@slates-integrations/ifttt",
"version": "0.2.0-rc.5",
@@ -9811,7 +9857,7 @@
},
"integrations/poweroffice": {
"name": "@slates-integrations/poweroffice",
- "version": "0.1.0-rc.7",
+ "version": "0.1.0-rc.8",
"dependencies": {
"@lowerdeck/error": "^1.1.0",
"@types/node": "^20",
@@ -10741,6 +10787,21 @@
"typescript": "^5",
},
},
+ "integrations/sap-s4hana": {
+ "name": "@slates-integrations/sap-s4hana",
+ "version": "0.1.1-rc.2",
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
"integrations/sap-successfactors": {
"name": "@slates-integrations/sap-successfactors",
"version": "0.2.0-rc.5",
@@ -11512,7 +11573,7 @@
},
"integrations/slack": {
"name": "@slates-integrations/slack",
- "version": "0.2.0-rc.27",
+ "version": "0.2.0-rc.28",
"dependencies": {
"@lowerdeck/error": "^1.1.0",
"@slates/slack-tools": "1.0.0-rc.8",
@@ -11659,6 +11720,21 @@
"typescript": "^5",
},
},
+ "integrations/sparebank-1-regnskap": {
+ "name": "@slates-integrations/sparebank-1-regnskap",
+ "version": "0.1.1-rc.2",
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
"integrations/split": {
"name": "@slates-integrations/split",
"version": "0.2.0-rc.5",
@@ -12969,6 +13045,21 @@
"typescript": "^5",
},
},
+ "integrations/unimicro": {
+ "name": "@slates-integrations/unimicro",
+ "version": "0.1.1-rc.2",
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
"integrations/unione": {
"name": "@slates-integrations/unione",
"version": "0.2.0-rc.5",
@@ -15157,6 +15248,8 @@
"@slates-integrations/bunnycdn": ["@slates-integrations/bunnycdn@workspace:integrations/bunnycdn"],
+ "@slates-integrations/business-central": ["@slates-integrations/business-central@workspace:integrations/business-central"],
+
"@slates-integrations/businessmap": ["@slates-integrations/businessmap@workspace:integrations/businessmap"],
"@slates-integrations/byteforms": ["@slates-integrations/byteforms@workspace:integrations/byteforms"],
@@ -15575,6 +15668,8 @@
"@slates-integrations/figma": ["@slates-integrations/figma@workspace:integrations/figma"],
+ "@slates-integrations/fiken": ["@slates-integrations/fiken@workspace:integrations/fiken"],
+
"@slates-integrations/filescom": ["@slates-integrations/filescom@workspace:integrations/files-com"],
"@slates-integrations/fillout-forms": ["@slates-integrations/fillout-forms@workspace:integrations/fillout-forms"],
@@ -15869,6 +15964,8 @@
"@slates-integrations/icypeas": ["@slates-integrations/icypeas@workspace:integrations/icypeas"],
+ "@slates-integrations/ifs-applications": ["@slates-integrations/ifs-applications@workspace:integrations/ifs-applications"],
+
"@slates-integrations/ifttt": ["@slates-integrations/ifttt@workspace:integrations/ifttt"],
"@slates-integrations/ignisign": ["@slates-integrations/ignisign@workspace:integrations/ignisign"],
@@ -16559,6 +16656,8 @@
"@slates-integrations/sanity": ["@slates-integrations/sanity@workspace:integrations/sanity"],
+ "@slates-integrations/sap-s4hana": ["@slates-integrations/sap-s4hana@workspace:integrations/sap-s4hana"],
+
"@slates-integrations/sap-successfactors": ["@slates-integrations/sap-successfactors@workspace:integrations/sap-successfactors"],
"@slates-integrations/satismeter": ["@slates-integrations/satismeter@workspace:integrations/satismeter"],
@@ -16709,6 +16808,8 @@
"@slates-integrations/sourcegraph": ["@slates-integrations/sourcegraph@workspace:integrations/sourcegraph"],
+ "@slates-integrations/sparebank-1-regnskap": ["@slates-integrations/sparebank-1-regnskap@workspace:integrations/sparebank-1-regnskap"],
+
"@slates-integrations/split": ["@slates-integrations/split@workspace:integrations/split"],
"@slates-integrations/splitwise": ["@slates-integrations/splitwise@workspace:integrations/splitwise"],
@@ -16935,6 +17036,8 @@
"@slates-integrations/u301": ["@slates-integrations/u301@workspace:integrations/u301"],
+ "@slates-integrations/unimicro": ["@slates-integrations/unimicro@workspace:integrations/unimicro"],
+
"@slates-integrations/unione": ["@slates-integrations/unione@workspace:integrations/unione"],
"@slates-integrations/uniqode": ["@slates-integrations/uniqode@workspace:integrations/uniqode"],
diff --git a/integrations/business-central/README.md b/integrations/business-central/README.md
new file mode 100644
index 0000000000..4f9c2f4fe9
--- /dev/null
+++ b/integrations/business-central/README.md
@@ -0,0 +1,90 @@
+#
Business Central
+
+Read Microsoft Dynamics 365 Business Central ERP data through the official API v2.0. This integration starts with a read-only surface for company discovery, customers, vendors, sales invoices, purchase invoices, sales invoice PDFs, items, chart of accounts, general ledger entries, journals, and document attachment metadata.
+
+## Authentication
+
+Business Central uses Microsoft Entra ID OAuth 2.0. Register an Entra web application with delegated Dynamics 365 Business Central permissions and request `https://api.businesscentral.dynamics.com/Financials.ReadWrite.All` plus `offline_access` so Slates can refresh access tokens.
+
+The OAuth connection can target the `organizations`, `common`, or a specific tenant ID authority. API calls default to the `production` Business Central environment unless `environmentName` is configured or supplied on a tool call.
+
+## Configuration
+
+- `tenantId`: optional Microsoft Entra tenant ID used in Business Central API URLs.
+- `environmentName`: optional default Business Central environment name. Defaults to `production`.
+- `companyId`: optional default Business Central company GUID for company-scoped tools.
+- `defaultLimit`: optional default page size for list tools. Defaults to 50.
+
+## Tools
+
+### List Companies
+
+List Business Central companies accessible in the selected environment. Use the returned company `id` as `companyId` for company-scoped tools.
+
+### List Customers
+
+List customer master records by search text, update timestamp, blocked state, and OData filters.
+
+### Get Customer
+
+Retrieve one customer by Business Central customer GUID.
+
+### List Vendors
+
+List vendor master records by search text, update timestamp, blocked state, and OData filters.
+
+### Get Vendor
+
+Retrieve one vendor by Business Central vendor GUID.
+
+### List Sales Invoices
+
+List sales invoices by status, customer, invoice date, posting date, due date, update timestamp, and OData filters.
+
+### Get Sales Invoice
+
+Retrieve one sales invoice with optional navigation expansion for lines, customer, dimensions, PDF metadata, and attachments.
+
+### Get Sales Invoice PDF
+
+Download a sales invoice PDF through a Slate attachment. File bytes are not returned in JSON output.
+
+### List Purchase Invoices
+
+List purchase invoices by status, vendor, invoice date, posting date, due date, update timestamp, and OData filters.
+
+### Get Purchase Invoice
+
+Retrieve one purchase invoice with optional navigation expansion for lines, vendor, dimensions, and attachments.
+
+### List Items
+
+List items and catalog data by search text, category, type, update timestamp, and OData filters.
+
+### List Accounts
+
+List chart-of-accounts rows by account category, account type, number/name search, and OData filters.
+
+### List General Ledger Entries
+
+List posted general ledger entries by posting date range, account, document, and OData filters.
+
+### List Journals
+
+List journals by code/display name search, template display name, and OData filters.
+
+### List Document Attachments
+
+List document attachment metadata from supported parent records such as customers, vendors, items, invoices, and general ledger entries.
+
+## Notes
+
+Destructive workflows from the research plan, including customer/vendor creation, invoice drafts, posting, cancellation, and payment journal actions, are intentionally excluded from this first release. Business Central posting and correction workflows can create audit artifacts or irreversible accounting records, so they should be added only with tenant-specific live E2E setup and cleanup policy.
+
+## License
+
+This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE).
+
+
diff --git a/integrations/business-central/docs/SPEC.md b/integrations/business-central/docs/SPEC.md
new file mode 100644
index 0000000000..bd2b5bbf62
--- /dev/null
+++ b/integrations/business-central/docs/SPEC.md
@@ -0,0 +1,95 @@
+# Slates Specification for Business Central
+
+## Overview
+
+Microsoft Dynamics 365 Business Central is a cloud ERP for finance, sales, purchasing, inventory, and operations workflows. This Slates integration targets the official Business Central API v2.0 at `https://api.businesscentral.dynamics.com/v2.0`.
+
+The initial package implements read-only tools for company discovery and high-value ERP lookup workflows:
+
+- companies
+- customers and vendors
+- sales and purchase invoices
+- sales invoice PDF download
+- items
+- chart of accounts
+- general ledger entries
+- journals
+- document attachment metadata
+
+## Authentication
+
+Authentication uses Microsoft Entra ID OAuth 2.0 authorization code flow.
+
+- Authorization endpoint: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize`
+- Token endpoint: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token`
+- Business Central delegated scope: `https://api.businesscentral.dynamics.com/Financials.ReadWrite.All`
+- Refresh scope: `offline_access`
+- Identity scopes: `openid`, `profile`, `email`
+
+The OAuth input can optionally specify a tenant authority. When omitted, the integration uses `organizations`. The access token is decoded for stable profile metadata and tenant hints; API calls still use the explicit config/tool `tenantId` when provided.
+
+## Configuration
+
+- `tenantId`: optional Microsoft Entra tenant ID segment for Business Central API URLs.
+- `environmentName`: optional Business Central environment name. Defaults to `production`.
+- `companyId`: optional default company GUID for company-scoped tools.
+- `defaultLimit`: optional default list page size. Defaults to 50 and is capped at 1000.
+
+API base URL construction:
+
+- Without tenant: `https://api.businesscentral.dynamics.com/v2.0/{environmentName}/api/v2.0`
+- With tenant: `https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environmentName}/api/v2.0`
+
+## OData Behavior
+
+List tools support bounded `$top`/`$skip` pagination, `$select`, `$expand`, structured filters, and an advanced `odataFilter` string. Structured filters are joined with `and`. The raw upstream `@odata.nextLink` is preserved when returned, and `nextSkip` is derived from it when possible.
+
+Company-scoped tools use explicit `companies({companyId})/...` paths. A tool-level `companyId` overrides configured `companyId`.
+
+## Implemented Tools
+
+- `list_companies`
+- `list_customers`
+- `get_customer`
+- `list_vendors`
+- `get_vendor`
+- `list_sales_invoices`
+- `get_sales_invoice`
+- `get_sales_invoice_pdf`
+- `list_purchase_invoices`
+- `get_purchase_invoice`
+- `list_items`
+- `list_accounts`
+- `list_general_ledger_entries`
+- `list_journals`
+- `list_document_attachments`
+
+## Error Handling
+
+Validation and upstream failures throw Slates `ServiceError` values through shared `createApiServiceError` and `buildApiServiceError` helpers. OData error envelopes preserve message, code, target, detail, and upstream status where available.
+
+The client retries 408, 429, 503, transient network failures, and similar timeout/reset failures with bounded backoff. It honors `Retry-After` when present.
+
+## File Outputs
+
+`get_sales_invoice_pdf` returns file content only through a Slate attachment. JSON output is limited to metadata such as company id, invoice id, filename, MIME type, byte size, and attachment count.
+
+## Deferred Scope
+
+The research plan identifies P2 write workflows such as customer/vendor creation, invoice draft creation, and invoice posting. Those are intentionally deferred. Business Central posting and cancellation can be irreversible or create accounting audit artifacts, so write tools need tenant-specific sandbox fixtures, cleanup policy, and explicit confirmation semantics before exposure.
+
+## Primary References
+
+- API v2.0 overview: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/
+- Connect apps/auth: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-develop-connect-apps
+- Endpoint structure: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/webservices/api-endpoint-structure
+- Filtering: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-connect-apps-filtering
+- Operational limits: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/operational-limits-online
+- Customers: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_customer
+- Vendors: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_vendor
+- Sales invoices: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_salesinvoice
+- Purchase invoices: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_purchaseinvoice
+- Items: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_item
+- Accounts: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_account
+- General ledger entries: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_generalledgerentry
+- Journals: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_journal
diff --git a/integrations/business-central/package.json b/integrations/business-central/package.json
new file mode 100644
index 0000000000..e5d9543cdd
--- /dev/null
+++ b/integrations/business-central/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@slates-integrations/business-central",
+ "main": "src/index.ts",
+ "type": "module",
+ "scripts": {
+ "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s",
+ "test": "vitest run --passWithNoTests",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2"
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "@vercel/ncc": "^0.38.4",
+ "typescript": "^5",
+ "vitest": "^3.1.2"
+ },
+ "version": "0.1.1-rc.2"
+}
diff --git a/integrations/business-central/slate.json b/integrations/business-central/slate.json
new file mode 100644
index 0000000000..705787d48a
--- /dev/null
+++ b/integrations/business-central/slate.json
@@ -0,0 +1,22 @@
+{
+ "name": "@microsoft/business-central",
+ "description": "Read Microsoft Dynamics 365 Business Central company, customer, vendor, invoice, item, chart of accounts, ledger, journal, document attachment, and sales invoice PDF data through the official Business Central API v2.0.",
+ "categories": [
+ "financial-data-and-stock-market",
+ "crm-and-sales-tools",
+ "apis-and-http-requests"
+ ],
+ "skills": [
+ "discover Business Central companies",
+ "look up customers and vendors",
+ "inspect sales and purchase invoices",
+ "download sales invoice PDFs",
+ "list items and catalog data",
+ "inspect chart of accounts",
+ "review general ledger entries",
+ "list journals",
+ "discover document attachments",
+ "query ERP accounting data"
+ ],
+ "logoUrl": "https://provider-logos.metorial-cdn.com/Dynamics%20365%20Icon.svg"
+}
diff --git a/integrations/business-central/src/auth.ts b/integrations/business-central/src/auth.ts
new file mode 100644
index 0000000000..69585b8173
--- /dev/null
+++ b/integrations/business-central/src/auth.ts
@@ -0,0 +1,324 @@
+import { createAxios, normalizeOAuthTokenResponse, requestAxiosData, SlateAuth } from 'slates';
+import { z } from 'zod';
+import { businessCentralApiError, businessCentralValidationError } from './lib/errors';
+
+let MICROSOFT_LOGIN_BASE = 'https://login.microsoftonline.com';
+let DEFAULT_TENANT = 'organizations';
+let BUSINESS_CENTRAL_SCOPE =
+ 'https://api.businesscentral.dynamics.com/Financials.ReadWrite.All';
+
+type BusinessCentralOAuthInput = {
+ tenantId?: string;
+};
+
+type BusinessCentralAuthOutput = {
+ token: string;
+ refreshToken?: string;
+ expiresAt?: string;
+ tokenType?: string;
+ tenantId?: string;
+ scopes?: string[];
+};
+
+type BusinessCentralCallbackContext = {
+ clientId: string;
+ clientSecret: string;
+ code: string;
+ redirectUri: string;
+ input: BusinessCentralOAuthInput;
+ scopes: string[];
+};
+
+type BusinessCentralRefreshContext = {
+ clientId: string;
+ clientSecret: string;
+ input: BusinessCentralOAuthInput;
+ output: BusinessCentralAuthOutput;
+ scopes: string[];
+};
+
+type BusinessCentralProfileContext = {
+ output: BusinessCentralAuthOutput;
+};
+
+type MicrosoftTokenResponse = {
+ access_token?: unknown;
+ refresh_token?: unknown;
+ expires_in?: unknown;
+ token_type?: unknown;
+ scope?: unknown;
+ id_token?: unknown;
+};
+
+let oauthInputSchema = z.object({
+ tenantId: z
+ .string()
+ .optional()
+ .describe(
+ 'Microsoft Entra tenant authority for OAuth. Use "organizations", "common", or a tenant ID. Defaults to "organizations".'
+ )
+});
+
+let oauthScopes = [
+ {
+ title: 'Business Central Read/Write Financials',
+ description:
+ 'Delegated access to Dynamics 365 Business Central financial data through the official API.',
+ scope: BUSINESS_CENTRAL_SCOPE
+ },
+ {
+ title: 'Offline Access',
+ description: 'Maintain access with refresh tokens.',
+ scope: 'offline_access'
+ },
+ {
+ title: 'OpenID',
+ description: 'Request OpenID identity claims for profile metadata.',
+ scope: 'openid'
+ },
+ {
+ title: 'Profile',
+ description: 'Request basic signed-in user profile claims.',
+ scope: 'profile'
+ },
+ {
+ title: 'Email',
+ description: 'Request signed-in user email claims when available.',
+ scope: 'email',
+ defaultChecked: false
+ }
+];
+
+let tokenHttpCache = new Map>();
+
+let resolveTenant = (input: BusinessCentralOAuthInput | undefined) => {
+ let tenant = input?.tenantId?.trim() || DEFAULT_TENANT;
+ if (tenant.includes('/')) {
+ throw businessCentralValidationError('Microsoft tenant ID cannot contain "/".');
+ }
+
+ return tenant;
+};
+
+let tokenHttp = (tenant: string) => {
+ let cached = tokenHttpCache.get(tenant);
+ if (cached) return cached;
+
+ let client = createAxios({
+ baseURL: `${MICROSOFT_LOGIN_BASE}/${encodeURIComponent(tenant)}/oauth2/v2.0`
+ });
+ tokenHttpCache.set(tenant, client);
+ return client;
+};
+
+let uniqueScopes = (scopes: string[]) => [...new Set(scopes.filter(Boolean))];
+
+let scopeParam = (scopes: string[]) => uniqueScopes(scopes).join(' ');
+
+let parseGrantedScopes = (scope: unknown, fallback: string[]) => {
+ if (Array.isArray(scope)) {
+ return uniqueScopes(scope.filter((item): item is string => typeof item === 'string'));
+ }
+
+ if (typeof scope === 'string') {
+ return uniqueScopes(scope.split(/\s+/g));
+ }
+
+ return uniqueScopes(fallback);
+};
+
+let decodeBase64Url = (value: string) => {
+ let normalized = value.replace(/-/g, '+').replace(/_/g, '/');
+ let padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
+ return Buffer.from(`${normalized}${padding}`, 'base64').toString('utf8');
+};
+
+let decodeJwtPayload = (token: string | undefined) => {
+ if (!token) return {};
+ let payload = token.split('.')[1];
+ if (!payload) return {};
+
+ try {
+ let parsed = JSON.parse(decodeBase64Url(payload));
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : {};
+ } catch {
+ return {};
+ }
+};
+
+let stringClaim = (claims: Record, ...keys: string[]) => {
+ for (let key of keys) {
+ let value = claims[key];
+ if (typeof value === 'string' && value.trim()) return value;
+ }
+
+ return undefined;
+};
+
+let normalizeToken = (
+ data: MicrosoftTokenResponse,
+ options: {
+ operation: string;
+ requestedScopes: string[];
+ previousRefreshToken?: string;
+ tenant: string;
+ }
+) => {
+ let normalized = normalizeOAuthTokenResponse(data, {
+ providerLabel: 'Microsoft',
+ operation: options.operation,
+ previousRefreshToken: options.previousRefreshToken,
+ refreshTokenFallbackMode: 'falsy'
+ });
+ let claims = decodeJwtPayload(normalized.token);
+
+ return {
+ token: normalized.token,
+ refreshToken: normalized.refreshToken,
+ expiresAt: normalized.expiresAt,
+ tokenType: typeof data.token_type === 'string' ? data.token_type : 'Bearer',
+ tenantId: stringClaim(claims, 'tid') ?? options.tenant,
+ scopes: parseGrantedScopes(data.scope, options.requestedScopes)
+ };
+};
+
+let requestToken = async (
+ tenant: string,
+ operation: string,
+ body: URLSearchParams
+): Promise =>
+ requestAxiosData(
+ operation,
+ () =>
+ tokenHttp(tenant).post('/token', body.toString(), {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Accept: 'application/json'
+ }
+ }),
+ businessCentralApiError
+ );
+
+export let auth = SlateAuth.create()
+ .output(
+ z.object({
+ token: z.string(),
+ refreshToken: z.string().optional(),
+ expiresAt: z.string().optional(),
+ tokenType: z.string().optional(),
+ tenantId: z.string().optional(),
+ scopes: z.array(z.string()).optional()
+ })
+ )
+ .addOauth({
+ type: 'auth.oauth',
+ name: 'Microsoft Entra OAuth',
+ key: 'oauth',
+ inputSchema: oauthInputSchema,
+ docs: [
+ {
+ type: 'docs.auth.oauth',
+ name: 'Microsoft identity platform OAuth documentation',
+ url: 'https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow'
+ },
+ {
+ type: 'docs.auth.oauth_scopes',
+ name: 'Business Central connect apps documentation',
+ url: 'https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-develop-connect-apps'
+ }
+ ],
+ scopes: oauthScopes,
+
+ getAuthorizationUrl: async ctx => {
+ let tenant = resolveTenant(ctx.input);
+ let params = new URLSearchParams({
+ client_id: ctx.clientId,
+ response_type: 'code',
+ redirect_uri: ctx.redirectUri,
+ response_mode: 'query',
+ scope: scopeParam(ctx.scopes),
+ state: ctx.state
+ });
+
+ return {
+ url: `${MICROSOFT_LOGIN_BASE}/${encodeURIComponent(tenant)}/oauth2/v2.0/authorize?${params.toString()}`
+ };
+ },
+
+ handleCallback: async (ctx: BusinessCentralCallbackContext) => {
+ let tenant = resolveTenant(ctx.input);
+ let requestedScopes = uniqueScopes(ctx.scopes);
+ let data = await requestToken(
+ tenant,
+ 'OAuth token exchange',
+ new URLSearchParams({
+ client_id: ctx.clientId,
+ client_secret: ctx.clientSecret,
+ code: ctx.code,
+ redirect_uri: ctx.redirectUri,
+ grant_type: 'authorization_code',
+ scope: scopeParam(requestedScopes)
+ })
+ );
+
+ return {
+ output: normalizeToken(data, {
+ operation: 'token exchange',
+ requestedScopes,
+ tenant
+ })
+ };
+ },
+
+ handleTokenRefresh: async (ctx: BusinessCentralRefreshContext) => {
+ if (!ctx.output.refreshToken) {
+ throw businessCentralValidationError(
+ 'No Microsoft refresh token is available. Reconnect Business Central with offline_access enabled.',
+ { reason: 'oauth_refresh_token_missing' }
+ );
+ }
+
+ let tenant = resolveTenant(ctx.input);
+ let requestedScopes = uniqueScopes(ctx.output.scopes ?? ctx.scopes);
+ let data = await requestToken(
+ tenant,
+ 'OAuth token refresh',
+ new URLSearchParams({
+ client_id: ctx.clientId,
+ client_secret: ctx.clientSecret,
+ refresh_token: ctx.output.refreshToken,
+ grant_type: 'refresh_token',
+ scope: scopeParam(requestedScopes)
+ })
+ );
+
+ return {
+ output: normalizeToken(data, {
+ operation: 'token refresh',
+ requestedScopes,
+ previousRefreshToken: ctx.output.refreshToken,
+ tenant
+ })
+ };
+ },
+
+ getProfile: async (ctx: BusinessCentralProfileContext) => {
+ let claims = decodeJwtPayload(ctx.output.token);
+ let id =
+ stringClaim(claims, 'oid', 'sub') ??
+ stringClaim(claims, 'tid') ??
+ ctx.output.tenantId ??
+ 'business-central-user';
+
+ return {
+ profile: {
+ id,
+ name: stringClaim(claims, 'name'),
+ email: stringClaim(claims, 'preferred_username', 'upn', 'email'),
+ tenantId: stringClaim(claims, 'tid') ?? ctx.output.tenantId
+ }
+ };
+ }
+ });
diff --git a/integrations/business-central/src/config.ts b/integrations/business-central/src/config.ts
new file mode 100644
index 0000000000..2abdaeb985
--- /dev/null
+++ b/integrations/business-central/src/config.ts
@@ -0,0 +1,26 @@
+import { SlateConfig } from 'slates';
+import { z } from 'zod';
+
+export let config = SlateConfig.create(
+ z.object({
+ tenantId: z
+ .string()
+ .optional()
+ .describe('Optional Microsoft Entra tenant ID to include in Business Central API URLs.'),
+ environmentName: z
+ .string()
+ .optional()
+ .describe('Default Business Central environment name. Defaults to "production".'),
+ companyId: z
+ .string()
+ .optional()
+ .describe('Default Business Central company GUID for company-scoped tools.'),
+ defaultLimit: z
+ .number()
+ .int()
+ .min(1)
+ .max(1000)
+ .optional()
+ .describe('Default list page size for tools when limit is omitted. Defaults to 50.')
+ })
+);
diff --git a/integrations/business-central/src/index.ts b/integrations/business-central/src/index.ts
new file mode 100644
index 0000000000..e3eacdae90
--- /dev/null
+++ b/integrations/business-central/src/index.ts
@@ -0,0 +1,41 @@
+import { Slate } from 'slates';
+import { spec } from './spec';
+import {
+ getCustomer,
+ getPurchaseInvoice,
+ getSalesInvoice,
+ getSalesInvoicePdf,
+ getVendor,
+ listAccounts,
+ listCompanies,
+ listCustomers,
+ listDocumentAttachments,
+ listGeneralLedgerEntries,
+ listItems,
+ listJournals,
+ listPurchaseInvoices,
+ listSalesInvoices,
+ listVendors
+} from './tools';
+
+export let provider = Slate.create({
+ spec,
+ tools: [
+ listCompanies,
+ listCustomers,
+ getCustomer,
+ listVendors,
+ getVendor,
+ listSalesInvoices,
+ getSalesInvoice,
+ getSalesInvoicePdf,
+ listPurchaseInvoices,
+ getPurchaseInvoice,
+ listItems,
+ listAccounts,
+ listGeneralLedgerEntries,
+ listJournals,
+ listDocumentAttachments
+ ],
+ triggers: []
+});
diff --git a/integrations/business-central/src/lib/client.test.ts b/integrations/business-central/src/lib/client.test.ts
new file mode 100644
index 0000000000..d386809a4a
--- /dev/null
+++ b/integrations/business-central/src/lib/client.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let axiosMocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn()
+ },
+ createAuthenticatedAxios: vi.fn()
+}));
+
+vi.mock('slates', async importOriginal => {
+ let actual = await importOriginal();
+
+ return {
+ ...actual,
+ createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios
+ };
+});
+
+import { BusinessCentralClient } from './client';
+
+beforeEach(() => {
+ axiosMocks.api.get.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReturnValue(axiosMocks.api);
+});
+
+describe('BusinessCentralClient retry behavior', () => {
+ it('retries documented gateway timeout responses for OData list requests', async () => {
+ axiosMocks.api.get
+ .mockRejectedValueOnce({
+ response: {
+ status: 504,
+ headers: {
+ 'Retry-After': '0'
+ },
+ data: {
+ error: {
+ code: 'GatewayTimeout',
+ message: 'Gateway Timeout'
+ }
+ }
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '11111111-1111-1111-1111-111111111111',
+ name: 'CRONUS'
+ }
+ ]
+ }
+ });
+
+ let client = new BusinessCentralClient({ token: 'token' });
+ let result = await client.getList('list companies', '/companies', { $top: 1 });
+
+ expect(result.value).toEqual([
+ {
+ id: '11111111-1111-1111-1111-111111111111',
+ name: 'CRONUS'
+ }
+ ]);
+ expect(axiosMocks.api.get).toHaveBeenCalledTimes(2);
+ expect(axiosMocks.api.get).toHaveBeenNthCalledWith(1, '/companies', {
+ params: { $top: 1 }
+ });
+ });
+});
diff --git a/integrations/business-central/src/lib/client.ts b/integrations/business-central/src/lib/client.ts
new file mode 100644
index 0000000000..847122b582
--- /dev/null
+++ b/integrations/business-central/src/lib/client.ts
@@ -0,0 +1,218 @@
+import { createAuthenticatedAxios, getResponseHeaderValue } from 'slates';
+import { businessCentralApiError, businessCentralValidationError } from './errors';
+
+export type BusinessCentralClientConfig = {
+ token: string;
+ tenantId?: string;
+ environmentName?: string;
+};
+
+export type ODataListResponse = {
+ value?: T[];
+ '@odata.nextLink'?: string;
+ [key: string]: unknown;
+};
+
+export type DownloadedFile = {
+ contentBase64: string;
+ mimeType: string;
+ size: number;
+};
+
+let BUSINESS_CENTRAL_BASE_URL = 'https://api.businesscentral.dynamics.com/v2.0';
+let DEFAULT_ENVIRONMENT = 'production';
+let RETRYABLE_STATUSES = new Set([408, 429, 503, 504]);
+let RETRYABLE_ERROR_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'EAI_AGAIN']);
+
+let sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+let serializeParams = (params: Record) => {
+ let search = new URLSearchParams();
+
+ for (let [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null || value === '') continue;
+
+ if (Array.isArray(value)) {
+ let joined = value
+ .filter(item => item !== undefined && item !== null && item !== '')
+ .join(',');
+ if (joined) search.append(key, joined);
+ continue;
+ }
+
+ search.append(key, String(value));
+ }
+
+ return search.toString();
+};
+
+let normalizeSegment = (value: string | undefined, fallback?: string) => {
+ let normalized = value?.trim() || fallback;
+ if (!normalized) return undefined;
+
+ if (normalized.includes('/')) {
+ throw businessCentralValidationError('Business Central URL segments cannot contain "/".');
+ }
+
+ return encodeURIComponent(normalized);
+};
+
+export let getBusinessCentralBaseUrl = (config: {
+ tenantId?: string;
+ environmentName?: string;
+}) => {
+ let environment = normalizeSegment(config.environmentName, DEFAULT_ENVIRONMENT);
+ let tenant = normalizeSegment(config.tenantId);
+ let environmentPath = tenant ? `${tenant}/${environment}` : environment;
+
+ return `${BUSINESS_CENTRAL_BASE_URL}/${environmentPath}/api/v2.0`;
+};
+
+export let businessCentralEntityPath = (collection: string, id: string) =>
+ `${collection}(${encodeURIComponent(id)})`;
+
+let getResponseStatus = (error: unknown) => {
+ if (typeof error !== 'object' || error === null || !('response' in error)) {
+ return undefined;
+ }
+
+ let response = (error as { response?: { status?: unknown } }).response;
+ return typeof response?.status === 'number' ? response.status : undefined;
+};
+
+let getErrorCode = (error: unknown) => {
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
+ return undefined;
+ }
+
+ let code = (error as { code?: unknown }).code;
+ return typeof code === 'string' ? code : undefined;
+};
+
+let retryAfterMs = (error: unknown) => {
+ if (typeof error !== 'object' || error === null || !('response' in error)) {
+ return undefined;
+ }
+
+ let response = (error as { response?: { headers?: Record } }).response;
+ let raw =
+ response?.headers?.['retry-after'] ??
+ response?.headers?.['Retry-After'] ??
+ response?.headers?.['x-ms-retry-after-ms'];
+
+ if (typeof raw !== 'string' && typeof raw !== 'number') return undefined;
+
+ let text = String(raw).trim();
+ let seconds = Number(text);
+ if (Number.isFinite(seconds)) {
+ return Math.max(0, seconds * 1000);
+ }
+
+ let dateMs = Date.parse(text);
+ return Number.isFinite(dateMs) ? Math.max(0, dateMs - Date.now()) : undefined;
+};
+
+let isRetryableError = (error: unknown) => {
+ let status = getResponseStatus(error);
+ if (status !== undefined) return RETRYABLE_STATUSES.has(status);
+
+ let code = getErrorCode(error);
+ return code ? RETRYABLE_ERROR_CODES.has(code) : false;
+};
+
+let normalizeArrayBuffer = (value: unknown) => {
+ if (Buffer.isBuffer(value)) return value;
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
+ if (ArrayBuffer.isView(value)) {
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
+ }
+ if (typeof value === 'string') return Buffer.from(value, 'binary');
+
+ throw businessCentralValidationError(
+ 'Business Central file download returned an unsupported response body.'
+ );
+};
+
+export class BusinessCentralClient {
+ private http: ReturnType;
+
+ constructor(config: BusinessCentralClientConfig) {
+ this.http = createAuthenticatedAxios({
+ baseURL: getBusinessCentralBaseUrl(config),
+ authHeader: { value: `Bearer ${config.token}` },
+ headers: {
+ Accept: 'application/json',
+ 'OData-MaxVersion': '4.0',
+ 'OData-Version': '4.0'
+ },
+ paramsSerializer: { serialize: serializeParams }
+ });
+ }
+
+ private async withRetry(operation: string, run: () => Promise): Promise {
+ let lastError: unknown;
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ return await run();
+ } catch (error) {
+ lastError = error;
+
+ if (!isRetryableError(error) || attempt === 2) {
+ throw businessCentralApiError(error, operation);
+ }
+
+ await sleep(retryAfterMs(error) ?? 500 * 2 ** attempt);
+ }
+ }
+
+ throw businessCentralApiError(lastError, operation);
+ }
+
+ getData(operation: string, path: string, params?: Record) {
+ return this.withRetry(operation, async () => {
+ let response = await this.http.get(path, { params });
+ return response.data as T;
+ });
+ }
+
+ async getList(
+ operation: string,
+ path: string,
+ params?: Record
+ ): Promise> {
+ let data = await this.getData>(operation, path, params);
+ if (!data || typeof data !== 'object' || !Array.isArray(data.value)) {
+ throw businessCentralValidationError(
+ `Business Central ${operation} did not return an OData value list.`
+ );
+ }
+
+ return data;
+ }
+
+ async downloadFile(
+ operation: string,
+ path: string,
+ params?: {
+ acceptLanguage?: string;
+ }
+ ): Promise {
+ let response = await this.withRetry(operation, () =>
+ this.http.get(path, {
+ responseType: 'arraybuffer',
+ headers: {
+ Accept: 'application/pdf',
+ ...(params?.acceptLanguage ? { 'Accept-Language': params.acceptLanguage } : {})
+ }
+ })
+ );
+ let buffer = normalizeArrayBuffer(response.data);
+
+ return {
+ contentBase64: buffer.toString('base64'),
+ mimeType: getResponseHeaderValue(response.headers, 'content-type') ?? 'application/pdf',
+ size: buffer.byteLength
+ };
+ }
+}
diff --git a/integrations/business-central/src/lib/errors.ts b/integrations/business-central/src/lib/errors.ts
new file mode 100644
index 0000000000..18fc392a21
--- /dev/null
+++ b/integrations/business-central/src/lib/errors.ts
@@ -0,0 +1,74 @@
+import {
+ buildApiServiceError,
+ collectApiErrorDetails,
+ createApiServiceError,
+ isApiErrorRecord
+} from 'slates';
+
+export let businessCentralValidationError = (
+ message: string,
+ options: {
+ reason?: string;
+ upstreamStatus?: number | string;
+ upstreamCode?: string;
+ parent?: unknown;
+ } = {}
+) =>
+ createApiServiceError(message, {
+ reason: options.reason ?? 'business_central_validation_error',
+ upstreamStatus: options.upstreamStatus,
+ upstreamCode: options.upstreamCode,
+ parent: options.parent
+ });
+
+let odataErrorRecord = (value: unknown) => {
+ if (!isApiErrorRecord(value)) return undefined;
+ let error = value.error;
+ return isApiErrorRecord(error) ? error : undefined;
+};
+
+let collectODataDetails = (value: unknown) => {
+ let details: string[] = [];
+ let error = odataErrorRecord(value);
+
+ if (error) {
+ collectApiErrorDetails(error, details, {
+ detailKeys: ['message', 'code', 'target'],
+ nestedKeys: ['details', 'innererror', 'errors'],
+ includeNumbers: true
+ });
+ }
+
+ collectApiErrorDetails(value, details, {
+ detailKeys: ['message', 'detail', 'error', 'error_description', 'code', 'target'],
+ nestedKeys: ['details', 'errors'],
+ includeNumbers: true
+ });
+
+ return details;
+};
+
+export let businessCentralApiError = (error: unknown, operation = 'request') =>
+ buildApiServiceError(error, {
+ providerLabel: 'Business Central',
+ reason: 'business_central_api_error',
+ operation,
+ detailKeys: ['message', 'detail', 'error', 'error_description', 'code', 'target'],
+ nestedKeys: ['details', 'errors', 'innererror'],
+ extractMessage: (input, helpers) => {
+ let response = helpers.getResponse(input);
+ let details = collectODataDetails(response?.data);
+
+ if (details.length > 0) {
+ return details.join(' - ');
+ }
+
+ if (input instanceof Error && input.message) return input.message;
+ return undefined;
+ },
+ extractUpstreamCode: (_input, response) => {
+ let errorRecord = odataErrorRecord(response?.data);
+ let code = errorRecord?.code;
+ return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined;
+ }
+ });
diff --git a/integrations/business-central/src/spec.ts b/integrations/business-central/src/spec.ts
new file mode 100644
index 0000000000..aa38f37f59
--- /dev/null
+++ b/integrations/business-central/src/spec.ts
@@ -0,0 +1,13 @@
+import { SlateSpecification } from 'slates';
+import { auth } from './auth';
+import { config } from './config';
+
+export let spec = SlateSpecification.create({
+ key: 'business-central',
+ name: 'Business Central',
+ description:
+ 'Read Microsoft Dynamics 365 Business Central ERP companies, customers, vendors, invoices, items, chart of accounts, general ledger entries, journals, document attachments, and sales invoice PDFs through the official API v2.0.',
+ metadata: {},
+ config,
+ auth
+});
diff --git a/integrations/business-central/src/tools.attachments.test.ts b/integrations/business-central/src/tools.attachments.test.ts
new file mode 100644
index 0000000000..d98f22b83c
--- /dev/null
+++ b/integrations/business-central/src/tools.attachments.test.ts
@@ -0,0 +1,147 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let axiosMocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn()
+ },
+ createAuthenticatedAxios: vi.fn()
+}));
+
+vi.mock('slates', async importOriginal => {
+ let actual = await importOriginal();
+
+ return {
+ ...actual,
+ createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios
+ };
+});
+
+import { listDocumentAttachments } from './tools/attachments';
+
+let invokeListDocumentAttachments = (input: Record) =>
+ listDocumentAttachments.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+beforeEach(() => {
+ axiosMocks.api.get.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReturnValue(axiosMocks.api);
+});
+
+describe('Business Central list document attachments', () => {
+ it('uses the documented documentAttachments navigation and maps byteSize metadata', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: 'ATT00089',
+ fileName: 'Invoice_10542.pdf',
+ byteSize: 245823,
+ attachmentContent: 'JVBERi0x',
+ parentType: 'Purchase Invoice',
+ parentId: '22222222-2222-2222-2222-222222222222',
+ lineNumber: 0,
+ documentFlowSales: false,
+ documentFlowPurchase: true,
+ lastModifiedDateTime: '2025-04-28T09:15:42Z'
+ }
+ ]
+ }
+ });
+
+ let result = await invokeListDocumentAttachments({
+ parentResource: 'purchaseInvoice',
+ parentId: '22222222-2222-2222-2222-222222222222',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/purchaseInvoices(22222222-2222-2222-2222-222222222222)/documentAttachments',
+ {
+ params: {
+ $top: 5,
+ $skip: 0
+ }
+ }
+ );
+ expect(result.output.documentAttachments[0]).toMatchObject({
+ id: 'ATT00089',
+ fileName: 'Invoice_10542.pdf',
+ parentResource: 'purchaseInvoice',
+ parentId: '22222222-2222-2222-2222-222222222222',
+ parentType: 'Purchase Invoice',
+ byteSize: 245823,
+ size: 245823,
+ lineNumber: 0,
+ documentFlowSales: false,
+ documentFlowPurchase: true,
+ lastModifiedDateTime: '2025-04-28T09:15:42Z'
+ });
+ expect(result.output.documentAttachments[0].record).not.toHaveProperty(
+ 'attachmentContent'
+ );
+ });
+
+ it('uses the documented attachments navigation for general ledger entries', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '33333333-3333-3333-3333-333333333333',
+ parentId: '44444444-4444-4444-4444-444444444444',
+ fileName: 'receipt.pdf',
+ byteSize: 1024,
+ parentType: 'Journal'
+ }
+ ]
+ }
+ });
+
+ let result = await invokeListDocumentAttachments({
+ parentResource: 'generalLedgerEntry',
+ parentId: '44444444-4444-4444-4444-444444444444',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/generalLedgerEntries(44444444-4444-4444-4444-444444444444)/attachments',
+ {
+ params: {
+ $top: 5,
+ $skip: 0
+ }
+ }
+ );
+ expect(result.output.documentAttachments[0]).toMatchObject({
+ id: '33333333-3333-3333-3333-333333333333',
+ parentResource: 'generalLedgerEntry',
+ parentId: '44444444-4444-4444-4444-444444444444',
+ fileName: 'receipt.pdf',
+ byteSize: 1024,
+ size: 1024,
+ parentType: 'Journal'
+ });
+ });
+
+ it('rejects inline attachment content selection for metadata listing', async () => {
+ await expect(
+ invokeListDocumentAttachments({
+ parentResource: 'purchaseInvoice',
+ parentId: '22222222-2222-2222-2222-222222222222',
+ select: ['id', 'attachmentContent']
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+ await expect(
+ invokeListDocumentAttachments({
+ parentResource: 'purchaseInvoice',
+ parentId: '22222222-2222-2222-2222-222222222222',
+ select: ['id', 'attachmentContent']
+ })
+ ).rejects.toThrow('returns metadata only');
+ expect(axiosMocks.api.get).not.toHaveBeenCalled();
+ });
+});
diff --git a/integrations/business-central/src/tools.catalog-accounting.test.ts b/integrations/business-central/src/tools.catalog-accounting.test.ts
new file mode 100644
index 0000000000..2c7351dc40
--- /dev/null
+++ b/integrations/business-central/src/tools.catalog-accounting.test.ts
@@ -0,0 +1,367 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let axiosMocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn()
+ },
+ createAuthenticatedAxios: vi.fn()
+}));
+
+vi.mock('slates', async importOriginal => {
+ let actual = await importOriginal();
+
+ return {
+ ...actual,
+ createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios
+ };
+});
+
+import {
+ listAccounts,
+ listGeneralLedgerEntries,
+ listItems,
+ listJournals
+} from './tools/catalog-accounting';
+
+let invokeListItems = (input: Record) =>
+ listItems.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+let invokeListAccounts = (input: Record) =>
+ listAccounts.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+let invokeListGeneralLedgerEntries = (input: Record) =>
+ listGeneralLedgerEntries.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+let invokeListJournals = (input: Record) =>
+ listJournals.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+beforeEach(() => {
+ axiosMocks.api.get.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReturnValue(axiosMocks.api);
+});
+
+describe('Business Central list items', () => {
+ it('uses schema version 2.1 for nested search filters', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '22222222-2222-2222-2222-222222222222',
+ number: '1896-S',
+ displayName: 'ATHENS Desk',
+ type: 'Inventory',
+ itemCategoryCode: 'TABLE',
+ blocked: false,
+ inventory: 4,
+ unitPrice: 1000.8,
+ unitCost: 780.7,
+ priceIncludesTax: false
+ }
+ ]
+ }
+ });
+
+ let result = await invokeListItems({
+ search: ' Desk ',
+ category: 'TABLE',
+ type: 'Inventory',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/items',
+ {
+ params: {
+ $top: 5,
+ $skip: 0,
+ $filter:
+ "((contains(tolower(displayName),'desk') or contains(tolower(number),'desk'))) and (itemCategoryCode eq 'TABLE') and (type eq 'Inventory')",
+ $schemaversion: '2.1'
+ }
+ }
+ );
+ expect(result.output.items[0]).toMatchObject({
+ id: '22222222-2222-2222-2222-222222222222',
+ number: '1896-S',
+ displayName: 'ATHENS Desk',
+ type: 'Inventory',
+ itemCategoryCode: 'TABLE',
+ blocked: false,
+ inventory: 4,
+ unitPrice: 1000.8,
+ unitCost: 780.7,
+ priceIncludesTax: false
+ });
+ });
+
+ it('does not request schema version 2.1 without a search filter', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: []
+ }
+ });
+
+ await invokeListItems({
+ category: 'TABLE',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/items',
+ {
+ params: {
+ $top: 5,
+ $skip: 0,
+ $filter: "itemCategoryCode eq 'TABLE'"
+ }
+ }
+ );
+ });
+});
+
+describe('Business Central list accounts', () => {
+ it('searches account numbers with schema version 2.1 by default', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '33333333-3333-3333-3333-333333333333',
+ number: '2910',
+ displayName: 'VAT Payable',
+ category: 'Liabilities',
+ subCategory: 'VAT',
+ accountType: 'Posting',
+ directPosting: true,
+ incomeBalance: 'Balance Sheet',
+ debitCreditBalance: 'Credit',
+ netChange: -12.34,
+ totalBalance: 100.5,
+ balance: 100.5,
+ consolidationTranslationMethod: 'Average Rate',
+ consolidationDebitAccount: '2911',
+ consolidationCreditAccount: '2912',
+ excludeFromConsolidation: false,
+ blocked: false
+ }
+ ]
+ }
+ });
+
+ let result = await invokeListAccounts({
+ search: ' 2910 ',
+ accountCategory: 'Liabilities',
+ accountType: 'Posting',
+ limit: 10
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/accounts',
+ {
+ params: {
+ $top: 10,
+ $skip: 0,
+ $filter:
+ "((contains(tolower(number),'2910'))) and (category eq 'Liabilities') and (accountType eq 'Posting')",
+ $schemaversion: '2.1'
+ }
+ }
+ );
+ expect(result.output.accounts[0]).toMatchObject({
+ id: '33333333-3333-3333-3333-333333333333',
+ number: '2910',
+ displayName: 'VAT Payable',
+ category: 'Liabilities',
+ accountType: 'Posting',
+ directPosting: true,
+ incomeBalance: 'Balance Sheet',
+ debitCreditBalance: 'Credit',
+ netChange: -12.34,
+ totalBalance: 100.5,
+ balance: 100.5,
+ consolidationTranslationMethod: 'Average Rate',
+ consolidationDebitAccount: '2911',
+ consolidationCreditAccount: '2912',
+ excludeFromConsolidation: false,
+ blocked: false
+ });
+ });
+
+ it('can target display names without building a cross-field or filter', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: []
+ }
+ });
+
+ await invokeListAccounts({
+ search: 'bank',
+ searchField: 'displayName',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/accounts',
+ {
+ params: {
+ $top: 5,
+ $skip: 0,
+ $filter: "(contains(tolower(displayName),'bank'))",
+ $schemaversion: '2.1'
+ }
+ }
+ );
+ });
+});
+
+describe('Business Central list general ledger entries', () => {
+ it('uses the documented collection path and maps documented additional currency fields', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '44444444-4444-4444-4444-444444444444',
+ entryNumber: 123,
+ postingDate: '2026-01-31',
+ documentNumber: 'G00001',
+ documentType: 'Invoice',
+ accountId: '33333333-3333-3333-3333-333333333333',
+ accountNumber: '2910',
+ description: 'VAT payable',
+ debitAmount: 0,
+ creditAmount: 25.5,
+ additionalCurrencyDebitAmount: 0,
+ additionalCurrencyCreditAmount: 2.25,
+ lastModifiedDateTime: '2026-02-01T12:34:56Z'
+ }
+ ]
+ }
+ });
+
+ let result = await invokeListGeneralLedgerEntries({
+ postingDateFrom: '2026-01-01',
+ postingDateTo: '2026-01-31',
+ accountId: '33333333-3333-3333-3333-333333333333',
+ accountNumber: '2910',
+ documentNumber: 'G00001',
+ limit: 10
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/generalLedgerEntries',
+ {
+ params: {
+ $top: 10,
+ $skip: 0,
+ $filter:
+ "(postingDate ge 2026-01-01) and (postingDate le 2026-01-31) and (accountId eq 33333333-3333-3333-3333-333333333333) and (accountNumber eq '2910') and (documentNumber eq 'G00001')"
+ }
+ }
+ );
+ expect(result.output.generalLedgerEntries[0]).toMatchObject({
+ id: '44444444-4444-4444-4444-444444444444',
+ entryNumber: 123,
+ postingDate: '2026-01-31',
+ documentNumber: 'G00001',
+ documentType: 'Invoice',
+ accountId: '33333333-3333-3333-3333-333333333333',
+ accountNumber: '2910',
+ description: 'VAT payable',
+ debitAmount: 0,
+ creditAmount: 25.5,
+ additionalCurrencyDebitAmount: 0,
+ additionalCurrencyCreditAmount: 2.25,
+ lastModifiedDateTime: '2026-02-01T12:34:56Z'
+ });
+ });
+});
+
+describe('Business Central list journals', () => {
+ it('searches one journal field with schema version 2.1', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '55555555-5555-5555-5555-555555555555',
+ code: 'DEFAULT',
+ displayName: 'Default Journal',
+ templateDisplayName: 'GENERAL',
+ balancingAccountId: '33333333-3333-3333-3333-333333333333',
+ balancingAccountNumber: '2910',
+ lastModifiedDateTime: '2026-02-01T12:34:56Z'
+ }
+ ]
+ }
+ });
+
+ let result = await invokeListJournals({
+ search: ' default ',
+ templateDisplayName: 'GENERAL',
+ limit: 10
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/journals',
+ {
+ params: {
+ $top: 10,
+ $skip: 0,
+ $filter:
+ "((contains(tolower(code),'default'))) and (templateDisplayName eq 'GENERAL')",
+ $schemaversion: '2.1'
+ }
+ }
+ );
+ expect(result.output.journals[0]).toMatchObject({
+ id: '55555555-5555-5555-5555-555555555555',
+ code: 'DEFAULT',
+ displayName: 'Default Journal',
+ templateDisplayName: 'GENERAL',
+ balancingAccountId: '33333333-3333-3333-3333-333333333333',
+ balancingAccountNumber: '2910',
+ lastModifiedDateTime: '2026-02-01T12:34:56Z'
+ });
+ });
+
+ it('can search journal display names without cross-field or filters', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: []
+ }
+ });
+
+ await invokeListJournals({
+ search: 'cash',
+ searchField: 'displayName',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/journals',
+ {
+ params: {
+ $top: 5,
+ $skip: 0,
+ $filter: "(contains(tolower(displayName),'cash'))",
+ $schemaversion: '2.1'
+ }
+ }
+ );
+ });
+});
diff --git a/integrations/business-central/src/tools.contacts.test.ts b/integrations/business-central/src/tools.contacts.test.ts
new file mode 100644
index 0000000000..9117557230
--- /dev/null
+++ b/integrations/business-central/src/tools.contacts.test.ts
@@ -0,0 +1,183 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let axiosMocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn()
+ },
+ createAuthenticatedAxios: vi.fn()
+}));
+
+vi.mock('slates', async importOriginal => {
+ let actual = await importOriginal();
+
+ return {
+ ...actual,
+ createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios
+ };
+});
+
+import { getVendor, listCustomers, listVendors } from './tools/contacts';
+
+let invokeListCustomers = (input: Record) =>
+ listCustomers.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+let invokeListVendors = (input: Record) =>
+ listVendors.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+let invokeGetVendor = (input: Record) =>
+ getVendor.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+beforeEach(() => {
+ axiosMocks.api.get.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReturnValue(axiosMocks.api);
+});
+
+describe('Business Central list customers', () => {
+ it('uses schema version 2.1 for nested search filters', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: []
+ }
+ });
+
+ await invokeListCustomers({
+ search: ' Adatum ',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/customers',
+ {
+ params: {
+ $top: 5,
+ $skip: 0,
+ $filter:
+ "(contains(tolower(displayName),'adatum') or contains(tolower(number),'adatum') or contains(tolower(email),'adatum') or contains(tolower(phoneNumber),'adatum'))",
+ $schemaversion: '2.1'
+ }
+ }
+ );
+ });
+
+ it('does not request schema version 2.1 without a search filter', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: []
+ }
+ });
+
+ await invokeListCustomers({
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/customers',
+ {
+ params: {
+ $top: 5,
+ $skip: 0
+ }
+ }
+ );
+ });
+});
+
+describe('Business Central list vendors', () => {
+ it('uses documented displayName search with schema version 2.1', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '22222222-2222-2222-2222-222222222222',
+ number: 'V10000',
+ displayName: 'Wide World Importers',
+ irs1099Code: 'MISC',
+ paymentMethodId: '33333333-3333-3333-3333-333333333333',
+ taxLiable: true
+ }
+ ]
+ }
+ });
+
+ let result = await invokeListVendors({
+ search: ' Wide ',
+ limit: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/vendors',
+ {
+ params: {
+ $top: 5,
+ $skip: 0,
+ $filter: "(contains(tolower(displayName),'wide'))",
+ $schemaversion: '2.1'
+ }
+ }
+ );
+ expect(result.output.vendors[0]).toMatchObject({
+ id: '22222222-2222-2222-2222-222222222222',
+ number: 'V10000',
+ displayName: 'Wide World Importers',
+ irs1099Code: 'MISC',
+ paymentMethodId: '33333333-3333-3333-3333-333333333333',
+ taxLiable: true
+ });
+ });
+
+ it('rejects vendor blocked filters outside documented values', async () => {
+ await expect(invokeListVendors({ blocked: 'Invoice' })).rejects.toBeInstanceOf(
+ ServiceError
+ );
+ await expect(invokeListVendors({ blocked: 'Invoice' })).rejects.toThrow(
+ 'Business Central vendor blocked filter must be one of'
+ );
+ expect(axiosMocks.api.get).not.toHaveBeenCalled();
+ });
+});
+
+describe('Business Central get vendor', () => {
+ it('uses the documented vendor entity path and maps vendor-specific fields', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ id: '22222222-2222-2222-2222-222222222222',
+ number: 'V10000',
+ displayName: 'Wide World Importers',
+ irs1099Code: 'MISC',
+ paymentMethodId: '33333333-3333-3333-3333-333333333333',
+ taxLiable: true
+ }
+ });
+
+ let result = await invokeGetVendor({
+ vendorId: '22222222-2222-2222-2222-222222222222'
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/vendors(22222222-2222-2222-2222-222222222222)',
+ { params: undefined }
+ );
+ expect(result.output).toMatchObject({
+ id: '22222222-2222-2222-2222-222222222222',
+ number: 'V10000',
+ displayName: 'Wide World Importers',
+ irs1099Code: 'MISC',
+ paymentMethodId: '33333333-3333-3333-3333-333333333333',
+ taxLiable: true
+ });
+ });
+});
diff --git a/integrations/business-central/src/tools.invoices.test.ts b/integrations/business-central/src/tools.invoices.test.ts
new file mode 100644
index 0000000000..af4c5f2869
--- /dev/null
+++ b/integrations/business-central/src/tools.invoices.test.ts
@@ -0,0 +1,532 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let axiosMocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn()
+ },
+ createAuthenticatedAxios: vi.fn()
+}));
+
+vi.mock('slates', async importOriginal => {
+ let actual = await importOriginal();
+
+ return {
+ ...actual,
+ createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios
+ };
+});
+
+import {
+ getPurchaseInvoice,
+ getSalesInvoice,
+ listPurchaseInvoices,
+ salesInvoicePdfContentPath
+} from './tools/invoices';
+
+let invokeGetSalesInvoice = (input: Record) =>
+ getSalesInvoice.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+let invokeListPurchaseInvoices = (input: Record) =>
+ listPurchaseInvoices.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+let invokeGetPurchaseInvoice = (input: Record) =>
+ getPurchaseInvoice.handleInvocation({
+ auth: { token: 'token' },
+ config: { companyId: '11111111-1111-1111-1111-111111111111' },
+ input
+ } as any);
+
+beforeEach(() => {
+ axiosMocks.api.get.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReturnValue(axiosMocks.api);
+});
+
+describe('salesInvoicePdfContentPath', () => {
+ it('uses the documented pdfDocument media stream path', () => {
+ expect(
+ salesInvoicePdfContentPath(
+ '11111111-1111-1111-1111-111111111111',
+ '22222222-2222-2222-2222-222222222222'
+ )
+ ).toBe(
+ '/companies(11111111-1111-1111-1111-111111111111)/salesInvoices(22222222-2222-2222-2222-222222222222)/pdfDocument/pdfDocumentContent'
+ );
+ });
+});
+
+describe('Business Central get sales invoice', () => {
+ it('uses the documented entity path, maps documented fields, and strips expanded attachment content', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ id: '22222222-2222-2222-2222-222222222222',
+ number: 'PS-INV103001',
+ externalDocumentNumber: 'EXT-100',
+ invoiceDate: '2026-01-15',
+ postingDate: '2026-01-15',
+ dueDate: '2026-02-15',
+ promisedPayDate: '2026-02-01',
+ customerPurchaseOrderReference: 'PO-123',
+ customerId: '33333333-3333-3333-3333-333333333333',
+ customerNumber: '20000',
+ customerName: 'Trey Research',
+ billToName: 'Trey Research',
+ billToCustomerId: '33333333-3333-3333-3333-333333333333',
+ billToCustomerNumber: '20000',
+ shipToName: 'Trey Research Warehouse',
+ shipToContact: 'Helen Ray',
+ sellToAddressLine1: '153 Thomas Drive',
+ sellToAddressLine2: 'Suite 1',
+ sellToCity: 'Chicago',
+ sellToCountry: 'US',
+ sellToState: 'IL',
+ sellToPostCode: '61236',
+ billToAddressLine1: '153 Thomas Drive',
+ billToAddressLine2: 'Suite 1',
+ billToCity: 'Chicago',
+ billToCountry: 'US',
+ billToState: 'IL',
+ billToPostCode: '61236',
+ shipToAddressLine1: '200 Warehouse Ave',
+ shipToAddressLine2: '',
+ shipToCity: 'Chicago',
+ shipToCountry: 'US',
+ shipToState: 'IL',
+ shipToPostCode: '61236',
+ currencyId: '00000000-0000-0000-0000-000000000000',
+ shortcutDimension1Code: 'SALES',
+ shortcutDimension2Code: 'NORTH',
+ currencyCode: 'USD',
+ orderId: '44444444-4444-4444-4444-444444444444',
+ orderNumber: 'SO-100',
+ paymentTermsId: '55555555-5555-5555-5555-555555555555',
+ shipmentMethodId: '66666666-6666-6666-6666-666666666666',
+ salesperson: 'PS',
+ disputeStatusId: '77777777-7777-7777-7777-777777777777',
+ disputeStatus: 'Open',
+ pricesIncludeTax: false,
+ remainingAmount: 0,
+ discountAmount: 10,
+ discountAppliedBeforeTax: true,
+ totalAmountExcludingTax: 164.7,
+ totalTaxAmount: 8.24,
+ totalAmountIncludingTax: 172.94,
+ status: 'Paid',
+ lastModifiedDateTime: '2026-01-16T00:25:58.337Z',
+ phoneNumber: '555-0100',
+ email: 'helen.ray@example.com',
+ salesInvoiceLines: {
+ value: [
+ {
+ id: '88888888-8888-8888-8888-888888888888',
+ documentId: '22222222-2222-2222-2222-222222222222',
+ sequence: 10000,
+ itemId: '99999999-9999-9999-9999-999999999999',
+ accountId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
+ lineType: 'Item',
+ lineObjectNumber: '1896-S',
+ description: 'ATHENS Desk',
+ description2: 'Black',
+ unitOfMeasureId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
+ unitOfMeasureCode: 'PCS',
+ quantity: 2,
+ unitPrice: 100,
+ discountAmount: 5,
+ discountPercent: 2.5,
+ discountAppliedBeforeTax: true,
+ amountExcludingTax: 195,
+ taxCode: 'TAX',
+ taxPercent: 5,
+ totalTaxAmount: 9.75,
+ amountIncludingTax: 204.75,
+ invoiceDiscountAllocation: 1,
+ netAmount: 194,
+ netTaxAmount: 9.7,
+ netAmountIncludingTax: 203.7,
+ shipmentDate: '2026-01-20',
+ itemVariantId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
+ locationId: 'dddddddd-dddd-dddd-dddd-dddddddddddd'
+ }
+ ]
+ },
+ dimensionSetLines: {
+ value: [
+ {
+ id: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
+ code: 'DEPARTMENT',
+ displayName: 'Department',
+ valueCode: 'SALES',
+ valueDisplayName: 'Sales'
+ }
+ ]
+ },
+ attachments: {
+ value: [
+ {
+ id: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
+ fileName: 'invoice.pdf',
+ byteSize: 128,
+ attachmentContent: 'JVBERi0x'
+ }
+ ]
+ },
+ documentAttachments: [
+ {
+ id: '12121212-1212-1212-1212-121212121212',
+ fileName: 'invoice-note.pdf',
+ byteSize: 64,
+ attachmentContent: 'JVBERi0x'
+ }
+ ]
+ }
+ });
+
+ let result = await invokeGetSalesInvoice({
+ invoiceId: '22222222-2222-2222-2222-222222222222',
+ select: ['id', 'number'],
+ expandLines: true,
+ expandCustomer: true,
+ expandDimensions: true,
+ expandPdfDocument: true,
+ expandAttachments: true
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/salesInvoices(22222222-2222-2222-2222-222222222222)',
+ {
+ params: {
+ $select: 'id,number',
+ $expand:
+ 'salesInvoiceLines,customer,dimensionSetLines,pdfDocument,attachments,documentAttachments'
+ }
+ }
+ );
+ expect(result.output).toMatchObject({
+ id: '22222222-2222-2222-2222-222222222222',
+ number: 'PS-INV103001',
+ promisedPayDate: '2026-02-01',
+ customerPurchaseOrderReference: 'PO-123',
+ billToCustomerNumber: '20000',
+ shipToContact: 'Helen Ray',
+ shortcutDimension1Code: 'SALES',
+ orderNumber: 'SO-100',
+ paymentTermsId: '55555555-5555-5555-5555-555555555555',
+ salesperson: 'PS',
+ disputeStatus: 'Open',
+ discountAppliedBeforeTax: true,
+ phoneNumber: '555-0100',
+ email: 'helen.ray@example.com',
+ lines: [
+ {
+ id: '88888888-8888-8888-8888-888888888888',
+ documentId: '22222222-2222-2222-2222-222222222222',
+ itemId: '99999999-9999-9999-9999-999999999999',
+ accountId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
+ description2: 'Black',
+ unitOfMeasureCode: 'PCS',
+ discountAppliedBeforeTax: true,
+ invoiceDiscountAllocation: 1,
+ netAmountIncludingTax: 203.7,
+ shipmentDate: '2026-01-20',
+ locationId: 'dddddddd-dddd-dddd-dddd-dddddddddddd'
+ }
+ ],
+ dimensions: [
+ {
+ code: 'DEPARTMENT',
+ valueCode: 'SALES'
+ }
+ ]
+ });
+
+ let output = result.output as any;
+ expect(output.record.attachments.value[0]).not.toHaveProperty('attachmentContent');
+ expect(output.record.documentAttachments[0]).not.toHaveProperty('attachmentContent');
+ expect(JSON.stringify(output.record)).not.toContain('JVBERi0x');
+ });
+});
+
+describe('Business Central list purchase invoices', () => {
+ it('uses the documented collection path, filters, expansion, and maps documented fields', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [
+ {
+ id: '22222222-2222-2222-2222-222222222222',
+ number: '108001',
+ invoiceDate: '2026-01-15',
+ postingDate: '2026-01-16',
+ dueDate: '2026-02-15',
+ vendorInvoiceNumber: 'V-107001',
+ vendorId: '33333333-3333-3333-3333-333333333333',
+ vendorNumber: '20000',
+ vendorName: 'First Up Consultants',
+ payToName: 'First Up Consultants',
+ payToContact: 'Evan McIntosh',
+ payToVendorId: '33333333-3333-3333-3333-333333333333',
+ payToVendorNumber: '20000',
+ shipToName: 'Atlanta Warehouse',
+ shipToContact: 'April Meyer',
+ buyFromAddressLine1: '100 Day Drive',
+ buyFromAddressLine2: 'Suite 100',
+ buyFromCity: 'Chicago',
+ buyFromCountry: 'US',
+ buyFromState: 'IL',
+ buyFromPostCode: '61236',
+ shipToAddressLine1: '7122 South Ashford Street',
+ shipToAddressLine2: 'Westminster',
+ shipToCity: 'Atlanta',
+ shipToCountry: 'US',
+ shipToState: 'GA',
+ shipToPostCode: '31772',
+ payToAddressLine1: '100 Day Drive',
+ payToAddressLine2: '',
+ payToCity: 'Chicago',
+ payToCountry: 'US',
+ payToState: 'IL',
+ payToPostCode: '61236',
+ shortcutDimension1Code: 'PURCHASE',
+ shortcutDimension2Code: 'SOUTH',
+ currencyId: '00000000-0000-0000-0000-000000000000',
+ currencyCode: 'USD',
+ orderId: '44444444-4444-4444-4444-444444444444',
+ orderNumber: 'PO-100',
+ purchaser: 'AH',
+ pricesIncludeTax: false,
+ discountAmount: 10,
+ discountAppliedBeforeTax: true,
+ totalAmountExcludingTax: 3122.8,
+ totalTaxAmount: 187.37,
+ totalAmountIncludingTax: 3310.17,
+ status: 'Open',
+ lastModifiedDateTime: '2026-01-17T00:26:53.793Z',
+ purchaseInvoiceLines: {
+ value: [
+ {
+ id: '55555555-5555-5555-5555-555555555555',
+ documentId: '22222222-2222-2222-2222-222222222222',
+ sequence: 10000,
+ accountId: '66666666-6666-6666-6666-666666666666',
+ lineType: 'Account',
+ description: 'Consulting services',
+ quantity: 1,
+ unitCost: 3122.8,
+ amountExcludingTax: 3122.8,
+ totalTaxAmount: 187.37,
+ amountIncludingTax: 3310.17
+ }
+ ]
+ }
+ }
+ ],
+ '@odata.nextLink':
+ 'https://api.businesscentral.dynamics.com/v2.0/production/api/v2.0/companies(11111111-1111-1111-1111-111111111111)/purchaseInvoices?$skip=10'
+ }
+ });
+
+ let result = await invokeListPurchaseInvoices({
+ vendorId: '33333333-3333-3333-3333-333333333333',
+ status: 'Open',
+ invoiceDateFrom: '2026-01-01',
+ dueDateTo: '2026-02-28',
+ updatedSince: '2026-01-15T00:00:00Z',
+ odataFilter: 'totalAmountIncludingTax gt 0',
+ expandLines: true,
+ expandVendor: true,
+ expand: ['dimensionSetLines'],
+ limit: 5,
+ skip: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/purchaseInvoices',
+ {
+ params: {
+ $top: 5,
+ $skip: 5,
+ $expand: 'purchaseInvoiceLines,vendor,dimensionSetLines',
+ $filter:
+ "(status eq 'Open') and (vendorId eq 33333333-3333-3333-3333-333333333333) and (invoiceDate ge 2026-01-01) and (dueDate le 2026-02-28) and (lastModifiedDateTime ge 2026-01-15T00:00:00Z) and (totalAmountIncludingTax gt 0)"
+ }
+ }
+ );
+ expect(result.output.purchaseInvoices[0]).toMatchObject({
+ id: '22222222-2222-2222-2222-222222222222',
+ number: '108001',
+ vendorInvoiceNumber: 'V-107001',
+ vendorId: '33333333-3333-3333-3333-333333333333',
+ vendorName: 'First Up Consultants',
+ payToContact: 'Evan McIntosh',
+ payToVendorNumber: '20000',
+ shipToName: 'Atlanta Warehouse',
+ shipToContact: 'April Meyer',
+ buyFromAddressLine1: '100 Day Drive',
+ shipToCity: 'Atlanta',
+ payToCity: 'Chicago',
+ shortcutDimension1Code: 'PURCHASE',
+ shortcutDimension2Code: 'SOUTH',
+ orderId: '44444444-4444-4444-4444-444444444444',
+ orderNumber: 'PO-100',
+ purchaser: 'AH',
+ pricesIncludeTax: false,
+ discountAppliedBeforeTax: true,
+ totalAmountIncludingTax: 3310.17,
+ status: 'Open',
+ lines: [
+ {
+ id: '55555555-5555-5555-5555-555555555555',
+ accountId: '66666666-6666-6666-6666-666666666666',
+ lineType: 'Account',
+ unitCost: 3122.8,
+ amountIncludingTax: 3310.17
+ }
+ ]
+ });
+ expect(result.output.page).toMatchObject({
+ count: 1,
+ limit: 5,
+ skip: 5,
+ nextSkip: 10
+ });
+ });
+});
+
+describe('Business Central get purchase invoice', () => {
+ it('uses the documented entity path, expansion, and maps purchase invoice line fields', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ id: '22222222-2222-2222-2222-222222222222',
+ number: '108001',
+ invoiceDate: '2026-01-15',
+ postingDate: '2026-01-16',
+ dueDate: '2026-02-15',
+ vendorInvoiceNumber: 'V-107001',
+ vendorId: '33333333-3333-3333-3333-333333333333',
+ vendorNumber: '20000',
+ vendorName: 'First Up Consultants',
+ payToName: 'First Up Consultants',
+ payToContact: 'Evan McIntosh',
+ payToVendorId: '33333333-3333-3333-3333-333333333333',
+ payToVendorNumber: '20000',
+ shipToName: 'Atlanta Warehouse',
+ shipToContact: 'April Meyer',
+ shortcutDimension1Code: 'PURCHASE',
+ shortcutDimension2Code: 'SOUTH',
+ currencyId: '00000000-0000-0000-0000-000000000000',
+ currencyCode: 'USD',
+ orderId: '44444444-4444-4444-4444-444444444444',
+ orderNumber: 'PO-100',
+ purchaser: 'AH',
+ pricesIncludeTax: false,
+ discountAmount: 10,
+ discountAppliedBeforeTax: true,
+ totalAmountExcludingTax: 3122.8,
+ totalTaxAmount: 187.37,
+ totalAmountIncludingTax: 3310.17,
+ status: 'Open',
+ lastModifiedDateTime: '2026-01-17T00:26:53.793Z',
+ purchaseInvoiceLines: {
+ value: [
+ {
+ id: '55555555-5555-5555-5555-555555555555',
+ documentId: '22222222-2222-2222-2222-222222222222',
+ sequence: 10000,
+ accountId: '66666666-6666-6666-6666-666666666666',
+ lineType: 'Account',
+ description: 'Consulting services',
+ quantity: 1,
+ unitCost: 3122.8,
+ discountAmount: 5,
+ discountPercent: 1.5,
+ discountAppliedBeforeTax: true,
+ taxCode: 'VAT',
+ taxPercent: 6,
+ amountExcludingTax: 3122.8,
+ totalTaxAmount: 187.37,
+ amountIncludingTax: 3310.17,
+ invoiceDiscountAllocation: 2,
+ netAmount: 3117.8,
+ netTaxAmount: 187.07,
+ netAmountIncludingTax: 3304.87,
+ expectedReceiptDate: '2026-01-20'
+ }
+ ]
+ },
+ dimensionSetLines: {
+ value: [
+ {
+ id: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
+ code: 'DEPARTMENT',
+ displayName: 'Department',
+ valueCode: 'PURCHASE',
+ valueDisplayName: 'Purchase'
+ }
+ ]
+ }
+ }
+ });
+
+ let result = await invokeGetPurchaseInvoice({
+ purchaseInvoiceId: '22222222-2222-2222-2222-222222222222',
+ select: ['id', 'number'],
+ expandLines: true,
+ expandVendor: true,
+ expandDimensions: true
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies(11111111-1111-1111-1111-111111111111)/purchaseInvoices(22222222-2222-2222-2222-222222222222)',
+ {
+ params: {
+ $select: 'id,number',
+ $expand: 'purchaseInvoiceLines,vendor,dimensionSetLines'
+ }
+ }
+ );
+ expect(result.output).toMatchObject({
+ id: '22222222-2222-2222-2222-222222222222',
+ number: '108001',
+ vendorInvoiceNumber: 'V-107001',
+ vendorId: '33333333-3333-3333-3333-333333333333',
+ vendorName: 'First Up Consultants',
+ payToContact: 'Evan McIntosh',
+ payToVendorNumber: '20000',
+ shortcutDimension1Code: 'PURCHASE',
+ shortcutDimension2Code: 'SOUTH',
+ orderId: '44444444-4444-4444-4444-444444444444',
+ orderNumber: 'PO-100',
+ purchaser: 'AH',
+ pricesIncludeTax: false,
+ discountAppliedBeforeTax: true,
+ totalAmountIncludingTax: 3310.17,
+ status: 'Open',
+ lines: [
+ {
+ id: '55555555-5555-5555-5555-555555555555',
+ accountId: '66666666-6666-6666-6666-666666666666',
+ lineType: 'Account',
+ unitCost: 3122.8,
+ expectedReceiptDate: '2026-01-20',
+ netAmountIncludingTax: 3304.87
+ }
+ ],
+ dimensions: [
+ {
+ code: 'DEPARTMENT',
+ valueCode: 'PURCHASE'
+ }
+ ]
+ });
+
+ expect(result.output.lines?.[0]).not.toHaveProperty('unitPrice');
+ expect(result.output.lines?.[0]).not.toHaveProperty('shipmentDate');
+ });
+});
diff --git a/integrations/business-central/src/tools.schema.test.ts b/integrations/business-central/src/tools.schema.test.ts
new file mode 100644
index 0000000000..572701323c
--- /dev/null
+++ b/integrations/business-central/src/tools.schema.test.ts
@@ -0,0 +1,4 @@
+import { describeMcpCompatibleToolSchemas } from '@slates/test';
+import { provider } from './index';
+
+describeMcpCompatibleToolSchemas('Business Central tool input schemas', provider.actions);
diff --git a/integrations/business-central/src/tools/attachments.ts b/integrations/business-central/src/tools/attachments.ts
new file mode 100644
index 0000000000..e9db849697
--- /dev/null
+++ b/integrations/business-central/src/tools/attachments.ts
@@ -0,0 +1,168 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import { businessCentralEntityPath } from '../lib/client';
+import { businessCentralValidationError } from '../lib/errors';
+import { spec } from '../spec';
+import {
+ type BusinessCentralContext,
+ booleanValue,
+ buildODataParams,
+ compactRecord,
+ companyInputFields,
+ companyPath,
+ createClient,
+ listInputFields,
+ numberValue,
+ type ODataInput,
+ pageOutputSchema,
+ pageSummary,
+ rawRecordSchema,
+ resolveCompanyId,
+ stringValue
+} from './shared';
+
+type AttachmentContext = BusinessCentralContext & {
+ input: BusinessCentralContext['input'] &
+ ODataInput & {
+ parentResource: keyof typeof parentResourceAttachmentSources;
+ parentId: string;
+ };
+};
+
+let ATTACHMENT_CONTENT_FIELD = 'attachmentContent';
+
+let parentResourceAttachmentSources = {
+ customer: { collection: 'customers', navigation: 'documentAttachments' },
+ vendor: { collection: 'vendors', navigation: 'documentAttachments' },
+ item: { collection: 'items', navigation: 'documentAttachments' },
+ salesInvoice: { collection: 'salesInvoices', navigation: 'documentAttachments' },
+ purchaseInvoice: { collection: 'purchaseInvoices', navigation: 'documentAttachments' },
+ generalLedgerEntry: { collection: 'generalLedgerEntries', navigation: 'attachments' }
+} as const;
+
+let documentAttachmentSchema = z.object({
+ id: z.string().optional(),
+ parentResource: z.string().optional(),
+ parentId: z.string().optional(),
+ parentType: z.string().optional(),
+ fileName: z.string().optional(),
+ fileExtension: z.string().optional(),
+ contentType: z.string().optional(),
+ byteSize: z.number().optional(),
+ size: z.number().optional(),
+ lineNumber: z.number().optional(),
+ documentFlowSales: z.boolean().optional(),
+ documentFlowPurchase: z.boolean().optional(),
+ createdDateTime: z.string().optional(),
+ lastModifiedDateTime: z.string().optional(),
+ record: rawRecordSchema
+});
+
+let assertNoAttachmentContentSelect = (input: ODataInput) => {
+ if (input.select?.some(field => field.trim() === ATTACHMENT_CONTENT_FIELD)) {
+ throw businessCentralValidationError(
+ 'list_document_attachments returns metadata only. Do not select attachmentContent; file contents must be returned through Slate attachments.'
+ );
+ }
+};
+
+let metadataRecord = (record: Record) => {
+ if (!Object.prototype.hasOwnProperty.call(record, ATTACHMENT_CONTENT_FIELD)) return record;
+
+ let sanitized = { ...record };
+ delete sanitized[ATTACHMENT_CONTENT_FIELD];
+ return sanitized;
+};
+
+let mapDocumentAttachment = (
+ record: Record,
+ parentResource: string,
+ parentId: string
+) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ parentResource,
+ parentId: stringValue(record, 'parentId') ?? parentId,
+ parentType: stringValue(record, 'parentType'),
+ fileName: stringValue(record, 'fileName') ?? stringValue(record, 'name'),
+ fileExtension: stringValue(record, 'fileExtension'),
+ contentType: stringValue(record, 'contentType') ?? stringValue(record, 'mimeType'),
+ byteSize: numberValue(record, 'byteSize'),
+ size:
+ numberValue(record, 'byteSize') ??
+ numberValue(record, 'size') ??
+ numberValue(record, 'contentLength'),
+ lineNumber: numberValue(record, 'lineNumber'),
+ documentFlowSales: booleanValue(record, 'documentFlowSales'),
+ documentFlowPurchase: booleanValue(record, 'documentFlowPurchase'),
+ createdDateTime: stringValue(record, 'createdDateTime'),
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime')
+ }),
+ record: metadataRecord(record)
+});
+
+export let listDocumentAttachments = SlateTool.create(spec, {
+ name: 'List Business Central Document Attachments',
+ key: 'list_document_attachments',
+ description:
+ 'List Business Central document attachment metadata for supported parent records such as customers, vendors, items, invoices, and general ledger entries.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...listInputFields,
+ parentResource: z
+ .enum([
+ 'customer',
+ 'vendor',
+ 'item',
+ 'salesInvoice',
+ 'purchaseInvoice',
+ 'generalLedgerEntry'
+ ])
+ .describe(
+ 'Parent record type whose Business Central attachment navigation should be listed.'
+ ),
+ parentId: z.string().describe('Business Central ID for the selected parent record.')
+ })
+ )
+ .output(
+ z.object({
+ documentAttachments: z
+ .array(documentAttachmentSchema)
+ .describe('Document attachment metadata returned by Business Central.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as AttachmentContext;
+ assertNoAttachmentContentSelect(ctx.input);
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let source = parentResourceAttachmentSources[ctx.input.parentResource];
+ let { params, page } = buildODataParams(ctx, ctx.input);
+ let response = await client.getList>(
+ 'list document attachments',
+ `/${companyPath(companyId)}/${businessCentralEntityPath(
+ source.collection,
+ ctx.input.parentId
+ )}/${source.navigation}`,
+ params
+ );
+ let documentAttachments = response.value!.map(record =>
+ mapDocumentAttachment(record, ctx.input.parentResource, ctx.input.parentId)
+ );
+
+ return {
+ output: {
+ documentAttachments,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${documentAttachments.length}** Business Central document attachment record(s).`
+ };
+ })
+ .build();
diff --git a/integrations/business-central/src/tools/catalog-accounting.ts b/integrations/business-central/src/tools/catalog-accounting.ts
new file mode 100644
index 0000000000..f9622e486d
--- /dev/null
+++ b/integrations/business-central/src/tools/catalog-accounting.ts
@@ -0,0 +1,449 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ type BusinessCentralContext,
+ buildODataParams,
+ compactRecord,
+ companyInputFields,
+ companyPath,
+ containsFilter,
+ createClient,
+ dateFromFilter,
+ dateToFilter,
+ equalityFilter,
+ listInputFields,
+ numberValue,
+ type ODataInput,
+ pageOutputSchema,
+ pageSummary,
+ rawEqualityFilter,
+ rawRecordSchema,
+ resolveCompanyId,
+ stringValue
+} from './shared';
+
+type ListContext = BusinessCentralContext & {
+ input: BusinessCentralContext['input'] &
+ ODataInput & {
+ search?: string;
+ category?: string;
+ type?: string;
+ updatedSince?: string;
+ accountCategory?: string;
+ accountType?: string;
+ postingDateFrom?: string;
+ postingDateTo?: string;
+ accountId?: string;
+ accountNumber?: string;
+ searchField?: 'number' | 'code' | 'displayName';
+ documentNumber?: string;
+ templateDisplayName?: string;
+ };
+};
+
+let itemSchema = z.object({
+ id: z.string().optional(),
+ number: z.string().optional(),
+ displayName: z.string().optional(),
+ type: z.string().optional(),
+ itemCategoryId: z.string().optional(),
+ itemCategoryCode: z.string().optional(),
+ blocked: z.boolean().optional(),
+ gtin: z.string().optional(),
+ inventory: z.number().optional(),
+ unitPrice: z.number().optional(),
+ unitCost: z.number().optional(),
+ priceIncludesTax: z.boolean().optional(),
+ taxGroupId: z.string().optional(),
+ taxGroupCode: z.string().optional(),
+ baseUnitOfMeasureId: z.string().optional(),
+ baseUnitOfMeasureCode: z.string().optional(),
+ generalProductPostingGroupId: z.string().optional(),
+ generalProductPostingGroupCode: z.string().optional(),
+ inventoryPostingGroupId: z.string().optional(),
+ inventoryPostingGroupCode: z.string().optional(),
+ lastModifiedDateTime: z.string().optional(),
+ record: rawRecordSchema
+});
+
+let accountSchema = z.object({
+ id: z.string().optional(),
+ number: z.string().optional(),
+ displayName: z.string().optional(),
+ category: z.string().optional(),
+ subCategory: z.string().optional(),
+ accountType: z.string().optional(),
+ directPosting: z.boolean().optional(),
+ incomeBalance: z.string().optional(),
+ debitCreditBalance: z.string().optional(),
+ blocked: z.boolean().optional(),
+ netChange: z.number().optional(),
+ totalBalance: z.number().optional(),
+ balance: z.number().optional(),
+ consolidationTranslationMethod: z.string().optional(),
+ consolidationDebitAccount: z.string().optional(),
+ consolidationCreditAccount: z.string().optional(),
+ excludeFromConsolidation: z.boolean().optional(),
+ lastModifiedDateTime: z.string().optional(),
+ record: rawRecordSchema
+});
+
+let generalLedgerEntrySchema = z.object({
+ id: z.string().optional(),
+ entryNumber: z.number().optional(),
+ postingDate: z.string().optional(),
+ documentNumber: z.string().optional(),
+ documentType: z.string().optional(),
+ accountId: z.string().optional(),
+ accountNumber: z.string().optional(),
+ description: z.string().optional(),
+ debitAmount: z.number().optional(),
+ creditAmount: z.number().optional(),
+ additionalCurrencyDebitAmount: z.number().optional(),
+ additionalCurrencyCreditAmount: z.number().optional(),
+ lastModifiedDateTime: z.string().optional(),
+ record: rawRecordSchema
+});
+
+let journalSchema = z.object({
+ id: z.string().optional(),
+ code: z.string().optional(),
+ displayName: z.string().optional(),
+ templateDisplayName: z.string().optional(),
+ balancingAccountId: z.string().optional(),
+ balancingAccountNumber: z.string().optional(),
+ lastModifiedDateTime: z.string().optional(),
+ record: rawRecordSchema
+});
+
+let mapItem = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ number: stringValue(record, 'number'),
+ displayName: stringValue(record, 'displayName'),
+ type: stringValue(record, 'type'),
+ itemCategoryId: stringValue(record, 'itemCategoryId'),
+ itemCategoryCode: stringValue(record, 'itemCategoryCode'),
+ blocked: typeof record.blocked === 'boolean' ? (record.blocked as boolean) : undefined,
+ gtin: stringValue(record, 'gtin'),
+ inventory: numberValue(record, 'inventory'),
+ unitPrice: numberValue(record, 'unitPrice'),
+ unitCost: numberValue(record, 'unitCost'),
+ priceIncludesTax:
+ typeof record.priceIncludesTax === 'boolean'
+ ? (record.priceIncludesTax as boolean)
+ : undefined,
+ taxGroupId: stringValue(record, 'taxGroupId'),
+ taxGroupCode: stringValue(record, 'taxGroupCode'),
+ baseUnitOfMeasureId: stringValue(record, 'baseUnitOfMeasureId'),
+ baseUnitOfMeasureCode: stringValue(record, 'baseUnitOfMeasureCode'),
+ generalProductPostingGroupId: stringValue(record, 'generalProductPostingGroupId'),
+ generalProductPostingGroupCode: stringValue(record, 'generalProductPostingGroupCode'),
+ inventoryPostingGroupId: stringValue(record, 'inventoryPostingGroupId'),
+ inventoryPostingGroupCode: stringValue(record, 'inventoryPostingGroupCode'),
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime')
+ }),
+ record
+});
+
+let mapAccount = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ number: stringValue(record, 'number'),
+ displayName: stringValue(record, 'displayName'),
+ category: stringValue(record, 'category'),
+ subCategory: stringValue(record, 'subCategory'),
+ accountType: stringValue(record, 'accountType'),
+ directPosting:
+ typeof record.directPosting === 'boolean'
+ ? (record.directPosting as boolean)
+ : undefined,
+ incomeBalance: stringValue(record, 'incomeBalance'),
+ debitCreditBalance: stringValue(record, 'debitCreditBalance'),
+ blocked: typeof record.blocked === 'boolean' ? (record.blocked as boolean) : undefined,
+ netChange: numberValue(record, 'netChange'),
+ totalBalance: numberValue(record, 'totalBalance'),
+ balance: numberValue(record, 'balance'),
+ consolidationTranslationMethod: stringValue(record, 'consolidationTranslationMethod'),
+ consolidationDebitAccount: stringValue(record, 'consolidationDebitAccount'),
+ consolidationCreditAccount: stringValue(record, 'consolidationCreditAccount'),
+ excludeFromConsolidation:
+ typeof record.excludeFromConsolidation === 'boolean'
+ ? (record.excludeFromConsolidation as boolean)
+ : undefined,
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime')
+ }),
+ record
+});
+
+let mapGeneralLedgerEntry = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ entryNumber: numberValue(record, 'entryNumber'),
+ postingDate: stringValue(record, 'postingDate'),
+ documentNumber: stringValue(record, 'documentNumber'),
+ documentType: stringValue(record, 'documentType'),
+ accountId: stringValue(record, 'accountId'),
+ accountNumber: stringValue(record, 'accountNumber'),
+ description: stringValue(record, 'description'),
+ debitAmount: numberValue(record, 'debitAmount'),
+ creditAmount: numberValue(record, 'creditAmount'),
+ additionalCurrencyDebitAmount: numberValue(record, 'additionalCurrencyDebitAmount'),
+ additionalCurrencyCreditAmount: numberValue(record, 'additionalCurrencyCreditAmount'),
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime')
+ }),
+ record
+});
+
+let mapJournal = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ code: stringValue(record, 'code'),
+ displayName: stringValue(record, 'displayName'),
+ templateDisplayName: stringValue(record, 'templateDisplayName'),
+ balancingAccountId: stringValue(record, 'balancingAccountId'),
+ balancingAccountNumber: stringValue(record, 'balancingAccountNumber'),
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime')
+ }),
+ record
+});
+
+let nestedFilterSchemaVersionParams = (search: string | undefined) =>
+ search?.trim() ? { $schemaversion: '2.1' } : {};
+
+export let listItems = SlateTool.create(spec, {
+ name: 'List Business Central Items',
+ key: 'list_items',
+ description:
+ 'List Business Central items and catalog records by search text, category, type, update timestamp, and OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...listInputFields,
+ search: z.string().optional().describe('Search item display name or number.'),
+ category: z.string().optional().describe('Filter by upstream itemCategoryCode.'),
+ type: z.string().optional().describe('Filter by upstream item type.'),
+ updatedSince: z
+ .string()
+ .optional()
+ .describe('Return items modified at or after this ISO timestamp.')
+ })
+ )
+ .output(
+ z.object({
+ items: z.array(itemSchema).describe('Business Central item records.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as ListContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let { params, page } = buildODataParams(ctx, ctx.input, [
+ containsFilter(['displayName', 'number'], ctx.input.search),
+ equalityFilter('itemCategoryCode', ctx.input.category),
+ equalityFilter('type', ctx.input.type),
+ dateFromFilter('lastModifiedDateTime', ctx.input.updatedSince)
+ ]);
+ Object.assign(params, nestedFilterSchemaVersionParams(ctx.input.search));
+ let response = await client.getList>(
+ 'list items',
+ `/${companyPath(companyId)}/items`,
+ params
+ );
+ let items = response.value!.map(mapItem);
+
+ return {
+ output: {
+ items,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${items.length}** Business Central item record(s).`
+ };
+ })
+ .build();
+
+export let listAccounts = SlateTool.create(spec, {
+ name: 'List Business Central Accounts',
+ key: 'list_accounts',
+ description:
+ 'List Business Central chart-of-accounts rows by search text, account category, account type, and OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...listInputFields,
+ search: z
+ .string()
+ .optional()
+ .describe(
+ 'Search one account text field. Defaults to account number; set searchField to displayName to search account names.'
+ ),
+ searchField: z
+ .enum(['number', 'displayName'])
+ .optional()
+ .describe('Account field searched by search. Defaults to number.'),
+ accountCategory: z.string().optional().describe('Filter by upstream account category.'),
+ accountType: z.string().optional().describe('Filter by upstream account type.')
+ })
+ )
+ .output(
+ z.object({
+ accounts: z.array(accountSchema).describe('Business Central chart of accounts rows.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as ListContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let searchField = ctx.input.searchField ?? 'number';
+ let { params, page } = buildODataParams(ctx, ctx.input, [
+ containsFilter([searchField], ctx.input.search),
+ equalityFilter('category', ctx.input.accountCategory),
+ equalityFilter('accountType', ctx.input.accountType)
+ ]);
+ Object.assign(params, nestedFilterSchemaVersionParams(ctx.input.search));
+ let response = await client.getList>(
+ 'list accounts',
+ `/${companyPath(companyId)}/accounts`,
+ params
+ );
+ let accounts = response.value!.map(mapAccount);
+
+ return {
+ output: {
+ accounts,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${accounts.length}** Business Central account record(s).`
+ };
+ })
+ .build();
+
+export let listGeneralLedgerEntries = SlateTool.create(spec, {
+ name: 'List Business Central General Ledger Entries',
+ key: 'list_general_ledger_entries',
+ description:
+ 'List Business Central posted general ledger entries by posting date range, account, document number, and OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...listInputFields,
+ postingDateFrom: z.string().optional().describe('Minimum postingDate.'),
+ postingDateTo: z.string().optional().describe('Maximum postingDate.'),
+ accountId: z.string().optional().describe('Filter by Business Central account GUID.'),
+ accountNumber: z.string().optional().describe('Filter by account number.'),
+ documentNumber: z.string().optional().describe('Filter by document number.')
+ })
+ )
+ .output(
+ z.object({
+ generalLedgerEntries: z
+ .array(generalLedgerEntrySchema)
+ .describe('Business Central general ledger entries.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as ListContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let { params, page } = buildODataParams(ctx, ctx.input, [
+ dateFromFilter('postingDate', ctx.input.postingDateFrom),
+ dateToFilter('postingDate', ctx.input.postingDateTo),
+ rawEqualityFilter('accountId', ctx.input.accountId),
+ equalityFilter('accountNumber', ctx.input.accountNumber),
+ equalityFilter('documentNumber', ctx.input.documentNumber)
+ ]);
+ let response = await client.getList>(
+ 'list general ledger entries',
+ `/${companyPath(companyId)}/generalLedgerEntries`,
+ params
+ );
+ let generalLedgerEntries = response.value!.map(mapGeneralLedgerEntry);
+
+ return {
+ output: {
+ generalLedgerEntries,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${generalLedgerEntries.length}** Business Central general ledger entr${
+ generalLedgerEntries.length === 1 ? 'y' : 'ies'
+ }.`
+ };
+ })
+ .build();
+
+export let listJournals = SlateTool.create(spec, {
+ name: 'List Business Central Journals',
+ key: 'list_journals',
+ description:
+ 'List Business Central journals by one selected search field, template display name, and OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...listInputFields,
+ search: z
+ .string()
+ .optional()
+ .describe('Search one journal text field. Defaults to code.'),
+ searchField: z
+ .enum(['code', 'displayName'])
+ .optional()
+ .describe('Journal field searched by search. Defaults to code.'),
+ templateDisplayName: z.string().optional().describe('Filter by template display name.')
+ })
+ )
+ .output(
+ z.object({
+ journals: z.array(journalSchema).describe('Business Central journals.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as ListContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let searchField = ctx.input.searchField ?? 'code';
+ let { params, page } = buildODataParams(ctx, ctx.input, [
+ containsFilter([searchField], ctx.input.search),
+ equalityFilter('templateDisplayName', ctx.input.templateDisplayName)
+ ]);
+ Object.assign(params, nestedFilterSchemaVersionParams(ctx.input.search));
+ let response = await client.getList>(
+ 'list journals',
+ `/${companyPath(companyId)}/journals`,
+ params
+ );
+ let journals = response.value!.map(mapJournal);
+
+ return {
+ output: {
+ journals,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${journals.length}** Business Central journal record(s).`
+ };
+ })
+ .build();
diff --git a/integrations/business-central/src/tools/companies.ts b/integrations/business-central/src/tools/companies.ts
new file mode 100644
index 0000000000..abaecebf42
--- /dev/null
+++ b/integrations/business-central/src/tools/companies.ts
@@ -0,0 +1,76 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ buildODataParams,
+ compactRecord,
+ createClient,
+ environmentInputFields,
+ listInputFields,
+ pageOutputSchema,
+ pageSummary,
+ rawRecordSchema,
+ stringValue
+} from './shared';
+
+let companySchema = z.object({
+ id: z.string().optional().describe('Business Central company GUID.'),
+ name: z.string().optional().describe('Company name.'),
+ displayName: z.string().optional().describe('Human-readable company display name.'),
+ businessProfileId: z.string().optional().describe('Business profile id when available.'),
+ systemVersion: z.string().optional().describe('Business Central system version.'),
+ record: rawRecordSchema
+});
+
+let mapCompany = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ name: stringValue(record, 'name'),
+ displayName: stringValue(record, 'displayName'),
+ businessProfileId: stringValue(record, 'businessProfileId'),
+ systemVersion: stringValue(record, 'systemVersion')
+ }),
+ record
+});
+
+export let listCompanies = SlateTool.create(spec, {
+ name: 'List Business Central Companies',
+ key: 'list_companies',
+ description:
+ 'List companies available to the authenticated Microsoft Dynamics 365 Business Central user in the selected environment. Use a returned company id as companyId for company-scoped tools.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...environmentInputFields,
+ ...listInputFields
+ })
+ )
+ .output(
+ z.object({
+ companies: z.array(companySchema).describe('Business Central companies.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let client = createClient(ctx);
+ let { params, page } = buildODataParams(ctx, ctx.input);
+ let response = await client.getList>(
+ 'list companies',
+ '/companies',
+ params
+ );
+ let companies = response.value!.map(mapCompany);
+
+ return {
+ output: {
+ companies,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${companies.length}** Business Central company record(s).`
+ };
+ })
+ .build();
diff --git a/integrations/business-central/src/tools/contacts.ts b/integrations/business-central/src/tools/contacts.ts
new file mode 100644
index 0000000000..24186a4110
--- /dev/null
+++ b/integrations/business-central/src/tools/contacts.ts
@@ -0,0 +1,323 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import { businessCentralEntityPath } from '../lib/client';
+import { businessCentralValidationError } from '../lib/errors';
+import { spec } from '../spec';
+import {
+ addressSchema,
+ type BusinessCentralContext,
+ booleanValue,
+ buildODataParams,
+ compactRecord,
+ companyInputFields,
+ companyPath,
+ containsFilter,
+ createClient,
+ dateFromFilter,
+ equalityFilter,
+ listInputFields,
+ mapAddress,
+ numberValue,
+ type ODataInput,
+ pageOutputSchema,
+ pageSummary,
+ rawRecordSchema,
+ resolveCompanyId,
+ stringValue
+} from './shared';
+
+type PartyContext = BusinessCentralContext & {
+ input: BusinessCentralContext['input'] &
+ ODataInput & {
+ customerId?: string;
+ vendorId?: string;
+ search?: string;
+ updatedSince?: string;
+ blocked?: string;
+ };
+};
+
+let nestedFilterSchemaVersionParams = (search: string | undefined) =>
+ search?.trim() ? { $schemaversion: '2.1' } : {};
+
+let defaultPartySearchFields = ['displayName', 'number', 'email', 'phoneNumber'];
+let vendorSearchFields = ['displayName'];
+let vendorBlockedValues = [' ', 'Payment', 'All'] as const;
+
+type ListPartiesOptions = {
+ usesNestedSearchFilter?: boolean;
+ searchFields?: string[];
+ blockedValues?: readonly string[];
+};
+
+let validateBlockedFilter = (
+ value: string | undefined,
+ allowedValues: readonly string[] | undefined,
+ label: string
+) => {
+ if (value === undefined || !allowedValues) return;
+ if (allowedValues.includes(value)) return;
+
+ throw businessCentralValidationError(
+ `Business Central ${label} blocked filter must be one of: ${allowedValues
+ .map(allowedValue => `"${allowedValue}"`)
+ .join(', ')}.`
+ );
+};
+
+let partySchema = z.object({
+ id: z.string().optional().describe('Business Central record GUID.'),
+ number: z.string().optional().describe('Business Central customer/vendor number.'),
+ displayName: z.string().optional().describe('Display name.'),
+ type: z.string().optional().describe('Business Central party type when returned.'),
+ phoneNumber: z.string().optional(),
+ email: z.string().optional(),
+ website: z.string().optional(),
+ taxRegistrationNumber: z.string().optional(),
+ currencyId: z.string().optional(),
+ currencyCode: z.string().optional(),
+ paymentTermsId: z.string().optional(),
+ shipmentMethodId: z.string().optional(),
+ blocked: z.string().optional(),
+ balance: z.number().optional().describe('Vendor balance when returned.'),
+ balanceDue: z.number().optional().describe('Customer balance due when returned.'),
+ creditLimit: z.number().optional().describe('Customer credit limit when returned.'),
+ lastModifiedDateTime: z.string().optional(),
+ address: addressSchema.optional(),
+ record: rawRecordSchema
+});
+
+let vendorSchema = partySchema.extend({
+ irs1099Code: z.string().optional().describe('Vendor 1099 code when returned.'),
+ paymentMethodId: z.string().optional().describe('Vendor payment method GUID.'),
+ taxLiable: z.boolean().optional().describe('Whether the vendor is liable for sales tax.')
+});
+
+let mapParty = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ number: stringValue(record, 'number'),
+ displayName: stringValue(record, 'displayName'),
+ type: stringValue(record, 'type'),
+ phoneNumber: stringValue(record, 'phoneNumber'),
+ email: stringValue(record, 'email'),
+ website: stringValue(record, 'website'),
+ taxRegistrationNumber: stringValue(record, 'taxRegistrationNumber'),
+ currencyId: stringValue(record, 'currencyId'),
+ currencyCode: stringValue(record, 'currencyCode'),
+ paymentTermsId: stringValue(record, 'paymentTermsId'),
+ shipmentMethodId: stringValue(record, 'shipmentMethodId'),
+ blocked: stringValue(record, 'blocked'),
+ balance: numberValue(record, 'balance'),
+ balanceDue: numberValue(record, 'balanceDue'),
+ creditLimit: numberValue(record, 'creditLimit'),
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime'),
+ address: mapAddress(record)
+ }),
+ record
+});
+
+let mapVendor = (record: Record) => ({
+ ...mapParty(record),
+ ...compactRecord({
+ irs1099Code: stringValue(record, 'irs1099Code'),
+ paymentMethodId: stringValue(record, 'paymentMethodId'),
+ taxLiable: booleanValue(record, 'taxLiable')
+ })
+});
+
+let listParties = async (
+ ctx: PartyContext,
+ kind: 'customers' | 'vendors',
+ options: ListPartiesOptions = {}
+) => {
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let input = ctx.input;
+ validateBlockedFilter(input.blocked, options.blockedValues, kind.slice(0, -1));
+ let { params, page } = buildODataParams(ctx, input, [
+ containsFilter(options.searchFields ?? defaultPartySearchFields, input.search),
+ dateFromFilter('lastModifiedDateTime', input.updatedSince),
+ equalityFilter('blocked', input.blocked)
+ ]);
+ if (options.usesNestedSearchFilter) {
+ Object.assign(params, nestedFilterSchemaVersionParams(input.search));
+ }
+ let response = await client.getList>(
+ `list ${kind}`,
+ `/${companyPath(companyId)}/${kind}`,
+ params
+ );
+ let records = response.value!.map(kind === 'vendors' ? mapVendor : mapParty);
+
+ return {
+ records,
+ page: pageSummary(response, page)
+ };
+};
+
+let getParty = async (ctx: PartyContext, kind: 'customers' | 'vendors', id: string) => {
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let record = await client.getData>(
+ `get ${kind.slice(0, -1)}`,
+ `/${companyPath(companyId)}/${businessCentralEntityPath(kind, id)}`
+ );
+
+ return kind === 'vendors' ? mapVendor(record) : mapParty(record);
+};
+
+export let listCustomers = SlateTool.create(spec, {
+ name: 'List Business Central Customers',
+ key: 'list_customers',
+ description:
+ 'List Business Central customers with bounded OData pagination, search, update timestamp, blocked-state, and advanced OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...listInputFields,
+ search: z
+ .string()
+ .optional()
+ .describe('Search customer display name, number, email, or phone number.'),
+ updatedSince: z
+ .string()
+ .optional()
+ .describe('Return customers modified at or after this ISO timestamp.'),
+ blocked: z.string().optional().describe('Filter by upstream blocked value.')
+ })
+ )
+ .output(
+ z.object({
+ customers: z
+ .array(partySchema)
+ .describe('Customer records returned by Business Central.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let result = await listParties(ctx as PartyContext, 'customers', {
+ usesNestedSearchFilter: true
+ });
+
+ return {
+ output: {
+ customers: result.records,
+ page: result.page
+ },
+ message: `Found **${result.records.length}** Business Central customer record(s).`
+ };
+ })
+ .build();
+
+export let getCustomer = SlateTool.create(spec, {
+ name: 'Get Business Central Customer',
+ key: 'get_customer',
+ description: 'Retrieve one Business Central customer by company and customer GUID.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ customerId: z.string().describe('Business Central customer GUID.')
+ })
+ )
+ .output(partySchema)
+ .handleInvocation(async ctx => {
+ let customer = await getParty(ctx as PartyContext, 'customers', ctx.input.customerId);
+
+ return {
+ output: customer,
+ message: `Retrieved Business Central customer **${customer.displayName ?? customer.number ?? ctx.input.customerId}**.`
+ };
+ })
+ .build();
+
+export let listVendors = SlateTool.create(spec, {
+ name: 'List Business Central Vendors',
+ key: 'list_vendors',
+ description:
+ 'List Business Central vendors with bounded OData pagination, search, update timestamp, blocked-state, and advanced OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...listInputFields,
+ search: z
+ .string()
+ .optional()
+ .describe(
+ 'Search vendor display name. For number, email, or phone filters, provide a documented OData expression in odataFilter.'
+ ),
+ updatedSince: z
+ .string()
+ .optional()
+ .describe('Return vendors modified at or after this ISO timestamp.'),
+ blocked: z
+ .string()
+ .optional()
+ .describe(
+ 'Filter by documented Business Central vendor blocked value: " ", "Payment", or "All".'
+ )
+ })
+ )
+ .output(
+ z.object({
+ vendors: z.array(vendorSchema).describe('Vendor records returned by Business Central.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let result = await listParties(ctx as PartyContext, 'vendors', {
+ usesNestedSearchFilter: true,
+ searchFields: vendorSearchFields,
+ blockedValues: vendorBlockedValues
+ });
+
+ return {
+ output: {
+ vendors: result.records,
+ page: result.page
+ },
+ message: `Found **${result.records.length}** Business Central vendor record(s).`
+ };
+ })
+ .build();
+
+export let getVendor = SlateTool.create(spec, {
+ name: 'Get Business Central Vendor',
+ key: 'get_vendor',
+ description: 'Retrieve one Business Central vendor by company and vendor GUID.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ vendorId: z.string().describe('Business Central vendor GUID.')
+ })
+ )
+ .output(vendorSchema)
+ .handleInvocation(async ctx => {
+ let vendor = await getParty(ctx as PartyContext, 'vendors', ctx.input.vendorId);
+
+ return {
+ output: vendor,
+ message: `Retrieved Business Central vendor **${vendor.displayName ?? vendor.number ?? ctx.input.vendorId}**.`
+ };
+ })
+ .build();
diff --git a/integrations/business-central/src/tools/index.ts b/integrations/business-central/src/tools/index.ts
new file mode 100644
index 0000000000..71f79ac656
--- /dev/null
+++ b/integrations/business-central/src/tools/index.ts
@@ -0,0 +1,5 @@
+export * from './attachments';
+export * from './catalog-accounting';
+export * from './companies';
+export * from './contacts';
+export * from './invoices';
diff --git a/integrations/business-central/src/tools/invoices.ts b/integrations/business-central/src/tools/invoices.ts
new file mode 100644
index 0000000000..9d246f1f52
--- /dev/null
+++ b/integrations/business-central/src/tools/invoices.ts
@@ -0,0 +1,771 @@
+import { createBase64Attachment, SlateTool } from 'slates';
+import { z } from 'zod';
+import { businessCentralEntityPath } from '../lib/client';
+import { spec } from '../spec';
+import {
+ type BusinessCentralContext,
+ booleanValue,
+ buildODataParams,
+ compactRecord,
+ companyInputFields,
+ companyPath,
+ createClient,
+ dateFromFilter,
+ dateToFilter,
+ dimensionSetLineSchema,
+ equalityFilter,
+ expandedListInputFields,
+ mapDimensionSetLines,
+ nestedODataRecords,
+ numberValue,
+ type ODataInput,
+ pageOutputSchema,
+ pageSummary,
+ rawEqualityFilter,
+ rawRecordSchema,
+ resolveCompanyId,
+ stringValue
+} from './shared';
+
+type InvoiceContext = BusinessCentralContext & {
+ input: BusinessCentralContext['input'] &
+ ODataInput & {
+ invoiceId?: string;
+ purchaseInvoiceId?: string;
+ customerId?: string;
+ vendorId?: string;
+ status?: string;
+ invoiceDateFrom?: string;
+ invoiceDateTo?: string;
+ postingDateFrom?: string;
+ postingDateTo?: string;
+ dueDateFrom?: string;
+ dueDateTo?: string;
+ updatedSince?: string;
+ expandLines?: boolean;
+ expandCustomer?: boolean;
+ expandVendor?: boolean;
+ expandDimensions?: boolean;
+ expandAttachments?: boolean;
+ expandPdfDocument?: boolean;
+ acceptLanguage?: string;
+ fileName?: string;
+ };
+};
+
+let invoiceLineBaseSchema = z.object({
+ id: z.string().optional(),
+ documentId: z.string().optional(),
+ sequence: z.number().optional(),
+ itemId: z.string().optional(),
+ accountId: z.string().optional(),
+ lineType: z.string().optional(),
+ lineObjectNumber: z.string().optional(),
+ description: z.string().optional(),
+ description2: z.string().optional(),
+ unitOfMeasureId: z.string().optional(),
+ unitOfMeasureCode: z.string().optional(),
+ quantity: z.number().optional(),
+ discountAmount: z.number().optional(),
+ discountPercent: z.number().optional(),
+ discountAppliedBeforeTax: z.boolean().optional(),
+ taxCode: z.string().optional(),
+ taxPercent: z.number().optional(),
+ totalTaxAmount: z.number().optional(),
+ amountExcludingTax: z.number().optional(),
+ amountIncludingTax: z.number().optional(),
+ invoiceDiscountAllocation: z.number().optional(),
+ netAmount: z.number().optional(),
+ netTaxAmount: z.number().optional(),
+ netAmountIncludingTax: z.number().optional(),
+ itemVariantId: z.string().optional(),
+ locationId: z.string().optional(),
+ record: rawRecordSchema
+});
+
+let salesInvoiceLineSchema = invoiceLineBaseSchema.extend({
+ unitPrice: z.number().optional(),
+ shipmentDate: z.string().optional()
+});
+
+let purchaseInvoiceLineSchema = invoiceLineBaseSchema.extend({
+ unitCost: z.number().optional(),
+ expectedReceiptDate: z.string().optional()
+});
+
+let salesInvoiceSchema = z.object({
+ id: z.string().optional().describe('Business Central sales invoice GUID.'),
+ number: z.string().optional().describe('Sales invoice number.'),
+ externalDocumentNumber: z.string().optional(),
+ customerId: z.string().optional(),
+ customerNumber: z.string().optional(),
+ customerName: z.string().optional(),
+ billToCustomerId: z.string().optional(),
+ billToName: z.string().optional(),
+ invoiceDate: z.string().optional(),
+ postingDate: z.string().optional(),
+ dueDate: z.string().optional(),
+ promisedPayDate: z.string().optional(),
+ customerPurchaseOrderReference: z.string().optional(),
+ billToCustomerNumber: z.string().optional(),
+ shipToName: z.string().optional(),
+ shipToContact: z.string().optional(),
+ sellToAddressLine1: z.string().optional(),
+ sellToAddressLine2: z.string().optional(),
+ sellToCity: z.string().optional(),
+ sellToCountry: z.string().optional(),
+ sellToState: z.string().optional(),
+ sellToPostCode: z.string().optional(),
+ billToAddressLine1: z.string().optional(),
+ billToAddressLine2: z.string().optional(),
+ billToCity: z.string().optional(),
+ billToCountry: z.string().optional(),
+ billToState: z.string().optional(),
+ billToPostCode: z.string().optional(),
+ shipToAddressLine1: z.string().optional(),
+ shipToAddressLine2: z.string().optional(),
+ shipToCity: z.string().optional(),
+ shipToCountry: z.string().optional(),
+ shipToState: z.string().optional(),
+ shipToPostCode: z.string().optional(),
+ currencyId: z.string().optional(),
+ currencyCode: z.string().optional(),
+ shortcutDimension1Code: z.string().optional(),
+ shortcutDimension2Code: z.string().optional(),
+ orderId: z.string().optional(),
+ orderNumber: z.string().optional(),
+ paymentTermsId: z.string().optional(),
+ shipmentMethodId: z.string().optional(),
+ salesperson: z.string().optional(),
+ disputeStatusId: z.string().optional(),
+ disputeStatus: z.string().optional(),
+ pricesIncludeTax: z.boolean().optional(),
+ discountAmount: z.number().optional(),
+ discountAppliedBeforeTax: z.boolean().optional(),
+ totalAmountExcludingTax: z.number().optional(),
+ totalTaxAmount: z.number().optional(),
+ totalAmountIncludingTax: z.number().optional(),
+ remainingAmount: z.number().optional(),
+ status: z.string().optional(),
+ lastModifiedDateTime: z.string().optional(),
+ phoneNumber: z.string().optional(),
+ email: z.string().optional(),
+ lines: z.array(salesInvoiceLineSchema).optional(),
+ dimensions: z.array(dimensionSetLineSchema).optional(),
+ record: rawRecordSchema
+});
+
+let purchaseInvoiceSchema = z.object({
+ id: z.string().optional().describe('Business Central purchase invoice GUID.'),
+ number: z.string().optional().describe('Purchase invoice number.'),
+ vendorInvoiceNumber: z.string().optional(),
+ vendorId: z.string().optional(),
+ vendorNumber: z.string().optional(),
+ vendorName: z.string().optional(),
+ payToVendorId: z.string().optional(),
+ payToName: z.string().optional(),
+ payToContact: z.string().optional(),
+ payToVendorNumber: z.string().optional(),
+ shipToName: z.string().optional(),
+ shipToContact: z.string().optional(),
+ buyFromAddressLine1: z.string().optional(),
+ buyFromAddressLine2: z.string().optional(),
+ buyFromCity: z.string().optional(),
+ buyFromCountry: z.string().optional(),
+ buyFromState: z.string().optional(),
+ buyFromPostCode: z.string().optional(),
+ shipToAddressLine1: z.string().optional(),
+ shipToAddressLine2: z.string().optional(),
+ shipToCity: z.string().optional(),
+ shipToCountry: z.string().optional(),
+ shipToState: z.string().optional(),
+ shipToPostCode: z.string().optional(),
+ payToAddressLine1: z.string().optional(),
+ payToAddressLine2: z.string().optional(),
+ payToCity: z.string().optional(),
+ payToCountry: z.string().optional(),
+ payToState: z.string().optional(),
+ payToPostCode: z.string().optional(),
+ invoiceDate: z.string().optional(),
+ postingDate: z.string().optional(),
+ dueDate: z.string().optional(),
+ currencyId: z.string().optional(),
+ currencyCode: z.string().optional(),
+ shortcutDimension1Code: z.string().optional(),
+ shortcutDimension2Code: z.string().optional(),
+ orderId: z.string().optional(),
+ orderNumber: z.string().optional(),
+ purchaser: z.string().optional(),
+ pricesIncludeTax: z.boolean().optional(),
+ discountAmount: z.number().optional(),
+ discountAppliedBeforeTax: z.boolean().optional(),
+ totalAmountExcludingTax: z.number().optional(),
+ totalTaxAmount: z.number().optional(),
+ totalAmountIncludingTax: z.number().optional(),
+ status: z.string().optional(),
+ lastModifiedDateTime: z.string().optional(),
+ lines: z.array(purchaseInvoiceLineSchema).optional(),
+ dimensions: z.array(dimensionSetLineSchema).optional(),
+ record: rawRecordSchema
+});
+
+let ATTACHMENT_CONTENT_FIELD = 'attachmentContent';
+let EXPANDED_ATTACHMENT_FIELDS = ['attachments', 'documentAttachments'] as const;
+
+let isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+let stripAttachmentContent = (value: unknown) => {
+ if (
+ !isRecord(value) ||
+ !Object.prototype.hasOwnProperty.call(value, ATTACHMENT_CONTENT_FIELD)
+ ) {
+ return value;
+ }
+
+ let sanitized = { ...value };
+ delete sanitized[ATTACHMENT_CONTENT_FIELD];
+ return sanitized;
+};
+
+let sanitizeExpandedAttachmentCollection = (value: unknown): unknown => {
+ if (Array.isArray(value)) return value.map(stripAttachmentContent);
+ if (isRecord(value) && Array.isArray(value.value)) {
+ return {
+ ...value,
+ value: value.value.map(stripAttachmentContent)
+ };
+ }
+
+ return value;
+};
+
+let sanitizeInvoiceRecord = (record: Record) => {
+ let sanitized = record;
+
+ for (let field of EXPANDED_ATTACHMENT_FIELDS) {
+ if (!Object.prototype.hasOwnProperty.call(record, field)) continue;
+ if (sanitized === record) sanitized = { ...record };
+ sanitized[field] = sanitizeExpandedAttachmentCollection(record[field]);
+ }
+
+ return sanitized;
+};
+
+let mapInvoiceLineBase = (record: Record) =>
+ compactRecord({
+ id: stringValue(record, 'id'),
+ documentId: stringValue(record, 'documentId'),
+ sequence: numberValue(record, 'sequence'),
+ itemId: stringValue(record, 'itemId'),
+ accountId: stringValue(record, 'accountId'),
+ lineType: stringValue(record, 'lineType'),
+ lineObjectNumber: stringValue(record, 'lineObjectNumber'),
+ description: stringValue(record, 'description'),
+ description2: stringValue(record, 'description2'),
+ unitOfMeasureId: stringValue(record, 'unitOfMeasureId'),
+ unitOfMeasureCode: stringValue(record, 'unitOfMeasureCode'),
+ quantity: numberValue(record, 'quantity'),
+ discountAmount: numberValue(record, 'discountAmount'),
+ discountPercent: numberValue(record, 'discountPercent'),
+ discountAppliedBeforeTax: booleanValue(record, 'discountAppliedBeforeTax'),
+ taxCode: stringValue(record, 'taxCode'),
+ taxPercent: numberValue(record, 'taxPercent'),
+ totalTaxAmount: numberValue(record, 'totalTaxAmount'),
+ amountExcludingTax: numberValue(record, 'amountExcludingTax'),
+ amountIncludingTax: numberValue(record, 'amountIncludingTax'),
+ invoiceDiscountAllocation: numberValue(record, 'invoiceDiscountAllocation'),
+ netAmount: numberValue(record, 'netAmount'),
+ netTaxAmount: numberValue(record, 'netTaxAmount'),
+ netAmountIncludingTax: numberValue(record, 'netAmountIncludingTax'),
+ itemVariantId: stringValue(record, 'itemVariantId'),
+ locationId: stringValue(record, 'locationId')
+ });
+
+let mapSalesInvoiceLine = (record: Record) => ({
+ ...mapInvoiceLineBase(record),
+ ...compactRecord({
+ unitPrice: numberValue(record, 'unitPrice'),
+ shipmentDate: stringValue(record, 'shipmentDate')
+ }),
+ record
+});
+
+let mapPurchaseInvoiceLine = (record: Record) => ({
+ ...mapInvoiceLineBase(record),
+ ...compactRecord({
+ unitCost: numberValue(record, 'unitCost'),
+ expectedReceiptDate: stringValue(record, 'expectedReceiptDate')
+ }),
+ record
+});
+
+let mapSalesInvoice = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ number: stringValue(record, 'number'),
+ externalDocumentNumber: stringValue(record, 'externalDocumentNumber'),
+ customerId: stringValue(record, 'customerId'),
+ customerNumber: stringValue(record, 'customerNumber'),
+ customerName: stringValue(record, 'customerName'),
+ billToCustomerId: stringValue(record, 'billToCustomerId'),
+ billToName: stringValue(record, 'billToName'),
+ invoiceDate: stringValue(record, 'invoiceDate'),
+ postingDate: stringValue(record, 'postingDate'),
+ dueDate: stringValue(record, 'dueDate'),
+ promisedPayDate: stringValue(record, 'promisedPayDate'),
+ customerPurchaseOrderReference: stringValue(record, 'customerPurchaseOrderReference'),
+ billToCustomerNumber: stringValue(record, 'billToCustomerNumber'),
+ shipToName: stringValue(record, 'shipToName'),
+ shipToContact: stringValue(record, 'shipToContact'),
+ sellToAddressLine1: stringValue(record, 'sellToAddressLine1'),
+ sellToAddressLine2: stringValue(record, 'sellToAddressLine2'),
+ sellToCity: stringValue(record, 'sellToCity'),
+ sellToCountry: stringValue(record, 'sellToCountry'),
+ sellToState: stringValue(record, 'sellToState'),
+ sellToPostCode: stringValue(record, 'sellToPostCode'),
+ billToAddressLine1: stringValue(record, 'billToAddressLine1'),
+ billToAddressLine2: stringValue(record, 'billToAddressLine2'),
+ billToCity: stringValue(record, 'billToCity'),
+ billToCountry: stringValue(record, 'billToCountry'),
+ billToState: stringValue(record, 'billToState'),
+ billToPostCode: stringValue(record, 'billToPostCode'),
+ shipToAddressLine1: stringValue(record, 'shipToAddressLine1'),
+ shipToAddressLine2: stringValue(record, 'shipToAddressLine2'),
+ shipToCity: stringValue(record, 'shipToCity'),
+ shipToCountry: stringValue(record, 'shipToCountry'),
+ shipToState: stringValue(record, 'shipToState'),
+ shipToPostCode: stringValue(record, 'shipToPostCode'),
+ currencyId: stringValue(record, 'currencyId'),
+ currencyCode: stringValue(record, 'currencyCode'),
+ shortcutDimension1Code: stringValue(record, 'shortcutDimension1Code'),
+ shortcutDimension2Code: stringValue(record, 'shortcutDimension2Code'),
+ orderId: stringValue(record, 'orderId'),
+ orderNumber: stringValue(record, 'orderNumber'),
+ paymentTermsId: stringValue(record, 'paymentTermsId'),
+ shipmentMethodId: stringValue(record, 'shipmentMethodId'),
+ salesperson: stringValue(record, 'salesperson'),
+ disputeStatusId: stringValue(record, 'disputeStatusId'),
+ disputeStatus: stringValue(record, 'disputeStatus'),
+ pricesIncludeTax: booleanValue(record, 'pricesIncludeTax'),
+ discountAmount: numberValue(record, 'discountAmount'),
+ discountAppliedBeforeTax: booleanValue(record, 'discountAppliedBeforeTax'),
+ totalAmountExcludingTax: numberValue(record, 'totalAmountExcludingTax'),
+ totalTaxAmount: numberValue(record, 'totalTaxAmount'),
+ totalAmountIncludingTax: numberValue(record, 'totalAmountIncludingTax'),
+ remainingAmount: numberValue(record, 'remainingAmount'),
+ status: stringValue(record, 'status'),
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime'),
+ phoneNumber: stringValue(record, 'phoneNumber'),
+ email: stringValue(record, 'email'),
+ lines: nestedODataRecords(record, 'salesInvoiceLines', 'lines')?.map(mapSalesInvoiceLine),
+ dimensions: mapDimensionSetLines(nestedODataRecords(record, 'dimensionSetLines'))
+ }),
+ record: sanitizeInvoiceRecord(record)
+});
+
+let mapPurchaseInvoice = (record: Record) => ({
+ ...compactRecord({
+ id: stringValue(record, 'id'),
+ number: stringValue(record, 'number'),
+ vendorInvoiceNumber: stringValue(record, 'vendorInvoiceNumber'),
+ vendorId: stringValue(record, 'vendorId'),
+ vendorNumber: stringValue(record, 'vendorNumber'),
+ vendorName: stringValue(record, 'vendorName'),
+ payToVendorId: stringValue(record, 'payToVendorId'),
+ payToName: stringValue(record, 'payToName'),
+ payToContact: stringValue(record, 'payToContact'),
+ payToVendorNumber: stringValue(record, 'payToVendorNumber'),
+ shipToName: stringValue(record, 'shipToName'),
+ shipToContact: stringValue(record, 'shipToContact'),
+ buyFromAddressLine1: stringValue(record, 'buyFromAddressLine1'),
+ buyFromAddressLine2: stringValue(record, 'buyFromAddressLine2'),
+ buyFromCity: stringValue(record, 'buyFromCity'),
+ buyFromCountry: stringValue(record, 'buyFromCountry'),
+ buyFromState: stringValue(record, 'buyFromState'),
+ buyFromPostCode: stringValue(record, 'buyFromPostCode'),
+ shipToAddressLine1: stringValue(record, 'shipToAddressLine1'),
+ shipToAddressLine2: stringValue(record, 'shipToAddressLine2'),
+ shipToCity: stringValue(record, 'shipToCity'),
+ shipToCountry: stringValue(record, 'shipToCountry'),
+ shipToState: stringValue(record, 'shipToState'),
+ shipToPostCode: stringValue(record, 'shipToPostCode'),
+ payToAddressLine1: stringValue(record, 'payToAddressLine1'),
+ payToAddressLine2: stringValue(record, 'payToAddressLine2'),
+ payToCity: stringValue(record, 'payToCity'),
+ payToCountry: stringValue(record, 'payToCountry'),
+ payToState: stringValue(record, 'payToState'),
+ payToPostCode: stringValue(record, 'payToPostCode'),
+ invoiceDate: stringValue(record, 'invoiceDate'),
+ postingDate: stringValue(record, 'postingDate'),
+ dueDate: stringValue(record, 'dueDate'),
+ currencyId: stringValue(record, 'currencyId'),
+ currencyCode: stringValue(record, 'currencyCode'),
+ shortcutDimension1Code: stringValue(record, 'shortcutDimension1Code'),
+ shortcutDimension2Code: stringValue(record, 'shortcutDimension2Code'),
+ orderId: stringValue(record, 'orderId'),
+ orderNumber: stringValue(record, 'orderNumber'),
+ purchaser: stringValue(record, 'purchaser'),
+ pricesIncludeTax: booleanValue(record, 'pricesIncludeTax'),
+ discountAmount: numberValue(record, 'discountAmount'),
+ discountAppliedBeforeTax: booleanValue(record, 'discountAppliedBeforeTax'),
+ totalAmountExcludingTax: numberValue(record, 'totalAmountExcludingTax'),
+ totalTaxAmount: numberValue(record, 'totalTaxAmount'),
+ totalAmountIncludingTax: numberValue(record, 'totalAmountIncludingTax'),
+ status: stringValue(record, 'status'),
+ lastModifiedDateTime: stringValue(record, 'lastModifiedDateTime'),
+ lines: nestedODataRecords(record, 'purchaseInvoiceLines', 'lines')?.map(
+ mapPurchaseInvoiceLine
+ ),
+ dimensions: mapDimensionSetLines(nestedODataRecords(record, 'dimensionSetLines'))
+ }),
+ record: sanitizeInvoiceRecord(record)
+});
+
+let invoiceFilters = (
+ input: InvoiceContext['input'],
+ params: {
+ partyField: 'customerId' | 'vendorId';
+ partyId?: string;
+ }
+) => [
+ equalityFilter('status', input.status),
+ rawEqualityFilter(params.partyField, params.partyId),
+ dateFromFilter('invoiceDate', input.invoiceDateFrom),
+ dateToFilter('invoiceDate', input.invoiceDateTo),
+ dateFromFilter('postingDate', input.postingDateFrom),
+ dateToFilter('postingDate', input.postingDateTo),
+ dateFromFilter('dueDate', input.dueDateFrom),
+ dateToFilter('dueDate', input.dueDateTo),
+ dateFromFilter('lastModifiedDateTime', input.updatedSince)
+];
+
+let salesExpand = (input: InvoiceContext['input']) => [
+ ...(input.expandLines ? ['salesInvoiceLines'] : []),
+ ...(input.expandCustomer ? ['customer'] : []),
+ ...(input.expandDimensions ? ['dimensionSetLines'] : []),
+ ...(input.expandPdfDocument ? ['pdfDocument'] : []),
+ ...(input.expandAttachments ? ['attachments', 'documentAttachments'] : []),
+ ...(input.expand ?? [])
+];
+
+let purchaseExpand = (input: InvoiceContext['input']) => [
+ ...(input.expandLines ? ['purchaseInvoiceLines'] : []),
+ ...(input.expandVendor ? ['vendor'] : []),
+ ...(input.expandDimensions ? ['dimensionSetLines'] : []),
+ ...(input.expandAttachments ? ['attachments', 'documentAttachments'] : []),
+ ...(input.expand ?? [])
+];
+
+export let salesInvoicePdfContentPath = (companyId: string, invoiceId: string) =>
+ `/${companyPath(companyId)}/${businessCentralEntityPath(
+ 'salesInvoices',
+ invoiceId
+ )}/pdfDocument/pdfDocumentContent`;
+
+export let listSalesInvoices = SlateTool.create(spec, {
+ name: 'List Business Central Sales Invoices',
+ key: 'list_sales_invoices',
+ description:
+ 'List Business Central sales invoices by customer, status, invoice/posting/due dates, update timestamp, and OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...expandedListInputFields,
+ customerId: z.string().optional().describe('Filter by Business Central customer GUID.'),
+ status: z.string().optional().describe('Filter by upstream invoice status.'),
+ invoiceDateFrom: z.string().optional().describe('Minimum invoiceDate.'),
+ invoiceDateTo: z.string().optional().describe('Maximum invoiceDate.'),
+ postingDateFrom: z.string().optional().describe('Minimum postingDate.'),
+ postingDateTo: z.string().optional().describe('Maximum postingDate.'),
+ dueDateFrom: z.string().optional().describe('Minimum dueDate.'),
+ dueDateTo: z.string().optional().describe('Maximum dueDate.'),
+ updatedSince: z
+ .string()
+ .optional()
+ .describe('Return invoices modified at or after this ISO timestamp.'),
+ expandLines: z
+ .boolean()
+ .optional()
+ .describe('Expand salesInvoiceLines navigation data.'),
+ expandCustomer: z.boolean().optional().describe('Expand customer navigation data.')
+ })
+ )
+ .output(
+ z.object({
+ salesInvoices: z
+ .array(salesInvoiceSchema)
+ .describe('Sales invoice records returned by Business Central.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as InvoiceContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let { params, page } = buildODataParams(
+ ctx,
+ { ...ctx.input, expand: salesExpand(ctx.input) },
+ invoiceFilters(ctx.input, {
+ partyField: 'customerId',
+ partyId: ctx.input.customerId
+ })
+ );
+ let response = await client.getList>(
+ 'list sales invoices',
+ `/${companyPath(companyId)}/salesInvoices`,
+ params
+ );
+ let salesInvoices = response.value!.map(mapSalesInvoice);
+
+ return {
+ output: {
+ salesInvoices,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${salesInvoices.length}** Business Central sales invoice record(s).`
+ };
+ })
+ .build();
+
+export let getSalesInvoice = SlateTool.create(spec, {
+ name: 'Get Business Central Sales Invoice',
+ key: 'get_sales_invoice',
+ description:
+ 'Retrieve one Business Central sales invoice by GUID with optional lines, customer, dimensions, PDF metadata, and attachment navigation expansion.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ invoiceId: z.string().describe('Business Central sales invoice GUID.'),
+ select: z.array(z.string()).optional().describe('Optional OData $select fields.'),
+ expand: z.array(z.string()).optional().describe('Optional OData $expand fields.'),
+ expandLines: z
+ .boolean()
+ .optional()
+ .describe('Expand salesInvoiceLines navigation data.'),
+ expandCustomer: z.boolean().optional().describe('Expand customer navigation data.'),
+ expandDimensions: z.boolean().optional().describe('Expand dimensionSetLines.'),
+ expandPdfDocument: z.boolean().optional().describe('Expand pdfDocument metadata.'),
+ expandAttachments: z
+ .boolean()
+ .optional()
+ .describe('Expand attachments and documentAttachments metadata.')
+ })
+ )
+ .output(salesInvoiceSchema)
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as InvoiceContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let params = compactRecord({
+ $select: ctx.input.select?.join(','),
+ $expand: salesExpand(ctx.input).join(',') || undefined
+ });
+ let record = await client.getData>(
+ 'get sales invoice',
+ `/${companyPath(companyId)}/${businessCentralEntityPath('salesInvoices', ctx.input.invoiceId!)}`,
+ params
+ );
+ let invoice = mapSalesInvoice(record);
+
+ return {
+ output: invoice,
+ message: `Retrieved Business Central sales invoice **${invoice.number ?? ctx.input.invoiceId}**.`
+ };
+ })
+ .build();
+
+export let getSalesInvoicePdf = SlateTool.create(spec, {
+ name: 'Get Business Central Sales Invoice PDF',
+ key: 'get_sales_invoice_pdf',
+ description:
+ 'Download a Business Central sales invoice PDF as a Slate attachment. JSON output contains metadata only, never file bytes.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ invoiceId: z.string().describe('Business Central sales invoice GUID.'),
+ acceptLanguage: z
+ .string()
+ .optional()
+ .describe('Optional Accept-Language header for localized PDF output.'),
+ fileName: z.string().optional().describe('Optional filename metadata for the PDF.')
+ })
+ )
+ .output(
+ z.object({
+ companyId: z.string().describe('Business Central company GUID.'),
+ invoiceId: z.string().describe('Business Central sales invoice GUID.'),
+ fileName: z.string().describe('Suggested PDF filename.'),
+ mimeType: z.string().describe('Downloaded PDF MIME type.'),
+ size: z.number().describe('Downloaded PDF byte size.'),
+ attachmentCount: z.number().describe('Number of Slate attachments returned.')
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as InvoiceContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let invoiceId = ctx.input.invoiceId!;
+ let file = await client.downloadFile(
+ 'download sales invoice PDF',
+ salesInvoicePdfContentPath(companyId, invoiceId),
+ {
+ acceptLanguage: ctx.input.acceptLanguage
+ }
+ );
+ let fileName = ctx.input.fileName ?? `business-central-sales-invoice-${invoiceId}.pdf`;
+
+ return {
+ output: {
+ companyId,
+ invoiceId,
+ fileName,
+ mimeType: file.mimeType,
+ size: file.size,
+ attachmentCount: 1
+ },
+ message: `Downloaded Business Central sales invoice PDF **${fileName}**.`,
+ attachments: [createBase64Attachment(file.contentBase64, file.mimeType)]
+ };
+ })
+ .build();
+
+export let listPurchaseInvoices = SlateTool.create(spec, {
+ name: 'List Business Central Purchase Invoices',
+ key: 'list_purchase_invoices',
+ description:
+ 'List Business Central purchase invoices by vendor, status, invoice/posting/due dates, update timestamp, and OData filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ ...expandedListInputFields,
+ vendorId: z.string().optional().describe('Filter by Business Central vendor GUID.'),
+ status: z.string().optional().describe('Filter by upstream invoice status.'),
+ invoiceDateFrom: z.string().optional().describe('Minimum invoiceDate.'),
+ invoiceDateTo: z.string().optional().describe('Maximum invoiceDate.'),
+ postingDateFrom: z.string().optional().describe('Minimum postingDate.'),
+ postingDateTo: z.string().optional().describe('Maximum postingDate.'),
+ dueDateFrom: z.string().optional().describe('Minimum dueDate.'),
+ dueDateTo: z.string().optional().describe('Maximum dueDate.'),
+ updatedSince: z
+ .string()
+ .optional()
+ .describe('Return invoices modified at or after this ISO timestamp.'),
+ expandLines: z
+ .boolean()
+ .optional()
+ .describe('Expand purchaseInvoiceLines navigation data.'),
+ expandVendor: z.boolean().optional().describe('Expand vendor navigation data.')
+ })
+ )
+ .output(
+ z.object({
+ purchaseInvoices: z
+ .array(purchaseInvoiceSchema)
+ .describe('Purchase invoice records returned by Business Central.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as InvoiceContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let { params, page } = buildODataParams(
+ ctx,
+ { ...ctx.input, expand: purchaseExpand(ctx.input) },
+ invoiceFilters(ctx.input, {
+ partyField: 'vendorId',
+ partyId: ctx.input.vendorId
+ })
+ );
+ let response = await client.getList>(
+ 'list purchase invoices',
+ `/${companyPath(companyId)}/purchaseInvoices`,
+ params
+ );
+ let purchaseInvoices = response.value!.map(mapPurchaseInvoice);
+
+ return {
+ output: {
+ purchaseInvoices,
+ page: pageSummary(response, page)
+ },
+ message: `Found **${purchaseInvoices.length}** Business Central purchase invoice record(s).`
+ };
+ })
+ .build();
+
+export let getPurchaseInvoice = SlateTool.create(spec, {
+ name: 'Get Business Central Purchase Invoice',
+ key: 'get_purchase_invoice',
+ description:
+ 'Retrieve one Business Central purchase invoice by GUID with optional lines, vendor, dimensions, and attachment navigation expansion.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ ...companyInputFields,
+ purchaseInvoiceId: z.string().describe('Business Central purchase invoice GUID.'),
+ select: z.array(z.string()).optional().describe('Optional OData $select fields.'),
+ expand: z.array(z.string()).optional().describe('Optional OData $expand fields.'),
+ expandLines: z
+ .boolean()
+ .optional()
+ .describe('Expand purchaseInvoiceLines navigation data.'),
+ expandVendor: z.boolean().optional().describe('Expand vendor navigation data.'),
+ expandDimensions: z.boolean().optional().describe('Expand dimensionSetLines.'),
+ expandAttachments: z
+ .boolean()
+ .optional()
+ .describe('Expand attachments and documentAttachments metadata.')
+ })
+ )
+ .output(purchaseInvoiceSchema)
+ .handleInvocation(async rawCtx => {
+ let ctx = rawCtx as InvoiceContext;
+ let client = createClient(ctx);
+ let companyId = resolveCompanyId(ctx);
+ let purchaseInvoiceId = ctx.input.purchaseInvoiceId!;
+ let params = compactRecord({
+ $select: ctx.input.select?.join(','),
+ $expand: purchaseExpand(ctx.input).join(',') || undefined
+ });
+ let record = await client.getData>(
+ 'get purchase invoice',
+ `/${companyPath(companyId)}/${businessCentralEntityPath(
+ 'purchaseInvoices',
+ purchaseInvoiceId
+ )}`,
+ params
+ );
+ let invoice = mapPurchaseInvoice(record);
+
+ return {
+ output: invoice,
+ message: `Retrieved Business Central purchase invoice **${invoice.number ?? purchaseInvoiceId}**.`
+ };
+ })
+ .build();
diff --git a/integrations/business-central/src/tools/shared.ts b/integrations/business-central/src/tools/shared.ts
new file mode 100644
index 0000000000..185be92f77
--- /dev/null
+++ b/integrations/business-central/src/tools/shared.ts
@@ -0,0 +1,315 @@
+import { pickDefined } from 'slates';
+import { z } from 'zod';
+import {
+ BusinessCentralClient,
+ businessCentralEntityPath,
+ type ODataListResponse
+} from '../lib/client';
+import { businessCentralValidationError } from '../lib/errors';
+
+export type BusinessCentralContext = {
+ auth: {
+ token: string;
+ tenantId?: string;
+ };
+ config?: {
+ tenantId?: string;
+ environmentName?: string;
+ companyId?: string;
+ defaultLimit?: number;
+ };
+ input: {
+ tenantId?: string;
+ environmentName?: string;
+ companyId?: string;
+ limit?: number;
+ skip?: number;
+ };
+};
+
+export type ODataInput = {
+ limit?: number;
+ skip?: number;
+ select?: string[];
+ expand?: string[];
+ odataFilter?: string;
+};
+
+export let rawRecordSchema = z
+ .record(z.string(), z.unknown())
+ .describe('Raw Business Central record for fields not normalized by this tool.');
+
+export let environmentInputFields = {
+ tenantId: z
+ .string()
+ .optional()
+ .describe('Override Microsoft Entra tenant ID segment for this Business Central request.'),
+ environmentName: z
+ .string()
+ .optional()
+ .describe(
+ 'Override Business Central environment name. Defaults to configured value or production.'
+ )
+};
+
+export let companyInputFields = {
+ ...environmentInputFields,
+ companyId: z
+ .string()
+ .optional()
+ .describe('Business Central company GUID. Overrides configured default companyId.')
+};
+
+export let listInputFields = {
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(1000)
+ .optional()
+ .describe(
+ 'Maximum number of records to return. Defaults to configured defaultLimit or 50.'
+ ),
+ skip: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe('Number of records to skip for OData pagination.'),
+ select: z
+ .array(z.string())
+ .optional()
+ .describe('Optional OData $select fields. Use Business Central API field names.'),
+ odataFilter: z
+ .string()
+ .optional()
+ .describe('Advanced OData $filter expression. Combined with structured filters using and.')
+};
+
+export let expandedListInputFields = {
+ ...listInputFields,
+ expand: z
+ .array(z.string())
+ .optional()
+ .describe('Optional OData $expand navigation properties.')
+};
+
+export let pageOutputSchema = z.object({
+ count: z.number().describe('Number of records returned in this response.'),
+ limit: z.number().describe('Requested page size.'),
+ skip: z.number().describe('Requested skip offset.'),
+ nextSkip: z.number().optional().describe('Next skip offset when another page is likely.'),
+ odataNextLink: z.string().optional().describe('Raw Business Central @odata.nextLink.')
+});
+
+export let addressSchema = z.object({
+ addressLine1: z.string().optional(),
+ addressLine2: z.string().optional(),
+ city: z.string().optional(),
+ state: z.string().optional(),
+ country: z.string().optional(),
+ postalCode: z.string().optional()
+});
+
+export let dimensionSetLineSchema = z.object({
+ id: z.string().optional(),
+ code: z.string().optional(),
+ displayName: z.string().optional(),
+ valueCode: z.string().optional(),
+ valueDisplayName: z.string().optional(),
+ record: rawRecordSchema
+});
+
+export let stringValue = (record: Record, key: string) => {
+ let value = record[key];
+ return typeof value === 'string' ? value : undefined;
+};
+
+export let numberValue = (record: Record, key: string) => {
+ let value = record[key];
+ return typeof value === 'number' ? value : undefined;
+};
+
+export let booleanValue = (record: Record, key: string) => {
+ let value = record[key];
+ return typeof value === 'boolean' ? value : undefined;
+};
+
+export let arrayValue = (record: Record, key: string) => {
+ let value = record[key];
+ return Array.isArray(value) ? value : undefined;
+};
+
+export let recordArrayValue = (record: Record, key: string) =>
+ arrayValue(record, key)?.filter(
+ (item): item is Record =>
+ typeof item === 'object' && item !== null && !Array.isArray(item)
+ );
+
+export let nestedODataRecords = (record: Record, ...keys: string[]) => {
+ for (let key of keys) {
+ let value = record[key];
+ if (Array.isArray(value)) {
+ return value.filter(
+ (item): item is Record =>
+ typeof item === 'object' && item !== null && !Array.isArray(item)
+ );
+ }
+
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ let list = (value as Record).value;
+ if (Array.isArray(list)) {
+ return list.filter(
+ (item): item is Record =>
+ typeof item === 'object' && item !== null && !Array.isArray(item)
+ );
+ }
+ }
+ }
+
+ return undefined;
+};
+
+export let compactRecord = (value: T) => pickDefined(value);
+
+export let mapAddress = (record: Record) =>
+ compactRecord({
+ addressLine1: stringValue(record, 'addressLine1'),
+ addressLine2: stringValue(record, 'addressLine2'),
+ city: stringValue(record, 'city'),
+ state: stringValue(record, 'state'),
+ country: stringValue(record, 'country'),
+ postalCode: stringValue(record, 'postalCode')
+ });
+
+export let mapDimensionSetLines = (
+ lines: Record[] | undefined
+): z.infer[] | undefined =>
+ lines?.map(line => ({
+ ...compactRecord({
+ id: stringValue(line, 'id'),
+ code: stringValue(line, 'code'),
+ displayName: stringValue(line, 'displayName'),
+ valueCode: stringValue(line, 'valueCode'),
+ valueDisplayName: stringValue(line, 'valueDisplayName')
+ }),
+ record: line
+ }));
+
+export let createClient = (ctx: BusinessCentralContext) =>
+ new BusinessCentralClient({
+ token: ctx.auth.token,
+ tenantId: ctx.input.tenantId ?? ctx.config?.tenantId ?? ctx.auth.tenantId,
+ environmentName: ctx.input.environmentName ?? ctx.config?.environmentName
+ });
+
+export let resolveCompanyId = (ctx: BusinessCentralContext) => {
+ let companyId = ctx.input.companyId ?? ctx.config?.companyId;
+ if (!companyId) {
+ throw businessCentralValidationError(
+ 'companyId is required. Call list_companies first or configure a default companyId.'
+ );
+ }
+
+ return companyId;
+};
+
+export let companyPath = (companyId: string) =>
+ businessCentralEntityPath('companies', companyId);
+
+export let odataString = (value: string) => `'${value.replace(/'/g, "''")}'`;
+
+export let odataDate = (value: string) => value;
+
+export let containsFilter = (fields: string[], search: string | undefined) => {
+ let normalized = search?.trim().toLowerCase();
+ if (!normalized) return undefined;
+
+ let literal = odataString(normalized);
+ return `(${fields.map(field => `contains(tolower(${field}),${literal})`).join(' or ')})`;
+};
+
+export let equalityFilter = (field: string, value: string | undefined) =>
+ value ? `${field} eq ${odataString(value)}` : undefined;
+
+export let rawEqualityFilter = (field: string, value: string | undefined) =>
+ value ? `${field} eq ${value}` : undefined;
+
+export let dateFromFilter = (field: string, value: string | undefined) =>
+ value ? `${field} ge ${odataDate(value)}` : undefined;
+
+export let dateToFilter = (field: string, value: string | undefined) =>
+ value ? `${field} le ${odataDate(value)}` : undefined;
+
+export let combineFilters = (filters: Array) => {
+ let provided = filters.map(filter => filter?.trim()).filter(Boolean) as string[];
+ if (provided.length === 0) return undefined;
+ if (provided.length === 1) return provided[0];
+ return provided.map(filter => `(${filter})`).join(' and ');
+};
+
+export let buildODataParams = (
+ ctx: BusinessCentralContext,
+ input: ODataInput,
+ filters: Array = []
+) => {
+ let limit = input.limit ?? ctx.config?.defaultLimit ?? 50;
+ let skip = input.skip ?? 0;
+ let filter = combineFilters([...filters, input.odataFilter]);
+
+ return {
+ params: compactRecord({
+ $top: limit,
+ $skip: skip,
+ $select: input.select?.join(','),
+ $expand: input.expand?.join(','),
+ $filter: filter
+ }),
+ page: {
+ limit,
+ skip
+ }
+ };
+};
+
+let skipFromNextLink = (nextLink: string | undefined) => {
+ if (!nextLink) return undefined;
+
+ try {
+ let url = new URL(nextLink);
+ let skip = url.searchParams.get('$skip') ?? url.searchParams.get('%24skip');
+ if (!skip) return undefined;
+ let parsed = Number(skip);
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+export let pageSummary = (
+ response: ODataListResponse,
+ page: { limit: number; skip: number }
+) => {
+ let count = response.value?.length ?? 0;
+ let odataNextLink = response['@odata.nextLink'];
+ let nextSkip = skipFromNextLink(odataNextLink);
+
+ return {
+ count,
+ limit: page.limit,
+ skip: page.skip,
+ nextSkip: nextSkip ?? (count >= page.limit ? page.skip + count : undefined),
+ odataNextLink
+ };
+};
+
+export let requireRecord = (value: unknown, label: string) => {
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ return value as Record;
+ }
+
+ throw businessCentralValidationError(`Business Central ${label} did not return an object.`);
+};
+
+export let optionalStringEnum = (values: readonly T[]) =>
+ z.enum(values as [T, ...T[]]).optional();
diff --git a/integrations/business-central/src/triggers/index.ts b/integrations/business-central/src/triggers/index.ts
new file mode 100644
index 0000000000..cb0ff5c3b5
--- /dev/null
+++ b/integrations/business-central/src/triggers/index.ts
@@ -0,0 +1 @@
+export {};
diff --git a/integrations/business-central/tsconfig.json b/integrations/business-central/tsconfig.json
new file mode 100644
index 0000000000..2abe727831
--- /dev/null
+++ b/integrations/business-central/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "types": ["node"],
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+ "moduleResolution": "bundler",
+
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ },
+ "include": ["src"]
+}
diff --git a/integrations/business-central/vitest.config.ts b/integrations/business-central/vitest.config.ts
new file mode 100644
index 0000000000..77375364da
--- /dev/null
+++ b/integrations/business-central/vitest.config.ts
@@ -0,0 +1,7 @@
+import { createSlatesVitestConfig } from '@slates/test/config';
+
+export default createSlatesVitestConfig({
+ test: {
+ include: ['src/**/*.test.ts']
+ }
+});
diff --git a/integrations/fiken/README.md b/integrations/fiken/README.md
new file mode 100644
index 0000000000..d7cc51a4c6
--- /dev/null
+++ b/integrations/fiken/README.md
@@ -0,0 +1,46 @@
+#
Fiken
+
+Fiken is a Norwegian online accounting system for small businesses, covering
+accounting, invoicing, purchases, sales, products, projects, and related
+financial records.
+
+This integration uses Fiken OAuth2 for third-party access. It can discover the
+companies available to the authenticated user, read and create contacts, inspect
+invoices and invoice drafts, create invoice drafts for review, manage products
+and projects, and read purchases, sales, bookkeeping accounts, and account
+balances.
+
+## Authentication
+
+Fiken third-party integrations must use OAuth2. The authorization flow uses
+`https://fiken.no/oauth/authorize`, and token exchange/refresh calls use
+`https://fiken.no/oauth/token` with HTTP Basic authentication from the OAuth app
+client id and client secret.
+
+Fiken may rotate refresh tokens during refresh. This integration preserves the
+latest refresh token returned by Fiken.
+
+## Configuration
+
+Set `defaultCompanySlug` when most tool calls should target one Fiken company.
+If it is not configured, company-scoped tools require `companySlug` input. Use
+`list_companies` first when the user does not know the slug.
+
+## Tools
+
+- List and get accessible companies.
+- List, get, and create contacts.
+- List and get invoices.
+- List, get, and create invoice drafts.
+- List, get, and create products.
+- List, get, and create projects.
+- List and get purchases and sales.
+- List and get bookkeeping accounts and account balances.
+
+## License
+
+This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE).
+
+
diff --git a/integrations/fiken/docs/SPEC.md b/integrations/fiken/docs/SPEC.md
new file mode 100644
index 0000000000..9aa44f2d08
--- /dev/null
+++ b/integrations/fiken/docs/SPEC.md
@@ -0,0 +1,57 @@
+# Fiken Integration Specification
+
+## Overview
+
+Fiken exposes a REST/JSON API at `https://api.fiken.no/api/v2`. A user can grant
+access to one or more companies, and company-scoped endpoints use
+`companySlug` in the path.
+
+The first implementation focuses on safe accounting workflows with stable API
+schemas: company discovery, contacts, invoices and invoice drafts, products,
+projects, purchases, sales, accounts, and account balances.
+
+## Authentication
+
+The integration exposes OAuth2 only. Fiken documents personal API tokens for
+customers' own internal use, but third-party integrations must use OAuth2.
+
+OAuth token exchange and refresh calls use
+`application/x-www-form-urlencoded` bodies and HTTP Basic authentication with
+the OAuth app client id and secret. The auth output stores `token`,
+`refreshToken`, and `expiresAt`; refresh preserves the previous refresh token
+when Fiken does not return a replacement.
+
+## API Behavior
+
+Collection tools use Fiken's `page` and `pageSize` query parameters. Defaults
+are `page=0` and `pageSize=25`, and `pageSize` is capped at 100.
+
+The client sends an `X-Request-ID` UUID and serializes requests through a
+per-client queue to respect Fiken's one-concurrent-request limit and 4
+requests-per-second guidance.
+
+## Tool Surface
+
+- `list_companies`, `get_company`
+- `list_contacts`, `get_contact`, `create_contact`
+- `list_invoices`, `get_invoice`
+- `list_invoice_drafts`, `get_invoice_draft`, `create_invoice_draft`
+- `list_products`, `get_product`, `create_product`
+- `list_projects`, `get_project`, `create_project`
+- `list_purchases`, `get_purchase`
+- `list_sales`, `get_sale`
+- `list_accounts`, `get_account`
+- `list_account_balances`, `get_account_balance`
+
+## Files
+
+This first tool surface does not download Fiken attachment content. Future
+download tools must return Slate attachments rather than inline base64 or full
+file text.
+
+## Risks
+
+Fiken production API access is a paid module for end users. Some tools may fail
+with authorization errors when API access is not enabled for the selected
+company. Attachment download URL behavior still needs live verification before
+adding file download tools.
diff --git a/integrations/fiken/package.json b/integrations/fiken/package.json
new file mode 100644
index 0000000000..d78b0cc243
--- /dev/null
+++ b/integrations/fiken/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@slates-integrations/fiken",
+ "main": "src/index.ts",
+ "type": "module",
+ "scripts": {
+ "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s",
+ "test": "vitest run --passWithNoTests",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2"
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2"
+ },
+ "version": "0.1.1-rc.2"
+}
diff --git a/integrations/fiken/slate.json b/integrations/fiken/slate.json
new file mode 100644
index 0000000000..7397bda815
--- /dev/null
+++ b/integrations/fiken/slate.json
@@ -0,0 +1,15 @@
+{
+ "name": "@metorial/fiken",
+ "description": "Connect to Fiken accounting data for Norwegian companies. Discover accessible companies, manage contacts, inspect invoices and invoice drafts, create reviewable invoice drafts, manage products and projects, list purchases and sales, and read bookkeeping accounts and balances.",
+ "categories": ["accounting", "erp", "finance"],
+ "skills": [
+ "discover Fiken companies",
+ "manage customer and supplier contacts",
+ "review invoices and invoice drafts",
+ "create invoice drafts for review",
+ "manage products and projects",
+ "inspect purchases and sales",
+ "read bookkeeping accounts and balances"
+ ],
+ "logoUrl": "https://cdn.fiken.no/logo-2026.png"
+}
diff --git a/integrations/fiken/src/auth.ts b/integrations/fiken/src/auth.ts
new file mode 100644
index 0000000000..8730c7ac55
--- /dev/null
+++ b/integrations/fiken/src/auth.ts
@@ -0,0 +1,159 @@
+import {
+ createApiServiceError,
+ createAxios,
+ normalizeOAuthTokenResponse,
+ requestAxiosData,
+ SlateAuth
+} from 'slates';
+import { z } from 'zod';
+import { FikenClient } from './lib/client';
+import { fikenApiError } from './lib/errors';
+
+export let authOutputSchema = z.object({
+ token: z.string(),
+ refreshToken: z.string().optional(),
+ expiresAt: z.string().optional(),
+ redirectUri: z.string().optional()
+});
+
+export type FikenAuthOutput = z.infer;
+
+let oauthHttp = createAxios({
+ baseURL: 'https://fiken.no'
+});
+
+let basicAuthHeader = (clientId: string, clientSecret: string) =>
+ `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
+
+let requestToken = async (
+ operation: string,
+ clientId: string,
+ clientSecret: string,
+ params: URLSearchParams
+) =>
+ requestAxiosData>(
+ operation,
+ () =>
+ oauthHttp.post('/oauth/token', params.toString(), {
+ headers: {
+ Authorization: basicAuthHeader(clientId, clientSecret),
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ }),
+ fikenApiError
+ );
+
+export let auth = SlateAuth.create()
+ .output(authOutputSchema)
+ .addOauth({
+ type: 'auth.oauth',
+ name: 'Fiken OAuth2',
+ key: 'oauth2',
+ docs: [
+ {
+ type: 'docs.auth.oauth',
+ name: 'Fiken API OAuth documentation',
+ url: 'https://api.fiken.no/api/v2/docs/'
+ }
+ ],
+ scopes: [],
+
+ getAuthorizationUrl: async ctx => {
+ let params = new URLSearchParams({
+ response_type: 'code',
+ client_id: ctx.clientId,
+ redirect_uri: ctx.redirectUri,
+ state: ctx.state
+ });
+
+ return {
+ url: `https://fiken.no/oauth/authorize?${params.toString()}`
+ };
+ },
+
+ handleCallback: async ctx => {
+ let data = await requestToken(
+ 'OAuth token exchange',
+ ctx.clientId,
+ ctx.clientSecret,
+ new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: ctx.code,
+ redirect_uri: ctx.redirectUri,
+ state: ctx.state
+ })
+ );
+
+ let token = normalizeOAuthTokenResponse(data, {
+ providerLabel: 'Fiken',
+ operation: 'token exchange',
+ accessTokenMessage: 'Fiken OAuth token exchange did not return an access token.'
+ });
+
+ return {
+ output: {
+ token: token.token,
+ refreshToken: token.refreshToken,
+ expiresAt: token.expiresAt,
+ redirectUri: ctx.redirectUri
+ }
+ };
+ },
+
+ handleTokenRefresh: async (ctx: {
+ clientId: string;
+ clientSecret: string;
+ output: FikenAuthOutput;
+ }) => {
+ if (!ctx.output.refreshToken) {
+ throw createApiServiceError('No Fiken refresh token is available.', {
+ reason: 'fiken_missing_refresh_token'
+ });
+ }
+
+ let data = await requestToken(
+ 'OAuth token refresh',
+ ctx.clientId,
+ ctx.clientSecret,
+ new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: ctx.output.refreshToken
+ })
+ );
+
+ let token = normalizeOAuthTokenResponse(data, {
+ providerLabel: 'Fiken',
+ operation: 'token refresh',
+ previousRefreshToken: ctx.output.refreshToken,
+ accessTokenMessage: 'Fiken OAuth refresh did not return an access token.'
+ });
+
+ return {
+ output: {
+ token: token.token,
+ refreshToken: token.refreshToken,
+ expiresAt: token.expiresAt,
+ redirectUri: ctx.output.redirectUri
+ }
+ };
+ },
+
+ getProfile: async (ctx: { output: FikenAuthOutput }) => {
+ let client = new FikenClient(ctx.output);
+ let user = await client.getUser();
+ let companies = await client.listCompanies({ page: 0, pageSize: 1 });
+ let email = typeof user.email === 'string' ? user.email : undefined;
+ let name = typeof user.name === 'string' ? user.name : (email ?? 'Fiken user');
+
+ return {
+ profile: {
+ id: email ?? name,
+ name,
+ email,
+ metadata: {
+ accessibleCompanyCount: companies.resultCount ?? companies.items.length
+ }
+ }
+ };
+ }
+ });
diff --git a/integrations/fiken/src/config.ts b/integrations/fiken/src/config.ts
new file mode 100644
index 0000000000..37e96e830b
--- /dev/null
+++ b/integrations/fiken/src/config.ts
@@ -0,0 +1,13 @@
+import { SlateConfig } from 'slates';
+import { z } from 'zod';
+
+export let configSchema = z.object({
+ defaultCompanySlug: z
+ .string()
+ .optional()
+ .describe('Default Fiken company slug for company-scoped tools.')
+});
+
+export type FikenConfig = z.infer;
+
+export let config = SlateConfig.create(configSchema);
diff --git a/integrations/fiken/src/index.ts b/integrations/fiken/src/index.ts
new file mode 100644
index 0000000000..b59f544085
--- /dev/null
+++ b/integrations/fiken/src/index.ts
@@ -0,0 +1,59 @@
+import { Slate } from 'slates';
+import { spec } from './spec';
+import {
+ createContact,
+ createInvoiceDraft,
+ createProduct,
+ createProject,
+ getAccount,
+ getAccountBalance,
+ getCompany,
+ getContact,
+ getInvoice,
+ getInvoiceDraft,
+ getProduct,
+ getProject,
+ getPurchase,
+ getSale,
+ listAccountBalances,
+ listAccounts,
+ listCompanies,
+ listContacts,
+ listInvoiceDrafts,
+ listInvoices,
+ listProducts,
+ listProjects,
+ listPurchases,
+ listSales
+} from './tools';
+
+export let provider = Slate.create({
+ spec,
+ tools: [
+ listCompanies,
+ getCompany,
+ listContacts,
+ getContact,
+ createContact,
+ listInvoices,
+ getInvoice,
+ listInvoiceDrafts,
+ getInvoiceDraft,
+ createInvoiceDraft,
+ listProducts,
+ getProduct,
+ createProduct,
+ listProjects,
+ getProject,
+ createProject,
+ listPurchases,
+ getPurchase,
+ listSales,
+ getSale,
+ listAccounts,
+ getAccount,
+ listAccountBalances,
+ getAccountBalance
+ ],
+ triggers: []
+});
diff --git a/integrations/fiken/src/lib/client.ts b/integrations/fiken/src/lib/client.ts
new file mode 100644
index 0000000000..53052899e9
--- /dev/null
+++ b/integrations/fiken/src/lib/client.ts
@@ -0,0 +1,366 @@
+import { randomUUID } from 'node:crypto';
+import {
+ createAuthenticatedAxios,
+ getResponseHeaderValue,
+ pickDefined,
+ requestAxios,
+ requestAxiosData
+} from 'slates';
+import type { FikenAuthOutput } from '../auth';
+import { fikenApiError, fikenValidationError } from './errors';
+
+export const FIKEN_API_BASE_URL = 'https://api.fiken.no/api/v2';
+
+export type FikenPagination = {
+ page?: number;
+ pageSize?: number;
+ pageCount?: number;
+ resultCount?: number;
+ nextPage?: number;
+};
+
+export type FikenListResponse = FikenPagination & {
+ items: T[];
+};
+
+let wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+let requestHeaders = () => ({
+ 'X-Request-ID': randomUUID()
+});
+
+let serializeParams = (params: Record) => {
+ let search = new URLSearchParams();
+
+ for (let [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null || value === '') continue;
+
+ if (Array.isArray(value)) {
+ for (let item of value) {
+ if (item !== undefined && item !== null && item !== '') {
+ search.append(key, String(item));
+ }
+ }
+ continue;
+ }
+
+ search.append(key, String(value));
+ }
+
+ return search.toString();
+};
+
+let numberHeader = (headers: unknown, name: string) => {
+ let value = getResponseHeaderValue(headers, name);
+ if (value === undefined || value === '') return undefined;
+ let parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+};
+
+let normalizeList = (
+ data: unknown,
+ headers: unknown,
+ requested: { page?: number; pageSize?: number }
+): FikenListResponse => {
+ if (!Array.isArray(data)) {
+ throw fikenValidationError('Fiken returned an unexpected non-list response.');
+ }
+
+ let page = numberHeader(headers, 'Fiken-Api-Page') ?? requested.page;
+ let pageSize = numberHeader(headers, 'Fiken-Api-Page-Size') ?? requested.pageSize;
+ let pageCount = numberHeader(headers, 'Fiken-Api-Page-Count');
+ let resultCount = numberHeader(headers, 'Fiken-Api-Result-Count');
+ let nextPage =
+ page !== undefined && pageCount !== undefined && page + 1 < pageCount
+ ? page + 1
+ : page !== undefined &&
+ pageCount === undefined &&
+ pageSize !== undefined &&
+ data.length >= pageSize
+ ? page + 1
+ : undefined;
+
+ return {
+ items: data as T[],
+ page,
+ pageSize,
+ pageCount,
+ resultCount,
+ nextPage
+ };
+};
+
+let locationPath = (location: string | undefined) => {
+ if (!location) return undefined;
+
+ try {
+ let url = new URL(location, FIKEN_API_BASE_URL);
+ return url.pathname.replace(/^\/api\/v2/, '') || undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+let lastLocationId = (location: string | undefined) => {
+ let path = locationPath(location);
+ let last = path?.split('/').filter(Boolean).at(-1);
+ if (!last) return undefined;
+ let parsed = Number(last);
+ return Number.isFinite(parsed) ? parsed : undefined;
+};
+
+let withPagination = (params: Record) =>
+ pickDefined({
+ ...params,
+ page: params.page ?? 0,
+ pageSize: params.pageSize ?? 25
+ });
+
+export class FikenClient {
+ private http: ReturnType;
+ private queue = Promise.resolve();
+ private lastRequestAt = 0;
+
+ constructor(auth: FikenAuthOutput) {
+ this.http = createAuthenticatedAxios({
+ baseURL: FIKEN_API_BASE_URL,
+ authHeader: {
+ value: `Bearer ${auth.token}`
+ },
+ headers: {
+ Accept: 'application/json'
+ },
+ paramsSerializer: { serialize: serializeParams },
+ errorAdapter: error => fikenApiError(error)
+ });
+ }
+
+ private async enqueue(run: () => Promise) {
+ let execute = async () => {
+ let elapsed = Date.now() - this.lastRequestAt;
+ if (this.lastRequestAt > 0 && elapsed < 275) {
+ await wait(275 - elapsed);
+ }
+
+ try {
+ return await run();
+ } finally {
+ this.lastRequestAt = Date.now();
+ }
+ };
+
+ let next = this.queue.then(execute, execute);
+ this.queue = next.then(
+ () => undefined,
+ () => undefined
+ );
+ return next;
+ }
+
+ private async getList(path: string, params: Record = {}) {
+ let query = withPagination(params);
+ let response = await this.enqueue(() =>
+ requestAxios(
+ `list ${path}`,
+ () => this.http.get(path, { params: query, headers: requestHeaders() }),
+ fikenApiError
+ )
+ );
+
+ return normalizeList(response.data, response.headers, {
+ page: typeof query.page === 'number' ? query.page : undefined,
+ pageSize: typeof query.pageSize === 'number' ? query.pageSize : undefined
+ });
+ }
+
+ private async getValue(path: string, params: Record = {}) {
+ return await this.enqueue(() =>
+ requestAxiosData(
+ `get ${path}`,
+ () => this.http.get(path, { params, headers: requestHeaders() }),
+ fikenApiError
+ )
+ );
+ }
+
+ private async postAndFetch(path: string, body: Record) {
+ let response = await this.enqueue(() =>
+ requestAxios(
+ `create ${path}`,
+ () => this.http.post(path, body, { headers: requestHeaders() }),
+ fikenApiError
+ )
+ );
+
+ let location = getResponseHeaderValue(response.headers, 'Location');
+ let createdPath = locationPath(location);
+ let createdId = lastLocationId(location);
+ let record = createdPath ? await this.getValue(createdPath) : undefined;
+
+ return {
+ id: createdId,
+ location,
+ record
+ };
+ }
+
+ async getUser() {
+ return await this.getValue>('/user');
+ }
+
+ async listCompanies(params: Record) {
+ return await this.getList>('/companies', params);
+ }
+
+ async getCompany(companySlug: string) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}`
+ );
+ }
+
+ async listContacts(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/contacts`,
+ params
+ );
+ }
+
+ async getContact(companySlug: string, contactId: number) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/contacts/${contactId}`
+ );
+ }
+
+ async createContact(companySlug: string, body: Record) {
+ return await this.postAndFetch>(
+ `/companies/${encodeURIComponent(companySlug)}/contacts`,
+ body
+ );
+ }
+
+ async listInvoices(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/invoices`,
+ params
+ );
+ }
+
+ async getInvoice(companySlug: string, invoiceId: number) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/invoices/${invoiceId}`
+ );
+ }
+
+ async listInvoiceDrafts(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/invoices/drafts`,
+ params
+ );
+ }
+
+ async getInvoiceDraft(companySlug: string, draftId: number) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/invoices/drafts/${draftId}`
+ );
+ }
+
+ async createInvoiceDraft(companySlug: string, body: Record) {
+ return await this.postAndFetch>(
+ `/companies/${encodeURIComponent(companySlug)}/invoices/drafts`,
+ body
+ );
+ }
+
+ async listProducts(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/products`,
+ params
+ );
+ }
+
+ async getProduct(companySlug: string, productId: number) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/products/${productId}`
+ );
+ }
+
+ async createProduct(companySlug: string, body: Record) {
+ return await this.postAndFetch>(
+ `/companies/${encodeURIComponent(companySlug)}/products`,
+ body
+ );
+ }
+
+ async listProjects(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/projects`,
+ params
+ );
+ }
+
+ async getProject(companySlug: string, projectId: number) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/projects/${projectId}`
+ );
+ }
+
+ async createProject(companySlug: string, body: Record) {
+ return await this.postAndFetch>(
+ `/companies/${encodeURIComponent(companySlug)}/projects`,
+ body
+ );
+ }
+
+ async listPurchases(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/purchases`,
+ params
+ );
+ }
+
+ async getPurchase(companySlug: string, purchaseId: number) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/purchases/${purchaseId}`
+ );
+ }
+
+ async listSales(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/sales`,
+ params
+ );
+ }
+
+ async getSale(companySlug: string, saleId: number) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/sales/${saleId}`
+ );
+ }
+
+ async listAccounts(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/accounts`,
+ params
+ );
+ }
+
+ async getAccount(companySlug: string, accountCode: string) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/accounts/${encodeURIComponent(accountCode)}`
+ );
+ }
+
+ async listAccountBalances(companySlug: string, params: Record) {
+ return await this.getList>(
+ `/companies/${encodeURIComponent(companySlug)}/accountBalances`,
+ params
+ );
+ }
+
+ async getAccountBalance(companySlug: string, accountCode: string, date: string) {
+ return await this.getValue>(
+ `/companies/${encodeURIComponent(companySlug)}/accountBalances/${encodeURIComponent(accountCode)}`,
+ { date }
+ );
+ }
+}
diff --git a/integrations/fiken/src/lib/errors.ts b/integrations/fiken/src/lib/errors.ts
new file mode 100644
index 0000000000..32a81344ab
--- /dev/null
+++ b/integrations/fiken/src/lib/errors.ts
@@ -0,0 +1,38 @@
+import {
+ buildApiServiceError,
+ collectApiErrorDetails,
+ createApiServiceError,
+ getApiErrorResponse,
+ isApiErrorRecord
+} from 'slates';
+
+export let fikenValidationError = (message: string) =>
+ createApiServiceError(message, { reason: 'fiken_validation_error' });
+
+let collectFikenDetails = (value: unknown, details: string[]) => {
+ collectApiErrorDetails(value, details, {
+ detailKeys: ['message', 'error_description', 'error', 'field', 'code', 'requestId'],
+ nestedKeys: ['errors', 'validationMessages', 'details'],
+ includeNumbers: true
+ });
+};
+
+export let fikenApiError = (error: unknown, operation = 'request') =>
+ buildApiServiceError(error, {
+ providerLabel: 'Fiken',
+ reason: 'fiken_api_error',
+ operation,
+ extractMessage: currentError => {
+ let response = getApiErrorResponse(currentError);
+ let details: string[] = [];
+ collectFikenDetails(response?.data, details);
+ collectFikenDetails(currentError, details);
+
+ return details.length > 0 ? details.join(' - ') : undefined;
+ },
+ extractUpstreamCode: (_currentError, response) => {
+ if (!isApiErrorRecord(response?.data)) return undefined;
+ let code = response.data.code ?? response.data.error;
+ return code === undefined ? undefined : String(code);
+ }
+ });
diff --git a/integrations/fiken/src/spec.ts b/integrations/fiken/src/spec.ts
new file mode 100644
index 0000000000..d7e1067494
--- /dev/null
+++ b/integrations/fiken/src/spec.ts
@@ -0,0 +1,13 @@
+import { SlateSpecification } from 'slates';
+import { auth } from './auth';
+import { config } from './config';
+
+export let spec = SlateSpecification.create({
+ key: 'fiken',
+ name: 'Fiken',
+ description:
+ 'Access Fiken accounting data including companies, contacts, invoices, invoice drafts, products, projects, purchases, sales, accounts, and account balances.',
+ metadata: {},
+ config,
+ auth
+});
diff --git a/integrations/fiken/src/tools.schema.test.ts b/integrations/fiken/src/tools.schema.test.ts
new file mode 100644
index 0000000000..468ffb92d9
--- /dev/null
+++ b/integrations/fiken/src/tools.schema.test.ts
@@ -0,0 +1,4 @@
+import { describeMcpCompatibleToolSchemas } from '@slates/test';
+import { provider } from './index';
+
+describeMcpCompatibleToolSchemas('Fiken tool input schemas', provider.actions);
diff --git a/integrations/fiken/src/tools/accounting.test.ts b/integrations/fiken/src/tools/accounting.test.ts
new file mode 100644
index 0000000000..c3264803e4
--- /dev/null
+++ b/integrations/fiken/src/tools/accounting.test.ts
@@ -0,0 +1,484 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let axiosMocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn()
+ },
+ createAuthenticatedAxios: vi.fn()
+}));
+
+vi.mock('slates', async importOriginal => {
+ let actual = await importOriginal();
+
+ return {
+ ...actual,
+ createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios
+ };
+});
+
+import {
+ detailedPurchaseSchema,
+ detailedSaleSchema,
+ getAccountBalance,
+ getSale,
+ listPurchases,
+ listSales,
+ mapDetailedPurchase,
+ mapDetailedSale
+} from './accounting';
+
+let invokeListPurchases = (input: Record) =>
+ listPurchases.handleInvocation({
+ auth: { token: 'token' },
+ config: {},
+ input
+ } as any);
+
+let invokeListSales = (input: Record) =>
+ listSales.handleInvocation({
+ auth: { token: 'token' },
+ config: {},
+ input
+ } as any);
+
+let invokeGetSale = (input: Record) =>
+ getSale.handleInvocation({
+ auth: { token: 'token' },
+ config: {},
+ input
+ } as any);
+
+let invokeGetAccountBalance = (input: Record) =>
+ getAccountBalance.handleInvocation({
+ auth: { token: 'token' },
+ config: {},
+ input
+ } as any);
+
+beforeEach(() => {
+ axiosMocks.api.get.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReturnValue(axiosMocks.api);
+});
+
+describe('list_purchases', () => {
+ it('sends documented strict date filters to Fiken', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: [],
+ headers: {
+ 'Fiken-Api-Page': '0',
+ 'Fiken-Api-Page-Size': '10',
+ 'Fiken-Api-Page-Count': '1',
+ 'Fiken-Api-Result-Count': '0'
+ }
+ });
+
+ await invokeListPurchases({
+ companySlug: 'demo-company',
+ page: 0,
+ pageSize: 10,
+ dateAfter: '2024-01-01',
+ dateBefore: '2024-02-01',
+ settledDateAfter: '2024-03-01',
+ settledDateBefore: '2024-04-01',
+ sortBy: 'date desc'
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies/demo-company/purchases',
+ expect.objectContaining({
+ params: expect.objectContaining({
+ page: 0,
+ pageSize: 10,
+ dateGt: '2024-01-01',
+ dateLt: '2024-02-01',
+ settledDateGt: '2024-03-01',
+ settledDateLt: '2024-04-01',
+ sortBy: 'date desc'
+ }),
+ headers: {
+ 'X-Request-ID': expect.any(String)
+ }
+ })
+ );
+ });
+});
+
+describe('list_sales', () => {
+ it('sends documented strict date filters to Fiken', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: [],
+ headers: {
+ 'Fiken-Api-Page': '0',
+ 'Fiken-Api-Page-Size': '10',
+ 'Fiken-Api-Page-Count': '1',
+ 'Fiken-Api-Result-Count': '0'
+ }
+ });
+
+ await invokeListSales({
+ companySlug: 'demo-company',
+ page: 0,
+ pageSize: 10,
+ dateAfter: '2024-01-01',
+ dateBefore: '2024-02-01',
+ lastModifiedAfter: '2024-03-01',
+ lastModifiedBefore: '2024-04-01'
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies/demo-company/sales',
+ expect.objectContaining({
+ params: expect.objectContaining({
+ page: 0,
+ pageSize: 10,
+ dateGt: '2024-01-01',
+ dateLt: '2024-02-01',
+ lastModifiedGt: '2024-03-01',
+ lastModifiedLt: '2024-04-01'
+ }),
+ headers: {
+ 'X-Request-ID': expect.any(String)
+ }
+ })
+ );
+ });
+});
+
+describe('get_sale', () => {
+ it('requests the documented sale endpoint without query parameters', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ saleId: 2888156,
+ saleNumber: 'XK455L',
+ lines: [],
+ salePayments: [],
+ saleAttachments: [],
+ notes: []
+ },
+ headers: {}
+ });
+
+ await invokeGetSale({
+ companySlug: 'demo-company',
+ saleId: 2888156
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies/demo-company/sales/2888156',
+ expect.objectContaining({
+ params: {},
+ headers: {
+ 'X-Request-ID': expect.any(String)
+ }
+ })
+ );
+ });
+});
+
+describe('get_account_balance', () => {
+ it('requests the documented account balance endpoint with required date query', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ code: '1500:10001',
+ name: 'Acme AS',
+ balance: 15200
+ },
+ headers: {}
+ });
+
+ let result = await invokeGetAccountBalance({
+ companySlug: 'demo-company',
+ accountCode: '1500:10001',
+ date: '2024-12-31'
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/companies/demo-company/accountBalances/1500%3A10001',
+ expect.objectContaining({
+ params: {
+ date: '2024-12-31'
+ },
+ headers: {
+ 'X-Request-ID': expect.any(String)
+ }
+ })
+ );
+ expect(result.output.balance).toMatchObject({
+ code: '1500:10001',
+ name: 'Acme AS',
+ balance: 15200
+ });
+ });
+
+ it('rejects an invalid date before calling Fiken', async () => {
+ await expect(
+ invokeGetAccountBalance({
+ companySlug: 'demo-company',
+ accountCode: '3020',
+ date: '2024-02-31'
+ })
+ ).rejects.toThrow('date must be a valid date formatted as YYYY-MM-DD.');
+
+ expect(axiosMocks.api.get).not.toHaveBeenCalled();
+ });
+});
+
+describe('mapDetailedPurchase', () => {
+ it('maps documented getPurchase purchaseResult detail fields', () => {
+ let purchase = detailedPurchaseSchema.parse(
+ mapDetailedPurchase({
+ purchaseId: 2888156,
+ transactionId: 3458156,
+ identifier: 'INV-123',
+ date: '2018-04-03',
+ dueDate: '2018-04-17',
+ kind: 'supplier',
+ paid: true,
+ settled: true,
+ settledDate: '2024-04-03',
+ currency: 'NOK',
+ paymentAccount: '1920:10001',
+ kid: '5855454756',
+ supplier: {
+ contactId: 2747365,
+ name: 'Fiken AS'
+ },
+ lines: [
+ {
+ lineId: 2888157,
+ description: 'Software subscription',
+ netPrice: 4500,
+ vat: 500,
+ account: '6550',
+ vatType: 'HIGH',
+ netPriceInCurrency: 4500,
+ vatInCurrency: 500,
+ projectId: 2815556
+ }
+ ],
+ payments: [
+ {
+ paymentId: 2888158,
+ date: '2018-04-03',
+ account: '1920:10001',
+ amount: 5000,
+ amountInNok: 5000,
+ currency: 'NOK',
+ fee: 25
+ }
+ ],
+ purchaseAttachments: [
+ {
+ identifier: '24760',
+ downloadUrl: 'https://api.fiken.no/api/v2/files/example.pdf',
+ downloadUrlWithFikenNormalUserCredentials: 'https://fiken.no/files/example.pdf',
+ comment: 'Receipt',
+ type: 'invoice'
+ }
+ ],
+ project: [
+ {
+ projectId: 2815556,
+ number: 'P-1',
+ name: 'Implementation',
+ description: 'Client work',
+ startDate: '2018-04-03',
+ endDate: '2018-04-17',
+ contact: {
+ contactId: 2747365,
+ name: 'Fiken AS'
+ },
+ completed: false
+ }
+ ],
+ deleted: false
+ })
+ );
+
+ expect(purchase).toMatchObject({
+ purchaseId: 2888156,
+ supplierId: 2747365,
+ supplierName: 'Fiken AS',
+ paymentAccount: '1920:10001',
+ kid: '5855454756',
+ lineCount: 1,
+ paymentCount: 1,
+ attachmentCount: 1,
+ lines: [
+ {
+ lineId: 2888157,
+ description: 'Software subscription',
+ account: '6550',
+ vatType: 'HIGH',
+ projectId: 2815556
+ }
+ ],
+ payments: [
+ {
+ paymentId: 2888158,
+ account: '1920:10001',
+ amount: 5000,
+ amountInNok: 5000,
+ fee: 25
+ }
+ ],
+ attachments: [
+ {
+ identifier: '24760',
+ comment: 'Receipt',
+ type: 'invoice'
+ }
+ ],
+ projects: [
+ {
+ projectId: 2815556,
+ number: 'P-1',
+ name: 'Implementation',
+ contactId: 2747365,
+ contactName: 'Fiken AS',
+ completed: false
+ }
+ ]
+ });
+ });
+});
+
+describe('mapDetailedSale', () => {
+ it('maps documented getSale saleResult detail fields', () => {
+ let sale = detailedSaleSchema.parse(
+ mapDetailedSale({
+ saleId: 2888156,
+ lastModifiedDate: '2018-04-03',
+ transactionId: 3458156,
+ saleNumber: 'XK455L',
+ date: '2018-04-03',
+ dueDate: '2018-04-17',
+ kind: 'external_invoice',
+ netAmount: 4500,
+ vatAmount: 5400,
+ settled: true,
+ settledDate: '2023-04-03',
+ writeOff: false,
+ totalPaid: 524500,
+ totalPaidInCurrency: 634550,
+ outstandingBalance: 145,
+ outstandingBalanceInCurrency: 65,
+ currency: 'NOK',
+ kid: '5855454756',
+ paymentAccount: '1920:10001',
+ paymentDate: '2018-04-03',
+ customer: {
+ contactId: 2747365,
+ name: 'Fiken AS'
+ },
+ lines: [
+ {
+ lineId: 2888157,
+ description: 'Consulting',
+ netPrice: 4500,
+ vat: 500,
+ account: '3000',
+ vatType: 'HIGH',
+ netPriceInCurrency: 4500,
+ vatInCurrency: 500
+ }
+ ],
+ salePayments: [
+ {
+ paymentId: 2888158,
+ date: '2018-04-03',
+ account: '1920:10001',
+ amount: 5000,
+ amountInNok: 5000,
+ currency: 'NOK',
+ fee: 25
+ }
+ ],
+ saleAttachments: [
+ {
+ identifier: '24760',
+ downloadUrl: 'https://api.fiken.no/api/v2/files/example.pdf',
+ downloadUrlWithFikenNormalUserCredentials: 'https://fiken.no/files/example.pdf',
+ comment: 'Invoice',
+ type: 'invoice'
+ }
+ ],
+ project: {
+ projectId: 2815556,
+ number: 'P-1',
+ name: 'Implementation',
+ description: 'Client work',
+ startDate: '2018-04-03',
+ endDate: '2018-04-17',
+ contact: {
+ contactId: 2747365,
+ name: 'Fiken AS'
+ },
+ completed: false
+ },
+ notes: [
+ {
+ author: 'James Jones',
+ note: 'Invoice sent after telephone conversation with customer'
+ }
+ ],
+ deleted: false
+ })
+ );
+
+ expect(sale).toMatchObject({
+ saleId: 2888156,
+ lastModifiedDate: '2018-04-03',
+ saleNumber: 'XK455L',
+ customerId: 2747365,
+ customerName: 'Fiken AS',
+ totalPaidInCurrency: 634550,
+ outstandingBalanceInCurrency: 65,
+ kid: '5855454756',
+ paymentAccount: '1920:10001',
+ paymentDate: '2018-04-03',
+ lineCount: 1,
+ paymentCount: 1,
+ attachmentCount: 1,
+ lines: [
+ {
+ lineId: 2888157,
+ description: 'Consulting',
+ account: '3000',
+ vatType: 'HIGH'
+ }
+ ],
+ payments: [
+ {
+ paymentId: 2888158,
+ account: '1920:10001',
+ amount: 5000,
+ amountInNok: 5000,
+ fee: 25
+ }
+ ],
+ attachments: [
+ {
+ identifier: '24760',
+ comment: 'Invoice',
+ type: 'invoice'
+ }
+ ],
+ project: {
+ projectId: 2815556,
+ number: 'P-1',
+ name: 'Implementation',
+ contactId: 2747365,
+ contactName: 'Fiken AS',
+ completed: false
+ },
+ notes: [
+ {
+ author: 'James Jones',
+ note: 'Invoice sent after telephone conversation with customer'
+ }
+ ]
+ });
+ });
+});
diff --git a/integrations/fiken/src/tools/accounting.ts b/integrations/fiken/src/tools/accounting.ts
new file mode 100644
index 0000000000..f77f2a7762
--- /dev/null
+++ b/integrations/fiken/src/tools/accounting.ts
@@ -0,0 +1,663 @@
+import { pickDefined, SlateTool } from 'slates';
+import { z } from 'zod';
+import { fikenValidationError } from '../lib/errors';
+import { spec } from '../spec';
+import {
+ accountBalanceSchema,
+ accountSchema,
+ asArray,
+ asBoolean,
+ asNumber,
+ asRecord,
+ asString,
+ attachmentMetadataSchema,
+ companySlugFor,
+ companySlugInput,
+ contactNoteSchema,
+ createClient,
+ listMetadata,
+ mapAccount,
+ mapAccountBalance,
+ mapAttachment,
+ mapProject,
+ mapPurchase,
+ mapSale,
+ paginationInputShape,
+ paginationOutputShape,
+ paginationParams,
+ projectSchema,
+ purchaseSchema,
+ rawRecordSchema,
+ saleSchema
+} from './shared';
+
+let purchaseSortSchema = z.enum(['date asc', 'date desc']);
+
+let accountCodePattern = /^\d{4}(?::\d{5})?$/;
+
+let isValidDate = (value: string) => {
+ let match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+ if (!match) return false;
+
+ let year = Number(match[1]);
+ let month = Number(match[2]);
+ let day = Number(match[3]);
+ let date = new Date(Date.UTC(year, month - 1, day));
+
+ return (
+ date.getUTCFullYear() === year &&
+ date.getUTCMonth() === month - 1 &&
+ date.getUTCDate() === day
+ );
+};
+
+let validateAccountBalanceRequest = (input: { accountCode: string; date: string }) => {
+ if (!accountCodePattern.test(input.accountCode)) {
+ throw fikenValidationError(
+ 'accountCode must be four digits, or four digits followed by a colon and five digits, for example 3020 or 1500:10001.'
+ );
+ }
+
+ if (!isValidDate(input.date)) {
+ throw fikenValidationError('date must be a valid date formatted as YYYY-MM-DD.');
+ }
+};
+
+export let purchaseLineSchema = z.object({
+ lineId: z.number().optional(),
+ description: z.string().optional(),
+ netPrice: z.number().optional(),
+ vat: z.number().optional(),
+ account: z.string().optional(),
+ vatType: z.string().optional(),
+ netPriceInCurrency: z.number().optional(),
+ vatInCurrency: z.number().optional(),
+ projectId: z.number().optional(),
+ raw: rawRecordSchema
+});
+
+export let purchasePaymentSchema = z.object({
+ paymentId: z.number().optional(),
+ date: z.string().optional(),
+ account: z.string().optional(),
+ amount: z.number().optional(),
+ amountInNok: z.number().optional(),
+ currency: z.string().optional(),
+ fee: z.number().optional(),
+ raw: rawRecordSchema
+});
+
+export let detailedPurchaseSchema = purchaseSchema.extend({
+ paymentAccount: z.string().optional(),
+ kid: z.string().optional(),
+ lines: z.array(purchaseLineSchema),
+ payments: z.array(purchasePaymentSchema),
+ attachments: z.array(attachmentMetadataSchema),
+ projects: z.array(projectSchema)
+});
+
+let mapPurchaseLine = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ lineId: asNumber(record.lineId),
+ description: asString(record.description),
+ netPrice: asNumber(record.netPrice),
+ vat: asNumber(record.vat),
+ account: asString(record.account),
+ vatType: asString(record.vatType),
+ netPriceInCurrency: asNumber(record.netPriceInCurrency),
+ vatInCurrency: asNumber(record.vatInCurrency),
+ projectId: asNumber(record.projectId),
+ raw: record
+ };
+};
+
+let mapPurchasePayment = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ paymentId: asNumber(record.paymentId),
+ date: asString(record.date),
+ account: asString(record.account),
+ amount: asNumber(record.amount),
+ amountInNok: asNumber(record.amountInNok),
+ currency: asString(record.currency),
+ fee: asNumber(record.fee),
+ raw: record
+ };
+};
+
+export let mapDetailedPurchase = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ let lines = asArray(record.lines).map(mapPurchaseLine);
+ let payments = asArray(record.payments).map(mapPurchasePayment);
+ let attachments = asArray(record.purchaseAttachments).map(mapAttachment);
+ let projects = asArray(record.project).map(mapProject);
+
+ return {
+ ...mapPurchase(value),
+ paymentAccount: asString(record.paymentAccount),
+ kid: asString(record.kid),
+ lines,
+ payments,
+ attachments,
+ projects,
+ lineCount: lines.length,
+ paymentCount: payments.length,
+ attachmentCount: attachments.length,
+ deleted: asBoolean(record.deleted),
+ raw: record
+ };
+};
+
+export let saleLineSchema = z.object({
+ lineId: z.number().optional(),
+ description: z.string().optional(),
+ netPrice: z.number().optional(),
+ vat: z.number().optional(),
+ account: z.string().optional(),
+ vatType: z.string().optional(),
+ netPriceInCurrency: z.number().optional(),
+ vatInCurrency: z.number().optional(),
+ projectId: z.number().optional(),
+ raw: rawRecordSchema
+});
+
+export let salePaymentSchema = z.object({
+ paymentId: z.number().optional(),
+ date: z.string().optional(),
+ account: z.string().optional(),
+ amount: z.number().optional(),
+ amountInNok: z.number().optional(),
+ currency: z.string().optional(),
+ fee: z.number().optional(),
+ raw: rawRecordSchema
+});
+
+export let detailedSaleSchema = saleSchema.extend({
+ lastModifiedDate: z.string().optional(),
+ totalPaidInCurrency: z.number().optional(),
+ outstandingBalanceInCurrency: z.number().optional(),
+ kid: z.string().optional(),
+ paymentAccount: z.string().optional(),
+ paymentDate: z.string().optional(),
+ lines: z.array(saleLineSchema),
+ payments: z.array(salePaymentSchema),
+ attachments: z.array(attachmentMetadataSchema),
+ project: projectSchema.optional(),
+ notes: z.array(contactNoteSchema)
+});
+
+let mapSaleLine = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ lineId: asNumber(record.lineId),
+ description: asString(record.description),
+ netPrice: asNumber(record.netPrice),
+ vat: asNumber(record.vat),
+ account: asString(record.account),
+ vatType: asString(record.vatType),
+ netPriceInCurrency: asNumber(record.netPriceInCurrency),
+ vatInCurrency: asNumber(record.vatInCurrency),
+ projectId: asNumber(record.projectId),
+ raw: record
+ };
+};
+
+let mapSalePayment = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ paymentId: asNumber(record.paymentId),
+ date: asString(record.date),
+ account: asString(record.account),
+ amount: asNumber(record.amount),
+ amountInNok: asNumber(record.amountInNok),
+ currency: asString(record.currency),
+ fee: asNumber(record.fee),
+ raw: record
+ };
+};
+
+let mapSaleNote = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ author: asString(record.author),
+ note: asString(record.note)
+ };
+};
+
+export let mapDetailedSale = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ let lines = asArray(record.lines).map(mapSaleLine);
+ let payments = asArray(record.salePayments).map(mapSalePayment);
+ let attachments = asArray(record.saleAttachments).map(mapAttachment);
+ let projectRecord = asRecord(record.project);
+ let project = Object.keys(projectRecord).length > 0 ? mapProject(record.project) : undefined;
+ let notes = asArray(record.notes).map(mapSaleNote);
+
+ return {
+ ...mapSale(value),
+ lastModifiedDate: asString(record.lastModifiedDate),
+ totalPaidInCurrency: asNumber(record.totalPaidInCurrency),
+ outstandingBalanceInCurrency: asNumber(record.outstandingBalanceInCurrency),
+ kid: asString(record.kid),
+ paymentAccount: asString(record.paymentAccount),
+ paymentDate: asString(record.paymentDate),
+ lines,
+ payments,
+ attachments,
+ project,
+ notes,
+ lineCount: lines.length,
+ paymentCount: payments.length,
+ attachmentCount: attachments.length,
+ raw: record
+ };
+};
+
+export let listPurchases = SlateTool.create(spec, {
+ name: 'List Purchases',
+ key: 'list_purchases',
+ description:
+ 'Lists Fiken purchases for a company with filters for date, settled date, paid status, supplier/contact id, and sort order.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ contactId: z.number().int().positive().optional().describe('Supplier contact id.'),
+ paid: z.boolean().optional(),
+ date: z.string().optional().describe('Exact purchase date, YYYY-MM-DD.'),
+ dateFrom: z.string().optional().describe('Purchase date on or after this date.'),
+ dateTo: z.string().optional().describe('Purchase date on or before this date.'),
+ dateAfter: z.string().optional().describe('Purchase date strictly after this date.'),
+ dateBefore: z.string().optional().describe('Purchase date strictly before this date.'),
+ settledDate: z.string().optional().describe('Exact settled date, YYYY-MM-DD.'),
+ settledDateFrom: z.string().optional(),
+ settledDateTo: z.string().optional(),
+ settledDateAfter: z
+ .string()
+ .optional()
+ .describe('Settled date strictly after this date.'),
+ settledDateBefore: z
+ .string()
+ .optional()
+ .describe('Settled date strictly before this date.'),
+ sortBy: purchaseSortSchema.optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ purchases: z.array(purchaseSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listPurchases(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ contactId: ctx.input.contactId,
+ paid: ctx.input.paid,
+ date: ctx.input.date,
+ dateGe: ctx.input.dateFrom,
+ dateLe: ctx.input.dateTo,
+ dateGt: ctx.input.dateAfter,
+ dateLt: ctx.input.dateBefore,
+ settledDate: ctx.input.settledDate,
+ settledDateGe: ctx.input.settledDateFrom,
+ settledDateLe: ctx.input.settledDateTo,
+ settledDateGt: ctx.input.settledDateAfter,
+ settledDateLt: ctx.input.settledDateBefore,
+ sortBy: ctx.input.sortBy
+ })
+ );
+ let purchases = response.items.map(mapPurchase);
+
+ return {
+ output: {
+ companySlug,
+ purchases,
+ ...listMetadata(response)
+ },
+ message: `Found **${purchases.length}** Fiken purchase${purchases.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getPurchase = SlateTool.create(spec, {
+ name: 'Get Purchase',
+ key: 'get_purchase',
+ description: 'Retrieves one Fiken purchase by purchase id.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ purchaseId: z.number().int().positive().describe('Fiken purchase id.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ purchase: detailedPurchaseSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let purchase = mapDetailedPurchase(
+ await client.getPurchase(companySlug, ctx.input.purchaseId)
+ );
+
+ return {
+ output: {
+ companySlug,
+ purchase
+ },
+ message: `Retrieved Fiken purchase **${purchase.identifier ?? ctx.input.purchaseId}**.`
+ };
+ })
+ .build();
+
+export let listSales = SlateTool.create(spec, {
+ name: 'List Sales',
+ key: 'list_sales',
+ description:
+ 'Lists Fiken sales for a company with filters for date, last modified date, sale number, settled status, and customer/contact id.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ contactId: z.number().int().positive().optional().describe('Customer contact id.'),
+ saleNumber: z.string().optional(),
+ settled: z.boolean().optional(),
+ date: z.string().optional().describe('Exact sale date, YYYY-MM-DD.'),
+ dateFrom: z.string().optional().describe('Sale date on or after this date.'),
+ dateTo: z.string().optional().describe('Sale date on or before this date.'),
+ dateAfter: z.string().optional().describe('Sale date strictly after this date.'),
+ dateBefore: z.string().optional().describe('Sale date strictly before this date.'),
+ lastModified: z.string().optional().describe('Exact last modified date, YYYY-MM-DD.'),
+ lastModifiedFrom: z.string().optional().describe('Modified on or after this date.'),
+ lastModifiedTo: z.string().optional().describe('Modified on or before this date.'),
+ lastModifiedAfter: z.string().optional().describe('Modified strictly after this date.'),
+ lastModifiedBefore: z.string().optional().describe('Modified strictly before this date.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ sales: z.array(saleSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listSales(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ contactId: ctx.input.contactId,
+ saleNumber: ctx.input.saleNumber,
+ settled: ctx.input.settled,
+ date: ctx.input.date,
+ dateGe: ctx.input.dateFrom,
+ dateLe: ctx.input.dateTo,
+ dateGt: ctx.input.dateAfter,
+ dateLt: ctx.input.dateBefore,
+ lastModified: ctx.input.lastModified,
+ lastModifiedGe: ctx.input.lastModifiedFrom,
+ lastModifiedLe: ctx.input.lastModifiedTo,
+ lastModifiedGt: ctx.input.lastModifiedAfter,
+ lastModifiedLt: ctx.input.lastModifiedBefore
+ })
+ );
+ let sales = response.items.map(mapSale);
+
+ return {
+ output: {
+ companySlug,
+ sales,
+ ...listMetadata(response)
+ },
+ message: `Found **${sales.length}** Fiken sale${sales.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getSale = SlateTool.create(spec, {
+ name: 'Get Sale',
+ key: 'get_sale',
+ description: 'Retrieves one Fiken sale by sale id.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ saleId: z.number().int().positive().describe('Fiken sale id.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ sale: detailedSaleSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let sale = mapDetailedSale(await client.getSale(companySlug, ctx.input.saleId));
+
+ return {
+ output: {
+ companySlug,
+ sale
+ },
+ message: `Retrieved Fiken sale **${sale.saleNumber ?? ctx.input.saleId}**.`
+ };
+ })
+ .build();
+
+export let listAccounts = SlateTool.create(spec, {
+ name: 'List Accounts',
+ key: 'list_accounts',
+ description:
+ 'Lists Fiken bookkeeping accounts for the current year with optional account range filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ fromAccount: z.number().int().optional(),
+ toAccount: z.number().int().optional(),
+ range: z
+ .string()
+ .optional()
+ .describe('Comma-separated account numbers or ranges, for example 1000-1500,2000.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ accounts: z.array(accountSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listAccounts(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ fromAccount: ctx.input.fromAccount,
+ toAccount: ctx.input.toAccount,
+ range: ctx.input.range
+ })
+ );
+ let accounts = response.items.map(mapAccount);
+
+ return {
+ output: {
+ companySlug,
+ accounts,
+ ...listMetadata(response)
+ },
+ message: `Found **${accounts.length}** Fiken account${accounts.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getAccount = SlateTool.create(spec, {
+ name: 'Get Account',
+ key: 'get_account',
+ description: 'Retrieves one Fiken bookkeeping account by account code.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ accountCode: z.string().describe('Fiken account code, for example 3020 or 1500:10001.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ account: accountSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let account = mapAccount(await client.getAccount(companySlug, ctx.input.accountCode));
+
+ return {
+ output: {
+ companySlug,
+ account
+ },
+ message: `Retrieved Fiken account **${account.code ?? ctx.input.accountCode}**.`
+ };
+ })
+ .build();
+
+export let listAccountBalances = SlateTool.create(spec, {
+ name: 'List Account Balances',
+ key: 'list_account_balances',
+ description: 'Lists Fiken bookkeeping accounts and closing balances for a required date.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ date: z.string().describe('Balance date, YYYY-MM-DD.'),
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ fromAccount: z.number().int().optional(),
+ toAccount: z.number().int().optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ balances: z.array(accountBalanceSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listAccountBalances(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ date: ctx.input.date,
+ fromAccount: ctx.input.fromAccount,
+ toAccount: ctx.input.toAccount
+ })
+ );
+ let balances = response.items.map(mapAccountBalance);
+
+ return {
+ output: {
+ companySlug,
+ balances,
+ ...listMetadata(response)
+ },
+ message: `Found **${balances.length}** Fiken account balance${balances.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getAccountBalance = SlateTool.create(spec, {
+ name: 'Get Account Balance',
+ key: 'get_account_balance',
+ description: 'Retrieves one Fiken account and its closing balance for a required date.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ accountCode: z.string().describe('Fiken account code.'),
+ date: z.string().describe('Balance date, YYYY-MM-DD.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ balance: accountBalanceSchema.describe(
+ 'Fiken account balance. The balance amount is returned in cents/minor units.'
+ )
+ })
+ )
+ .handleInvocation(async ctx => {
+ validateAccountBalanceRequest(ctx.input);
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let balance = mapAccountBalance(
+ await client.getAccountBalance(companySlug, ctx.input.accountCode, ctx.input.date)
+ );
+
+ return {
+ output: {
+ companySlug,
+ balance
+ },
+ message: `Retrieved Fiken balance for account **${balance.code ?? ctx.input.accountCode}**.`
+ };
+ })
+ .build();
diff --git a/integrations/fiken/src/tools/catalog.ts b/integrations/fiken/src/tools/catalog.ts
new file mode 100644
index 0000000000..5fd8afe2a6
--- /dev/null
+++ b/integrations/fiken/src/tools/catalog.ts
@@ -0,0 +1,358 @@
+import { pickDefined, SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ cleanBody,
+ companySlugFor,
+ companySlugInput,
+ createClient,
+ listMetadata,
+ mapProduct,
+ mapProject,
+ paginationInputShape,
+ paginationOutputShape,
+ paginationParams,
+ productSchema,
+ projectSchema
+} from './shared';
+
+export let listProducts = SlateTool.create(spec, {
+ name: 'List Products',
+ key: 'list_products',
+ description:
+ 'Lists Fiken products for a company with filters for product name, product number, active status, and created/modified dates.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ name: z.string().optional().describe('Exact product name filter.'),
+ productNumber: z.string().optional(),
+ active: z.boolean().optional(),
+ createdDate: z.string().optional().describe('YYYY-MM-DD. Exact product creation date.'),
+ createdDateFrom: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products created on or after this date.'),
+ createdDateTo: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products created on or before this date.'),
+ createdDateAfter: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products created strictly after this date.'),
+ createdDateBefore: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products created strictly before this date.'),
+ lastModified: z.string().optional().describe('YYYY-MM-DD. Exact product modified date.'),
+ lastModifiedFrom: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products modified on or after this date.'),
+ lastModifiedTo: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products modified on or before this date.'),
+ lastModifiedAfter: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products modified strictly after this date.'),
+ lastModifiedBefore: z
+ .string()
+ .optional()
+ .describe('YYYY-MM-DD. Return products modified strictly before this date.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ products: z.array(productSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listProducts(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ name: ctx.input.name,
+ productNumber: ctx.input.productNumber,
+ active: ctx.input.active,
+ createdDate: ctx.input.createdDate,
+ createdDateGe: ctx.input.createdDateFrom,
+ createdDateLe: ctx.input.createdDateTo,
+ createdDateGt: ctx.input.createdDateAfter,
+ createdDateLt: ctx.input.createdDateBefore,
+ lastModified: ctx.input.lastModified,
+ lastModifiedGe: ctx.input.lastModifiedFrom,
+ lastModifiedLe: ctx.input.lastModifiedTo,
+ lastModifiedGt: ctx.input.lastModifiedAfter,
+ lastModifiedLt: ctx.input.lastModifiedBefore
+ })
+ );
+ let products = response.items.map(mapProduct);
+
+ return {
+ output: {
+ companySlug,
+ products,
+ ...listMetadata(response)
+ },
+ message: `Found **${products.length}** Fiken product${products.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getProduct = SlateTool.create(spec, {
+ name: 'Get Product',
+ key: 'get_product',
+ description: 'Retrieves one Fiken product by product id.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ productId: z.number().int().positive().describe('Fiken product id.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ product: productSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let product = mapProduct(await client.getProduct(companySlug, ctx.input.productId));
+
+ return {
+ output: {
+ companySlug,
+ product
+ },
+ message: `Retrieved Fiken product **${product.name ?? ctx.input.productId}**.`
+ };
+ })
+ .build();
+
+export let createProduct = SlateTool.create(spec, {
+ name: 'Create Product',
+ key: 'create_product',
+ description: 'Creates a Fiken product that can be reused on invoice, sale, and draft lines.',
+ tags: {
+ destructive: false,
+ readOnly: false
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ name: z.string().min(1).describe('Product name.'),
+ incomeAccount: z.string().describe('Income account, for example 3000.'),
+ vatType: z.string().describe('Fiken VAT type, for example HIGH or NONE.'),
+ active: z
+ .boolean()
+ .optional()
+ .describe('Whether the product is active. Defaults to true.'),
+ unitPrice: z.number().int().optional().describe('Net unit price in cents.'),
+ productNumber: z.string().optional(),
+ stock: z.number().optional(),
+ note: z.string().max(200).optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ productId: z.number().optional(),
+ location: z.string().optional(),
+ product: productSchema.optional()
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let created = await client.createProduct(
+ companySlug,
+ cleanBody({
+ name: ctx.input.name,
+ incomeAccount: ctx.input.incomeAccount,
+ vatType: ctx.input.vatType,
+ active: ctx.input.active ?? true,
+ unitPrice: ctx.input.unitPrice,
+ productNumber: ctx.input.productNumber,
+ stock: ctx.input.stock,
+ note: ctx.input.note
+ })
+ );
+ let product = created.record ? mapProduct(created.record) : undefined;
+
+ return {
+ output: {
+ companySlug,
+ productId: product?.productId ?? created.id,
+ location: created.location,
+ product
+ },
+ message: `Created Fiken product **${product?.name ?? ctx.input.name}**.`
+ };
+ })
+ .build();
+
+export let listProjects = SlateTool.create(spec, {
+ name: 'List Projects',
+ key: 'list_projects',
+ description:
+ 'Lists Fiken projects for a company with filters for completion status, name, and project number.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ completed: z.boolean().optional(),
+ name: z.string().optional(),
+ number: z.string().optional().describe('Project number.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ projects: z.array(projectSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listProjects(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ completed: ctx.input.completed,
+ name: ctx.input.name,
+ number: ctx.input.number
+ })
+ );
+ let projects = response.items.map(mapProject);
+
+ return {
+ output: {
+ companySlug,
+ projects,
+ ...listMetadata(response)
+ },
+ message: `Found **${projects.length}** Fiken project${projects.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getProject = SlateTool.create(spec, {
+ name: 'Get Project',
+ key: 'get_project',
+ description: 'Retrieves one Fiken project by project id.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ projectId: z.number().int().positive().describe('Fiken project id.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ project: projectSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let project = mapProject(await client.getProject(companySlug, ctx.input.projectId));
+
+ return {
+ output: {
+ companySlug,
+ project
+ },
+ message: `Retrieved Fiken project **${project.name ?? ctx.input.projectId}**.`
+ };
+ })
+ .build();
+
+export let createProject = SlateTool.create(spec, {
+ name: 'Create Project',
+ key: 'create_project',
+ description: 'Creates a Fiken project for project accounting and document association.',
+ tags: {
+ destructive: false,
+ readOnly: false
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ number: z.string().describe('Project number.'),
+ name: z.string().min(1).describe('Project name.'),
+ startDate: z.string().describe('Project start date, YYYY-MM-DD.'),
+ description: z.string().optional(),
+ endDate: z.string().optional().describe('Project end date, YYYY-MM-DD.'),
+ contactId: z.number().int().positive().optional(),
+ completed: z.boolean().optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ projectId: z.number().optional(),
+ location: z.string().optional(),
+ project: projectSchema.optional()
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let created = await client.createProject(
+ companySlug,
+ cleanBody({
+ number: ctx.input.number,
+ name: ctx.input.name,
+ startDate: ctx.input.startDate,
+ description: ctx.input.description,
+ endDate: ctx.input.endDate,
+ contactId: ctx.input.contactId,
+ completed: ctx.input.completed
+ })
+ );
+ let project = created.record ? mapProject(created.record) : undefined;
+
+ return {
+ output: {
+ companySlug,
+ projectId: project?.projectId ?? created.id,
+ location: created.location,
+ project
+ },
+ message: `Created Fiken project **${project?.name ?? ctx.input.name}**.`
+ };
+ })
+ .build();
diff --git a/integrations/fiken/src/tools/companies.ts b/integrations/fiken/src/tools/companies.ts
new file mode 100644
index 0000000000..963231bcb2
--- /dev/null
+++ b/integrations/fiken/src/tools/companies.ts
@@ -0,0 +1,98 @@
+import { pickDefined, SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ companySchema,
+ companySlugFor,
+ companySlugInput,
+ createClient,
+ listMetadata,
+ mapCompany,
+ paginationInputShape,
+ paginationOutputShape,
+ paginationParams
+} from './shared';
+
+let companySortSchema = z.enum([
+ 'createdDate asc',
+ 'createdDate desc',
+ 'name asc',
+ 'name desc',
+ 'organizationNumber asc',
+ 'organizationNumber desc'
+]);
+
+export let listCompanies = SlateTool.create(spec, {
+ name: 'List Companies',
+ key: 'list_companies',
+ description:
+ 'Lists Fiken companies that the authenticated user has granted this app access to. Use this before company-scoped tools when the company slug is unknown.',
+ constraints: [
+ 'Fiken company listing supports pagination and sorting, but no server-side name filter.'
+ ],
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ sortBy: companySortSchema.optional().describe('Sort order for returned companies.')
+ })
+ )
+ .output(
+ z.object({
+ companies: z.array(companySchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let client = createClient(ctx);
+ let response = await client.listCompanies(
+ pickDefined({
+ ...paginationParams(ctx.input),
+ sortBy: ctx.input.sortBy
+ })
+ );
+ let companies = response.items.map(mapCompany);
+
+ return {
+ output: {
+ companies,
+ ...listMetadata(response)
+ },
+ message: `Found **${companies.length}** Fiken compan${companies.length === 1 ? 'y' : 'ies'}.`
+ };
+ })
+ .build();
+
+export let getCompany = SlateTool.create(spec, {
+ name: 'Get Company',
+ key: 'get_company',
+ description: 'Retrieves one Fiken company by company slug.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput.describe(
+ 'Fiken company slug from list_companies. Omit only when defaultCompanySlug is configured.'
+ )
+ })
+ )
+ .output(companySchema)
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let company = mapCompany(await client.getCompany(companySlug));
+
+ return {
+ output: company,
+ message: `Retrieved Fiken company **${company.name ?? company.companySlug ?? companySlug}**.`
+ };
+ })
+ .build();
diff --git a/integrations/fiken/src/tools/contacts.test.ts b/integrations/fiken/src/tools/contacts.test.ts
new file mode 100644
index 0000000000..ac9372290c
--- /dev/null
+++ b/integrations/fiken/src/tools/contacts.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'vitest';
+import { createContact } from './contacts';
+
+let createContactInputSchema = (
+ createContact as unknown as {
+ _inputSchema: { safeParse: (value: unknown) => { success: boolean } };
+ }
+)._inputSchema;
+
+describe('createContact input schema', () => {
+ let baseInput = {
+ companySlug: 'demo-company',
+ name: 'Test Contact'
+ };
+
+ test('accepts documented uppercase ISO 4217 currency codes', () => {
+ expect(
+ createContactInputSchema.safeParse({
+ ...baseInput,
+ currency: 'NOK'
+ }).success
+ ).toBe(true);
+ });
+
+ test('rejects currency codes outside the documented uppercase ISO 4217 pattern', () => {
+ expect(
+ createContactInputSchema.safeParse({
+ ...baseInput,
+ currency: 'nok'
+ }).success
+ ).toBe(false);
+
+ expect(
+ createContactInputSchema.safeParse({
+ ...baseInput,
+ currency: 'N0K'
+ }).success
+ ).toBe(false);
+ });
+});
diff --git a/integrations/fiken/src/tools/contacts.ts b/integrations/fiken/src/tools/contacts.ts
new file mode 100644
index 0000000000..677bb199d6
--- /dev/null
+++ b/integrations/fiken/src/tools/contacts.ts
@@ -0,0 +1,220 @@
+import { pickDefined, SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ addressInputSchema,
+ cleanBody,
+ companySlugFor,
+ companySlugInput,
+ contactSchema,
+ createClient,
+ listMetadata,
+ mapContact,
+ paginationInputShape,
+ paginationOutputShape,
+ paginationParams
+} from './shared';
+
+let contactSortSchema = z.enum([
+ 'lastModified asc',
+ 'lastModified desc',
+ 'createdDate asc',
+ 'createdDate desc'
+]);
+
+export let listContacts = SlateTool.create(spec, {
+ name: 'List Contacts',
+ key: 'list_contacts',
+ description:
+ 'Lists Fiken contacts for a company, with filters for customer/supplier status, identity fields, exact dates, and inclusive created/modified date ranges.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ name: z.string().optional().describe('Exact contact name filter.'),
+ email: z.string().optional().describe('Exact email filter.'),
+ organizationNumber: z.string().optional(),
+ customerNumber: z.number().int().optional(),
+ supplierNumber: z.number().int().optional(),
+ memberNumberString: z.string().optional(),
+ phoneNumber: z.string().optional(),
+ customer: z.boolean().optional().describe('Return contacts marked as customers.'),
+ supplier: z.boolean().optional().describe('Return contacts marked as suppliers.'),
+ inactive: z.boolean().optional().describe('Return inactive contacts when true.'),
+ group: z.string().optional().describe('Exact contact group name.'),
+ createdDate: z.string().optional().describe('Exact created date, YYYY-MM-DD.'),
+ createdDateFrom: z.string().optional().describe('Created on or after this date.'),
+ createdDateTo: z.string().optional().describe('Created on or before this date.'),
+ lastModified: z.string().optional().describe('Exact last modified date, YYYY-MM-DD.'),
+ lastModifiedFrom: z.string().optional().describe('Modified on or after this date.'),
+ lastModifiedTo: z.string().optional().describe('Modified on or before this date.'),
+ sortBy: contactSortSchema.optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ contacts: z.array(contactSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listContacts(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ name: ctx.input.name,
+ email: ctx.input.email,
+ organizationNumber: ctx.input.organizationNumber,
+ customerNumber: ctx.input.customerNumber,
+ supplierNumber: ctx.input.supplierNumber,
+ memberNumberString: ctx.input.memberNumberString,
+ phoneNumber: ctx.input.phoneNumber,
+ customer: ctx.input.customer,
+ supplier: ctx.input.supplier,
+ inactive: ctx.input.inactive,
+ group: ctx.input.group,
+ createdDate: ctx.input.createdDate,
+ createdDateGe: ctx.input.createdDateFrom,
+ createdDateLe: ctx.input.createdDateTo,
+ lastModified: ctx.input.lastModified,
+ lastModifiedGe: ctx.input.lastModifiedFrom,
+ lastModifiedLe: ctx.input.lastModifiedTo,
+ sortBy: ctx.input.sortBy
+ })
+ );
+ let contacts = response.items.map(mapContact);
+
+ return {
+ output: {
+ companySlug,
+ contacts,
+ ...listMetadata(response)
+ },
+ message: `Found **${contacts.length}** Fiken contact${contacts.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getContact = SlateTool.create(spec, {
+ name: 'Get Contact',
+ key: 'get_contact',
+ description: 'Retrieves one Fiken contact by contact id.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ contactId: z.number().int().positive().describe('Fiken contact id.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ contact: contactSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let contact = mapContact(await client.getContact(companySlug, ctx.input.contactId));
+
+ return {
+ output: {
+ companySlug,
+ contact
+ },
+ message: `Retrieved Fiken contact **${contact.name ?? ctx.input.contactId}**.`
+ };
+ })
+ .build();
+
+export let createContact = SlateTool.create(spec, {
+ name: 'Create Contact',
+ key: 'create_contact',
+ description:
+ 'Creates a Fiken contact for a company. Mark the contact as customer and/or supplier when it will be used for invoices, sales, or purchases.',
+ tags: {
+ destructive: false,
+ readOnly: false
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ name: z.string().min(1).max(200).describe('Contact name.'),
+ email: z.string().max(200).optional(),
+ organizationNumber: z.string().max(200).optional(),
+ phoneNumber: z.string().max(20).optional(),
+ memberNumberString: z.string().optional(),
+ customer: z.boolean().optional().describe('Set true when this contact is a customer.'),
+ supplier: z.boolean().optional().describe('Set true when this contact is a supplier.'),
+ bankAccountNumber: z.string().max(11).optional(),
+ currency: z
+ .string()
+ .regex(/^[A-Z]{3}$/)
+ .optional()
+ .describe('Uppercase ISO 4217 currency code.'),
+ language: z.enum(['NORWEGIAN', 'ENGLISH']).optional(),
+ inactive: z.boolean().optional(),
+ daysUntilInvoicingDueDate: z.number().int().optional(),
+ discount: z.number().min(0).max(100).optional(),
+ address: addressInputSchema.optional(),
+ groups: z.array(z.string()).optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ contactId: z.number().optional(),
+ location: z.string().optional(),
+ contact: contactSchema.optional()
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let created = await client.createContact(
+ companySlug,
+ cleanBody({
+ name: ctx.input.name,
+ email: ctx.input.email,
+ organizationNumber: ctx.input.organizationNumber,
+ phoneNumber: ctx.input.phoneNumber,
+ memberNumberString: ctx.input.memberNumberString,
+ customer: ctx.input.customer,
+ supplier: ctx.input.supplier,
+ bankAccountNumber: ctx.input.bankAccountNumber,
+ currency: ctx.input.currency,
+ language: ctx.input.language,
+ inactive: ctx.input.inactive,
+ daysUntilInvoicingDueDate: ctx.input.daysUntilInvoicingDueDate,
+ discount: ctx.input.discount,
+ address: ctx.input.address,
+ groups: ctx.input.groups
+ })
+ );
+ let contact = created.record ? mapContact(created.record) : undefined;
+
+ return {
+ output: {
+ companySlug,
+ contactId: contact?.contactId ?? created.id,
+ location: created.location,
+ contact
+ },
+ message: `Created Fiken contact **${contact?.name ?? ctx.input.name}**.`
+ };
+ })
+ .build();
diff --git a/integrations/fiken/src/tools/index.ts b/integrations/fiken/src/tools/index.ts
new file mode 100644
index 0000000000..ca332bcffd
--- /dev/null
+++ b/integrations/fiken/src/tools/index.ts
@@ -0,0 +1,5 @@
+export * from './accounting';
+export * from './catalog';
+export * from './companies';
+export * from './contacts';
+export * from './invoices';
diff --git a/integrations/fiken/src/tools/invoices.ts b/integrations/fiken/src/tools/invoices.ts
new file mode 100644
index 0000000000..be9fe8e8c9
--- /dev/null
+++ b/integrations/fiken/src/tools/invoices.ts
@@ -0,0 +1,334 @@
+import { pickDefined, SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ cleanBody,
+ companySlugFor,
+ companySlugInput,
+ createClient,
+ invoiceDraftLineInputSchema,
+ invoiceDraftSchema,
+ invoiceSchema,
+ listMetadata,
+ mapInvoice,
+ mapInvoiceDraft,
+ paginationInputShape,
+ paginationOutputShape,
+ paginationParams,
+ requireInvoiceDraftLineFields
+} from './shared';
+
+let roundingTypeSchema = z.enum([
+ 'none',
+ 'round_half',
+ 'round_whole',
+ 'round_down_half',
+ 'round_down_whole'
+]);
+
+export let listInvoices = SlateTool.create(spec, {
+ name: 'List Invoices',
+ key: 'list_invoices',
+ description:
+ 'Lists Fiken invoices for a company with filters for customer, invoice number, issue date, due date, last modified date, settled status, order reference, and source draft UUID.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ customerId: z.number().int().positive().optional(),
+ invoiceNumber: z.string().optional(),
+ issueDate: z.string().optional().describe('Exact issue date, YYYY-MM-DD.'),
+ issueDateFrom: z.string().optional().describe('Issued on or after this date.'),
+ issueDateTo: z.string().optional().describe('Issued on or before this date.'),
+ issueDateAfter: z.string().optional().describe('Issued strictly after this date.'),
+ issueDateBefore: z.string().optional().describe('Issued strictly before this date.'),
+ dueDate: z.string().optional().describe('Exact due date, YYYY-MM-DD.'),
+ dueDateFrom: z.string().optional().describe('Due on or after this date.'),
+ dueDateTo: z.string().optional().describe('Due on or before this date.'),
+ dueDateAfter: z.string().optional().describe('Due strictly after this date.'),
+ dueDateBefore: z.string().optional().describe('Due strictly before this date.'),
+ lastModified: z.string().optional().describe('Exact last modified date, YYYY-MM-DD.'),
+ lastModifiedFrom: z.string().optional().describe('Modified on or after this date.'),
+ lastModifiedTo: z.string().optional().describe('Modified on or before this date.'),
+ lastModifiedAfter: z.string().optional().describe('Modified strictly after this date.'),
+ lastModifiedBefore: z
+ .string()
+ .optional()
+ .describe('Modified strictly before this date.'),
+ settled: z.boolean().optional(),
+ orderReference: z.string().optional(),
+ invoiceDraftUuid: z.string().optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ invoices: z.array(invoiceSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listInvoices(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ customerId: ctx.input.customerId,
+ invoiceNumber: ctx.input.invoiceNumber,
+ issueDate: ctx.input.issueDate,
+ issueDateGe: ctx.input.issueDateFrom,
+ issueDateLe: ctx.input.issueDateTo,
+ issueDateGt: ctx.input.issueDateAfter,
+ issueDateLt: ctx.input.issueDateBefore,
+ dueDate: ctx.input.dueDate,
+ dueDateGe: ctx.input.dueDateFrom,
+ dueDateLe: ctx.input.dueDateTo,
+ dueDateGt: ctx.input.dueDateAfter,
+ dueDateLt: ctx.input.dueDateBefore,
+ lastModified: ctx.input.lastModified,
+ lastModifiedGe: ctx.input.lastModifiedFrom,
+ lastModifiedLe: ctx.input.lastModifiedTo,
+ lastModifiedGt: ctx.input.lastModifiedAfter,
+ lastModifiedLt: ctx.input.lastModifiedBefore,
+ settled: ctx.input.settled,
+ orderReference: ctx.input.orderReference,
+ invoiceDraftUuid: ctx.input.invoiceDraftUuid
+ })
+ );
+ let invoices = response.items.map(mapInvoice);
+
+ return {
+ output: {
+ companySlug,
+ invoices,
+ ...listMetadata(response)
+ },
+ message: `Found **${invoices.length}** Fiken invoice${invoices.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getInvoice = SlateTool.create(spec, {
+ name: 'Get Invoice',
+ key: 'get_invoice',
+ description:
+ 'Retrieves one Fiken invoice by invoice id, including line and attachment metadata returned by Fiken.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ invoiceId: z.number().int().positive().describe('Fiken invoice id, not invoice number.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ invoice: invoiceSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let invoice = mapInvoice(await client.getInvoice(companySlug, ctx.input.invoiceId));
+
+ return {
+ output: {
+ companySlug,
+ invoice
+ },
+ message: `Retrieved Fiken invoice **${invoice.invoiceNumber ?? ctx.input.invoiceId}**.`
+ };
+ })
+ .build();
+
+export let listInvoiceDrafts = SlateTool.create(spec, {
+ name: 'List Invoice Drafts',
+ key: 'list_invoice_drafts',
+ description:
+ 'Lists Fiken invoice drafts for a company with optional order reference or draft UUID filters.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ page: paginationInputShape.page,
+ pageSize: paginationInputShape.pageSize,
+ orderReference: z.string().optional(),
+ uuid: z.string().optional().describe('Invoice draft UUID.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ drafts: z.array(invoiceDraftSchema),
+ ...paginationOutputShape
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let response = await client.listInvoiceDrafts(
+ companySlug,
+ pickDefined({
+ ...paginationParams(ctx.input),
+ orderReference: ctx.input.orderReference,
+ uuid: ctx.input.uuid
+ })
+ );
+ let drafts = response.items.map(mapInvoiceDraft);
+
+ return {
+ output: {
+ companySlug,
+ drafts,
+ ...listMetadata(response)
+ },
+ message: `Found **${drafts.length}** Fiken invoice draft${drafts.length === 1 ? '' : 's'}.`
+ };
+ })
+ .build();
+
+export let getInvoiceDraft = SlateTool.create(spec, {
+ name: 'Get Invoice Draft',
+ key: 'get_invoice_draft',
+ description: 'Retrieves one Fiken invoice draft by draft id.',
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ draftId: z.number().int().positive().describe('Fiken invoice draft id.')
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ draft: invoiceDraftSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let draft = mapInvoiceDraft(await client.getInvoiceDraft(companySlug, ctx.input.draftId));
+
+ return {
+ output: {
+ companySlug,
+ draft
+ },
+ message: `Retrieved Fiken invoice draft **${draft.draftId ?? ctx.input.draftId}**.`
+ };
+ })
+ .build();
+
+export let createInvoiceDraft = SlateTool.create(spec, {
+ name: 'Create Invoice Draft',
+ key: 'create_invoice_draft',
+ description:
+ 'Creates a reviewable Fiken invoice draft. This does not finalize or send an invoice.',
+ constraints: [
+ 'This tool creates only invoice or cash_invoice drafts, not offers, order confirmations, or credit notes.',
+ 'For free-text lines without productId, provide description, unitPrice, vatType, and incomeAccount.'
+ ],
+ tags: {
+ destructive: false,
+ readOnly: false
+ }
+})
+ .input(
+ z.object({
+ companySlug: companySlugInput,
+ type: z
+ .enum(['invoice', 'cash_invoice'])
+ .optional()
+ .describe('Draft type. Defaults to invoice.'),
+ customerId: z
+ .number()
+ .int()
+ .positive()
+ .describe('Fiken contact id where customer=true.'),
+ daysUntilDueDate: z.number().int().min(0).describe('Days until the invoice is due.'),
+ issueDate: z.string().optional().describe('Issue date, YYYY-MM-DD.'),
+ invoiceText: z.string().optional().describe('Text printed above the invoice lines.'),
+ yourReference: z.string().optional(),
+ ourReference: z.string().optional(),
+ orderReference: z.string().optional(),
+ lines: z.array(invoiceDraftLineInputSchema).min(1).describe('Invoice draft lines.'),
+ currency: z.string().length(3).optional().describe('ISO 4217 currency code.'),
+ bankAccountNumber: z.string().optional(),
+ iban: z.string().optional(),
+ bic: z.string().optional(),
+ paymentAccount: z
+ .string()
+ .optional()
+ .describe('Payment account, for example 1920:10001.'),
+ contactPersonId: z.number().int().positive().optional(),
+ projectId: z.number().int().positive().optional(),
+ roundingType: roundingTypeSchema.optional()
+ })
+ )
+ .output(
+ z.object({
+ companySlug: z.string(),
+ draftId: z.number().optional(),
+ location: z.string().optional(),
+ draft: invoiceDraftSchema.optional()
+ })
+ )
+ .handleInvocation(async ctx => {
+ ctx.input.lines.forEach(requireInvoiceDraftLineFields);
+
+ let companySlug = companySlugFor(ctx, ctx.input.companySlug);
+ let client = createClient(ctx);
+ let created = await client.createInvoiceDraft(
+ companySlug,
+ cleanBody({
+ type: ctx.input.type ?? 'invoice',
+ customerId: ctx.input.customerId,
+ daysUntilDueDate: ctx.input.daysUntilDueDate,
+ issueDate: ctx.input.issueDate,
+ invoiceText: ctx.input.invoiceText,
+ yourReference: ctx.input.yourReference,
+ ourReference: ctx.input.ourReference,
+ orderReference: ctx.input.orderReference,
+ lines: ctx.input.lines.map(line => cleanBody(line)),
+ currency: ctx.input.currency,
+ bankAccountNumber: ctx.input.bankAccountNumber,
+ iban: ctx.input.iban,
+ bic: ctx.input.bic,
+ paymentAccount: ctx.input.paymentAccount,
+ contactPersonId: ctx.input.contactPersonId,
+ projectId: ctx.input.projectId,
+ roundingType: ctx.input.roundingType
+ })
+ );
+ let draft = created.record ? mapInvoiceDraft(created.record) : undefined;
+
+ return {
+ output: {
+ companySlug,
+ draftId: draft?.draftId ?? created.id,
+ location: created.location,
+ draft
+ },
+ message: `Created Fiken invoice draft **${draft?.draftId ?? created.id ?? 'from request'}**.`
+ };
+ })
+ .build();
diff --git a/integrations/fiken/src/tools/shared.test.ts b/integrations/fiken/src/tools/shared.test.ts
new file mode 100644
index 0000000000..42d01c60e0
--- /dev/null
+++ b/integrations/fiken/src/tools/shared.test.ts
@@ -0,0 +1,321 @@
+import { describe, expect, test } from 'vitest';
+import { mapContact, mapInvoice, mapInvoiceDraft } from './shared';
+
+describe('mapContact', () => {
+ test('maps documented detailed contact fields', () => {
+ let contact = mapContact({
+ contactId: 2747365,
+ name: 'Fiken AS',
+ email: 'kontakt@fiken.gmail',
+ bankAccountNumber: '11112233334',
+ customer: true,
+ supplier: true,
+ daysUntilInvoicingDueDate: 15,
+ discount: 25.5,
+ groups: ['customers', 'vip'],
+ documents: [
+ {
+ identifier: '24760',
+ downloadUrl: 'https://api.fiken.no/api/v2/files/example.pdf',
+ downloadUrlWithFikenNormalUserCredentials: 'https://fiken.no/files/example.pdf',
+ comment: 'Invoice attachment',
+ type: 'invoice'
+ }
+ ],
+ notes: [
+ {
+ author: 'Betty Boop',
+ note: 'Garage 45'
+ }
+ ],
+ contactPerson: [
+ {
+ contactPersonId: 123,
+ name: 'Betty Boop',
+ email: 'bb@gmail.com',
+ phoneNumber: '98573564',
+ address: {
+ streetAddress: 'Karl Johan 34',
+ city: 'Oslo',
+ postCode: '0550',
+ country: 'Norway'
+ }
+ }
+ ]
+ });
+
+ expect(contact).toMatchObject({
+ contactId: 2747365,
+ name: 'Fiken AS',
+ bankAccountNumber: '11112233334',
+ customer: true,
+ supplier: true,
+ daysUntilInvoicingDueDate: 15,
+ discount: 25.5,
+ groups: ['customers', 'vip'],
+ groupCount: 2,
+ documentCount: 1,
+ documents: [
+ {
+ identifier: '24760',
+ type: 'invoice',
+ comment: 'Invoice attachment'
+ }
+ ],
+ notes: [
+ {
+ author: 'Betty Boop',
+ note: 'Garage 45'
+ }
+ ],
+ contactPerson: [
+ {
+ contactPersonId: 123,
+ name: 'Betty Boop',
+ email: 'bb@gmail.com',
+ phoneNumber: '98573564',
+ address: {
+ streetAddress: 'Karl Johan 34',
+ city: 'Oslo',
+ postCode: '0550',
+ country: 'Norway'
+ }
+ }
+ ]
+ });
+ });
+});
+
+describe('mapInvoice', () => {
+ test('maps documented detailed invoice fields', () => {
+ let invoice = mapInvoice({
+ invoiceId: 2888156,
+ invoiceNumber: 10001,
+ issueDate: '2018-04-03',
+ dueDate: '2018-04-17',
+ originalDueDate: '2018-04-17',
+ createdDate: '2023-04-03',
+ lastModifiedDate: '2023-04-04',
+ kid: '5855454756',
+ net: 25000,
+ vat: 5000,
+ gross: 30000,
+ netInNok: 25000,
+ vatInNok: 5000,
+ grossInNok: 30000,
+ cash: false,
+ invoiceText: 'Invoice for services rendered.',
+ yourReference: 'Betty Boop',
+ ourReference: 'Koko',
+ orderReference: 'order-123',
+ invoiceDraftUuid: '123e4567-e89b-12d3-a456-426655440000',
+ currency: 'NOK',
+ bankAccountNumber: '11112233334',
+ sentManually: false,
+ associatedCreditNotes: [42],
+ lines: [
+ {
+ net: 4500,
+ vat: 500,
+ vatType: 'HIGH',
+ gross: 5000,
+ netInNok: 4500,
+ vatInNok: 500,
+ grossInNok: 5000,
+ vatInPercent: 25,
+ unitPrice: 4550,
+ quantity: 5,
+ discount: 25,
+ productId: 2888156,
+ productName: 'Gardening Gloves VI2',
+ description: 'Goatskin, with extra-long suede cuffs',
+ comment: 'One size fits all',
+ incomeAccount: '3000'
+ }
+ ],
+ invoicePdf: {
+ identifier: '2888156',
+ downloadUrl: 'https://api.fiken.no/api/v2/files/invoice.pdf',
+ downloadUrlWithFikenNormalUserCredentials: 'https://fiken.no/files/invoice.pdf',
+ comment: 'Invoice PDF',
+ type: 'invoice'
+ },
+ attachments: [
+ {
+ identifier: '24760',
+ downloadUrl: 'https://api.fiken.no/api/v2/files/example.pdf',
+ downloadUrlWithFikenNormalUserCredentials: 'https://fiken.no/files/example.pdf',
+ comment: 'Invoice attachment',
+ type: 'invoice'
+ }
+ ],
+ customer: {
+ contactId: 2747365,
+ name: 'Fiken AS'
+ },
+ sale: {
+ saleId: 123456
+ },
+ project: {
+ projectId: 15124866
+ }
+ });
+
+ expect(invoice).toMatchObject({
+ invoiceId: 2888156,
+ invoiceNumber: 10001,
+ invoiceText: 'Invoice for services rendered.',
+ yourReference: 'Betty Boop',
+ ourReference: 'Koko',
+ bankAccountNumber: '11112233334',
+ customerId: 2747365,
+ customerName: 'Fiken AS',
+ saleId: 123456,
+ projectId: 15124866,
+ associatedCreditNotes: [42],
+ lineCount: 1,
+ attachmentCount: 2,
+ lines: [
+ {
+ productId: 2888156,
+ productName: 'Gardening Gloves VI2',
+ net: 4500,
+ vat: 500,
+ gross: 5000,
+ netInNok: 4500,
+ vatInNok: 500,
+ grossInNok: 5000,
+ vatInPercent: 25,
+ unitPrice: 4550,
+ quantity: 5,
+ discount: 25,
+ vatType: 'HIGH',
+ incomeAccount: '3000'
+ }
+ ],
+ invoicePdf: {
+ identifier: '2888156',
+ type: 'invoice',
+ comment: 'Invoice PDF'
+ },
+ attachments: [
+ {
+ identifier: '24760',
+ type: 'invoice',
+ comment: 'Invoice attachment'
+ }
+ ]
+ });
+ });
+});
+
+describe('mapInvoiceDraft', () => {
+ test('maps documented invoice draft result fields', () => {
+ let draft = mapInvoiceDraft({
+ draftId: 2888156,
+ uuid: '123e4567-e89b-12d3-a456-426655440000',
+ type: 'invoice',
+ lastModifiedDate: '2023-04-03',
+ issueDate: '2018-04-03',
+ daysUntilDueDate: 15,
+ invoiceText: 'Invoice for services rendered.',
+ currency: 'NOK',
+ yourReference: 'Betty Boop',
+ ourReference: 'Koko',
+ orderReference: 'order-123',
+ net: 4500,
+ gross: 5000,
+ bankAccountNumber: '11112233334',
+ iban: 'NO49 1111 2233 334',
+ bic: 'DNBANOKKXXX',
+ paymentAccount: '1920:10001',
+ createdFromInvoiceId: 73408306,
+ customers: [
+ {
+ contactId: 2747365,
+ name: 'Fiken AS',
+ customer: true
+ }
+ ],
+ lines: [
+ {
+ invoiceishDraftLineId: 2888157,
+ lastModifiedDate: '2023-04-03',
+ productId: 2888156,
+ description: 'Goatskin, with extra-long suede cuffs',
+ unitPrice: 4550,
+ vatType: 'HIGH',
+ quantity: 5,
+ discount: 25,
+ comment: 'One size fits all',
+ incomeAccount: '3000'
+ }
+ ],
+ attachments: [
+ {
+ identifier: '24760',
+ downloadUrl: 'https://api.fiken.no/api/v2/files/example.pdf',
+ downloadUrlWithFikenNormalUserCredentials: 'https://fiken.no/files/example.pdf',
+ comment: 'Invoice attachment',
+ type: 'invoice'
+ }
+ ],
+ projectId: 73408306,
+ roundingType: 'none'
+ });
+
+ expect(draft).toMatchObject({
+ draftId: 2888156,
+ uuid: '123e4567-e89b-12d3-a456-426655440000',
+ type: 'invoice',
+ lastModifiedDate: '2023-04-03',
+ issueDate: '2018-04-03',
+ daysUntilDueDate: 15,
+ invoiceText: 'Invoice for services rendered.',
+ currency: 'NOK',
+ yourReference: 'Betty Boop',
+ ourReference: 'Koko',
+ orderReference: 'order-123',
+ net: 4500,
+ gross: 5000,
+ bankAccountNumber: '11112233334',
+ iban: 'NO49 1111 2233 334',
+ bic: 'DNBANOKKXXX',
+ paymentAccount: '1920:10001',
+ createdFromInvoiceId: 73408306,
+ customerCount: 1,
+ customers: [
+ {
+ contactId: 2747365,
+ name: 'Fiken AS',
+ customer: true
+ }
+ ],
+ lineCount: 1,
+ lines: [
+ {
+ draftLineId: 2888157,
+ lastModifiedDate: '2023-04-03',
+ productId: 2888156,
+ description: 'Goatskin, with extra-long suede cuffs',
+ unitPrice: 4550,
+ vatType: 'HIGH',
+ quantity: 5,
+ discount: 25,
+ comment: 'One size fits all',
+ incomeAccount: '3000'
+ }
+ ],
+ attachmentCount: 1,
+ attachments: [
+ {
+ identifier: '24760',
+ type: 'invoice',
+ comment: 'Invoice attachment'
+ }
+ ],
+ projectId: 73408306,
+ roundingType: 'none'
+ });
+ });
+});
diff --git a/integrations/fiken/src/tools/shared.ts b/integrations/fiken/src/tools/shared.ts
new file mode 100644
index 0000000000..c915cfdb0a
--- /dev/null
+++ b/integrations/fiken/src/tools/shared.ts
@@ -0,0 +1,743 @@
+import { pickDefined } from 'slates';
+import { z } from 'zod';
+import type { FikenAuthOutput } from '../auth';
+import type { FikenConfig } from '../config';
+import { FikenClient, type FikenListResponse } from '../lib/client';
+import { fikenValidationError } from '../lib/errors';
+
+export type ToolContext = {
+ auth: FikenAuthOutput;
+ config: FikenConfig;
+};
+
+export let rawRecordSchema = z.record(z.string(), z.any()).describe('Raw Fiken record');
+
+export let paginationInputShape = {
+ page: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe('Fiken page number. Pages are zero-indexed. Defaults to 0.'),
+ pageSize: z
+ .number()
+ .int()
+ .min(1)
+ .max(100)
+ .optional()
+ .describe('Number of records to return, up to 100. Defaults to 25.')
+};
+
+export let companySlugInput = z
+ .string()
+ .optional()
+ .describe('Fiken company slug. Omit only when defaultCompanySlug is configured.');
+
+export let paginationOutputShape = {
+ page: z.number().optional(),
+ pageSize: z.number().optional(),
+ pageCount: z.number().optional(),
+ resultCount: z.number().optional(),
+ nextPage: z.number().optional()
+};
+
+export let addressInputSchema = z
+ .object({
+ streetAddress: z.string().optional(),
+ streetAddressLine2: z.string().optional(),
+ city: z.string().optional(),
+ postCode: z.string().optional(),
+ country: z.string().describe('Country name, for example Norway.')
+ })
+ .describe('Fiken address.');
+
+export let addressOutputSchema = z
+ .object({
+ streetAddress: z.string().optional(),
+ streetAddressLine2: z.string().optional(),
+ city: z.string().optional(),
+ postCode: z.string().optional(),
+ country: z.string().optional()
+ })
+ .optional();
+
+export let attachmentMetadataSchema = z.object({
+ identifier: z.string().optional(),
+ downloadUrl: z.string().optional(),
+ downloadUrlWithFikenNormalUserCredentials: z.string().optional(),
+ comment: z.string().optional(),
+ type: z.string().optional()
+});
+
+export let contactNoteSchema = z.object({
+ author: z.string().optional(),
+ note: z.string().optional()
+});
+
+export let contactPersonSchema = z.object({
+ contactPersonId: z.number().optional(),
+ name: z.string().optional(),
+ email: z.string().optional(),
+ phoneNumber: z.string().optional(),
+ address: addressOutputSchema
+});
+
+export let companySchema = z.object({
+ companySlug: z.string().optional(),
+ name: z.string().optional(),
+ organizationNumber: z.string().optional(),
+ vatType: z.string().optional(),
+ email: z.string().optional(),
+ creationDate: z.string().optional(),
+ hasApiAccess: z.boolean().optional(),
+ testCompany: z.boolean().optional(),
+ accountingStartDate: z.string().optional(),
+ vatRegistrationDate: z.string().optional(),
+ address: addressOutputSchema,
+ raw: rawRecordSchema
+});
+
+export let contactSchema = z.object({
+ contactId: z.number().optional(),
+ name: z.string().optional(),
+ email: z.string().optional(),
+ organizationNumber: z.string().optional(),
+ customerNumber: z.number().optional(),
+ supplierNumber: z.number().optional(),
+ memberNumberString: z.string().optional(),
+ customerAccountCode: z.string().optional(),
+ supplierAccountCode: z.string().optional(),
+ phoneNumber: z.string().optional(),
+ bankAccountNumber: z.string().optional(),
+ customer: z.boolean().optional(),
+ supplier: z.boolean().optional(),
+ inactive: z.boolean().optional(),
+ currency: z.string().optional(),
+ language: z.string().nullable().optional(),
+ daysUntilInvoicingDueDate: z.number().optional(),
+ discount: z.number().optional(),
+ createdDate: z.string().optional(),
+ lastModifiedDate: z.string().optional(),
+ address: addressOutputSchema,
+ groups: z.array(z.string()).optional(),
+ documents: z.array(attachmentMetadataSchema).optional(),
+ notes: z.array(contactNoteSchema).optional(),
+ contactPerson: z.array(contactPersonSchema).optional(),
+ groupCount: z.number().optional(),
+ documentCount: z.number().optional(),
+ raw: rawRecordSchema
+});
+
+export let attachmentSummaryShape = {
+ attachmentCount: z.number().optional()
+};
+
+export let invoiceLineSchema = z.object({
+ draftLineId: z.number().optional(),
+ lastModifiedDate: z.string().optional(),
+ productId: z.number().optional(),
+ productName: z.string().optional(),
+ description: z.string().optional(),
+ comment: z.string().optional(),
+ quantity: z.number().optional(),
+ unitPrice: z.number().optional(),
+ discount: z.number().optional(),
+ net: z.number().optional(),
+ vat: z.number().optional(),
+ gross: z.number().optional(),
+ netInNok: z.number().optional(),
+ vatInNok: z.number().optional(),
+ grossInNok: z.number().optional(),
+ vatInPercent: z.number().optional(),
+ vatType: z.string().optional(),
+ incomeAccount: z.string().optional(),
+ raw: rawRecordSchema
+});
+
+export let invoiceSchema = z.object({
+ invoiceId: z.number().optional(),
+ invoiceNumber: z.number().optional(),
+ issueDate: z.string().optional(),
+ dueDate: z.string().optional(),
+ originalDueDate: z.string().optional(),
+ createdDate: z.string().optional(),
+ lastModifiedDate: z.string().optional(),
+ currency: z.string().optional(),
+ net: z.number().optional(),
+ vat: z.number().optional(),
+ gross: z.number().optional(),
+ netInNok: z.number().optional(),
+ vatInNok: z.number().optional(),
+ grossInNok: z.number().optional(),
+ settled: z.boolean().optional(),
+ cash: z.boolean().optional(),
+ sentManually: z.boolean().optional(),
+ kid: z.string().optional(),
+ invoiceText: z.string().optional(),
+ yourReference: z.string().optional(),
+ ourReference: z.string().optional(),
+ bankAccountNumber: z.string().optional(),
+ customerId: z.number().optional(),
+ customerName: z.string().optional(),
+ projectId: z.number().optional(),
+ saleId: z.number().optional(),
+ orderReference: z.string().optional(),
+ invoiceDraftUuid: z.string().optional(),
+ associatedCreditNotes: z.array(z.number()).optional(),
+ lines: z.array(invoiceLineSchema).optional(),
+ invoicePdf: attachmentMetadataSchema.optional(),
+ attachments: z.array(attachmentMetadataSchema).optional(),
+ lineCount: z.number().optional(),
+ ...attachmentSummaryShape,
+ raw: rawRecordSchema
+});
+
+export let invoiceDraftLineInputSchema = z.object({
+ productId: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe(
+ 'Product id. If omitted, provide description, unitPrice, vatType, and incomeAccount.'
+ ),
+ description: z.string().max(200).optional(),
+ unitPrice: z.number().int().optional().describe('Net unit price in cents.'),
+ vatType: z.string().optional().describe('Fiken VAT type, for example HIGH or NONE.'),
+ incomeAccount: z.string().optional().describe('Income account, for example 3000.'),
+ quantity: z.number().positive().describe('Number of units to invoice.'),
+ discount: z.number().min(0).max(100).optional(),
+ comment: z.string().max(200).optional()
+});
+
+export let invoiceDraftSchema = z.object({
+ draftId: z.number().optional(),
+ uuid: z.string().optional(),
+ type: z.string().optional(),
+ lastModifiedDate: z.string().optional(),
+ issueDate: z.string().optional(),
+ daysUntilDueDate: z.number().optional(),
+ invoiceText: z.string().optional(),
+ currency: z.string().optional(),
+ yourReference: z.string().optional(),
+ ourReference: z.string().optional(),
+ orderReference: z.string().optional(),
+ net: z.number().optional(),
+ gross: z.number().optional(),
+ bankAccountNumber: z.string().optional(),
+ iban: z.string().optional(),
+ bic: z.string().optional(),
+ paymentAccount: z.string().optional(),
+ createdFromInvoiceId: z.number().optional(),
+ customers: z.array(contactSchema).optional(),
+ lines: z.array(invoiceLineSchema).optional(),
+ attachments: z.array(attachmentMetadataSchema).optional(),
+ customerCount: z.number().optional(),
+ lineCount: z.number().optional(),
+ projectId: z.number().optional(),
+ roundingType: z.string().optional(),
+ ...attachmentSummaryShape,
+ raw: rawRecordSchema
+});
+
+export let productSchema = z.object({
+ productId: z.number().optional(),
+ name: z.string().optional(),
+ productNumber: z.string().optional(),
+ unitPrice: z.number().optional(),
+ incomeAccount: z.string().optional(),
+ vatType: z.string().optional(),
+ active: z.boolean().optional(),
+ stock: z.number().optional(),
+ note: z.string().optional(),
+ createdDate: z.string().optional(),
+ lastModifiedDate: z.string().optional(),
+ raw: rawRecordSchema
+});
+
+export let projectSchema = z.object({
+ projectId: z.number().optional(),
+ number: z.string().optional(),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ startDate: z.string().optional(),
+ endDate: z.string().optional(),
+ contactId: z.number().optional(),
+ contactName: z.string().optional(),
+ completed: z.boolean().optional(),
+ raw: rawRecordSchema
+});
+
+export let purchaseSchema = z.object({
+ purchaseId: z.number().optional(),
+ transactionId: z.number().optional(),
+ identifier: z.string().optional(),
+ date: z.string().optional(),
+ dueDate: z.string().optional(),
+ kind: z.string().optional(),
+ paid: z.boolean().optional(),
+ settled: z.boolean().optional(),
+ settledDate: z.string().optional(),
+ currency: z.string().optional(),
+ supplierId: z.number().optional(),
+ supplierName: z.string().optional(),
+ lineCount: z.number().optional(),
+ paymentCount: z.number().optional(),
+ attachmentCount: z.number().optional(),
+ deleted: z.boolean().optional(),
+ raw: rawRecordSchema
+});
+
+export let saleSchema = z.object({
+ saleId: z.number().optional(),
+ transactionId: z.number().optional(),
+ saleNumber: z.string().optional(),
+ date: z.string().optional(),
+ dueDate: z.string().optional(),
+ kind: z.string().optional(),
+ netAmount: z.number().optional(),
+ vatAmount: z.number().optional(),
+ settled: z.boolean().optional(),
+ settledDate: z.string().optional(),
+ totalPaid: z.number().optional(),
+ outstandingBalance: z.number().optional(),
+ currency: z.string().optional(),
+ customerId: z.number().optional(),
+ customerName: z.string().optional(),
+ lineCount: z.number().optional(),
+ paymentCount: z.number().optional(),
+ attachmentCount: z.number().optional(),
+ writeOff: z.boolean().optional(),
+ deleted: z.boolean().optional(),
+ raw: rawRecordSchema
+});
+
+export let accountSchema = z.object({
+ code: z.string().optional(),
+ name: z.string().optional(),
+ raw: rawRecordSchema
+});
+
+export let accountBalanceSchema = z.object({
+ code: z.string().optional(),
+ name: z.string().optional(),
+ balance: z.number().optional(),
+ raw: rawRecordSchema
+});
+
+export let createClient = (ctx: ToolContext) => new FikenClient(ctx.auth);
+
+export let companySlugFor = (ctx: ToolContext, inputCompanySlug?: string) => {
+ let slug = inputCompanySlug?.trim() || ctx.config.defaultCompanySlug?.trim();
+ if (!slug) {
+ throw fikenValidationError(
+ 'companySlug is required. Provide companySlug or configure defaultCompanySlug.'
+ );
+ }
+
+ return slug;
+};
+
+export let asRecord = (value: unknown): Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+
+export let asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
+
+export let asString = (value: unknown) => (typeof value === 'string' ? value : undefined);
+
+export let asNumber = (value: unknown) => (typeof value === 'number' ? value : undefined);
+
+export let asBoolean = (value: unknown) => (typeof value === 'boolean' ? value : undefined);
+
+export let asNullableString = (value: unknown) =>
+ value === null || typeof value === 'string' ? value : undefined;
+
+export let mapAddress = (value: unknown): z.infer | undefined => {
+ let record = asRecord(value);
+ if (Object.keys(record).length === 0) return undefined;
+
+ return {
+ streetAddress: asString(record.streetAddress),
+ streetAddressLine2: asString(record.streetAddressLine2),
+ city: asString(record.city),
+ postCode: asString(record.postCode),
+ country: asString(record.country)
+ };
+};
+
+export let mapAttachment = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ identifier: asString(record.identifier),
+ downloadUrl: asString(record.downloadUrl),
+ downloadUrlWithFikenNormalUserCredentials: asString(
+ record.downloadUrlWithFikenNormalUserCredentials
+ ),
+ comment: asString(record.comment),
+ type: asString(record.type)
+ };
+};
+
+let mapContactNote = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ author: asString(record.author),
+ note: asString(record.note)
+ };
+};
+
+let mapContactPerson = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ contactPersonId: asNumber(record.contactPersonId),
+ name: asString(record.name),
+ email: asString(record.email),
+ phoneNumber: asString(record.phoneNumber),
+ address: mapAddress(record.address)
+ };
+};
+
+export let mapCompany = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ companySlug: asString(record.slug),
+ name: asString(record.name),
+ organizationNumber: asString(record.organizationNumber),
+ vatType: asString(record.vatType),
+ email: asString(record.email),
+ creationDate: asString(record.creationDate),
+ hasApiAccess: asBoolean(record.hasApiAccess),
+ testCompany: asBoolean(record.testCompany),
+ accountingStartDate: asString(record.accountingStartDate),
+ vatRegistrationDate: asString(record.vatRegistrationDate),
+ address: mapAddress(record.address),
+ raw: record
+ };
+};
+
+export let mapContact = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ contactId: asNumber(record.contactId),
+ name: asString(record.name),
+ email: asString(record.email),
+ organizationNumber: asString(record.organizationNumber),
+ customerNumber: asNumber(record.customerNumber),
+ supplierNumber: asNumber(record.supplierNumber),
+ memberNumberString: asString(record.memberNumberString),
+ customerAccountCode: asString(record.customerAccountCode),
+ supplierAccountCode: asString(record.supplierAccountCode),
+ phoneNumber: asString(record.phoneNumber),
+ bankAccountNumber: asString(record.bankAccountNumber),
+ customer: asBoolean(record.customer),
+ supplier: asBoolean(record.supplier),
+ inactive: asBoolean(record.inactive),
+ currency: asString(record.currency),
+ language: asNullableString(record.language),
+ daysUntilInvoicingDueDate: asNumber(record.daysUntilInvoicingDueDate),
+ discount: asNumber(record.discount),
+ createdDate: asString(record.createdDate),
+ lastModifiedDate: asString(record.lastModifiedDate),
+ address: mapAddress(record.address),
+ groups: Array.isArray(record.groups)
+ ? record.groups.filter((group): group is string => typeof group === 'string')
+ : undefined,
+ documents: Array.isArray(record.documents)
+ ? record.documents.map(mapAttachment)
+ : undefined,
+ notes: Array.isArray(record.notes) ? record.notes.map(mapContactNote) : undefined,
+ contactPerson: Array.isArray(record.contactPerson)
+ ? record.contactPerson.map(mapContactPerson)
+ : undefined,
+ groupCount: Array.isArray(record.groups) ? record.groups.length : undefined,
+ documentCount: Array.isArray(record.documents) ? record.documents.length : undefined,
+ raw: record
+ };
+};
+
+let mapInvoiceLine = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ draftLineId: asNumber(record.invoiceishDraftLineId),
+ lastModifiedDate: asString(record.lastModifiedDate),
+ productId: asNumber(record.productId),
+ productName: asString(record.productName),
+ description: asString(record.description),
+ comment: asString(record.comment),
+ quantity: asNumber(record.quantity),
+ unitPrice: asNumber(record.unitPrice),
+ discount: asNumber(record.discount),
+ net: asNumber(record.net),
+ vat: asNumber(record.vat),
+ gross: asNumber(record.gross),
+ netInNok: asNumber(record.netInNok),
+ vatInNok: asNumber(record.vatInNok),
+ grossInNok: asNumber(record.grossInNok),
+ vatInPercent: asNumber(record.vatInPercent),
+ vatType: asString(record.vatType),
+ incomeAccount: asString(record.incomeAccount),
+ raw: record
+ };
+};
+
+export let mapInvoice = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ let customer = asRecord(record.customer);
+ let project = asRecord(record.project);
+ let sale = asRecord(record.sale);
+ let lines = asArray(record.lines);
+ let attachments = asArray(record.attachments);
+ let invoicePdf = Object.keys(asRecord(record.invoicePdf)).length
+ ? mapAttachment(record.invoicePdf)
+ : undefined;
+ let mappedAttachments = attachments.map(mapAttachment);
+ let mappedLines = lines.map(mapInvoiceLine);
+
+ return {
+ invoiceId: asNumber(record.invoiceId),
+ invoiceNumber: asNumber(record.invoiceNumber),
+ issueDate: asString(record.issueDate),
+ dueDate: asString(record.dueDate),
+ originalDueDate: asString(record.originalDueDate),
+ createdDate: asString(record.createdDate),
+ lastModifiedDate: asString(record.lastModifiedDate),
+ currency: asString(record.currency),
+ net: asNumber(record.net),
+ vat: asNumber(record.vat),
+ gross: asNumber(record.gross),
+ netInNok: asNumber(record.netInNok),
+ vatInNok: asNumber(record.vatInNok),
+ grossInNok: asNumber(record.grossInNok),
+ settled: asBoolean(record.settled),
+ cash: asBoolean(record.cash),
+ sentManually: asBoolean(record.sentManually),
+ kid: asString(record.kid),
+ invoiceText: asString(record.invoiceText),
+ yourReference: asString(record.yourReference),
+ ourReference: asString(record.ourReference),
+ bankAccountNumber: asString(record.bankAccountNumber),
+ customerId: asNumber(customer.contactId),
+ customerName: asString(customer.name),
+ projectId: asNumber(project.projectId),
+ saleId: asNumber(sale.saleId),
+ orderReference: asString(record.orderReference),
+ invoiceDraftUuid: asString(record.invoiceDraftUuid),
+ associatedCreditNotes: Array.isArray(record.associatedCreditNotes)
+ ? record.associatedCreditNotes.filter(
+ (creditNoteId): creditNoteId is number => typeof creditNoteId === 'number'
+ )
+ : undefined,
+ lines: mappedLines,
+ invoicePdf,
+ attachments: mappedAttachments,
+ lineCount: lines.length,
+ attachmentCount: mappedAttachments.length + (invoicePdf ? 1 : 0),
+ raw: {
+ ...record,
+ lines: mappedLines
+ }
+ };
+};
+
+export let mapInvoiceDraft = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ let customers = asArray(record.customers);
+ let lines = asArray(record.lines);
+ let attachments = asArray(record.attachments);
+ let mappedCustomers = customers.map(mapContact);
+ let mappedLines = lines.map(mapInvoiceLine);
+ let mappedAttachments = attachments.map(mapAttachment);
+
+ return {
+ draftId: asNumber(record.draftId),
+ uuid: asString(record.uuid),
+ type: asString(record.type),
+ lastModifiedDate: asString(record.lastModifiedDate),
+ issueDate: asString(record.issueDate),
+ daysUntilDueDate: asNumber(record.daysUntilDueDate),
+ invoiceText: asString(record.invoiceText),
+ currency: asString(record.currency),
+ yourReference: asString(record.yourReference),
+ ourReference: asString(record.ourReference),
+ orderReference: asString(record.orderReference),
+ net: asNumber(record.net),
+ gross: asNumber(record.gross),
+ bankAccountNumber: asString(record.bankAccountNumber),
+ iban: asString(record.iban),
+ bic: asString(record.bic),
+ paymentAccount: asString(record.paymentAccount),
+ createdFromInvoiceId: asNumber(record.createdFromInvoiceId),
+ customers: mappedCustomers,
+ lines: mappedLines,
+ attachments: mappedAttachments,
+ customerCount: customers.length,
+ lineCount: lines.length,
+ projectId: asNumber(record.projectId),
+ roundingType: asString(record.roundingType),
+ attachmentCount: attachments.length,
+ raw: {
+ ...record,
+ customers: mappedCustomers,
+ lines: mappedLines,
+ attachments: mappedAttachments
+ }
+ };
+};
+
+export let mapProduct = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ productId: asNumber(record.productId),
+ name: asString(record.name),
+ productNumber: asString(record.productNumber),
+ unitPrice: asNumber(record.unitPrice),
+ incomeAccount: asString(record.incomeAccount),
+ vatType: asString(record.vatType),
+ active: asBoolean(record.active),
+ stock: asNumber(record.stock),
+ note: asString(record.note),
+ createdDate: asString(record.createdDate),
+ lastModifiedDate: asString(record.lastModifiedDate),
+ raw: record
+ };
+};
+
+export let mapProject = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ let contact = asRecord(record.contact);
+ return {
+ projectId: asNumber(record.projectId),
+ number: asString(record.number),
+ name: asString(record.name),
+ description: asString(record.description),
+ startDate: asString(record.startDate),
+ endDate: asString(record.endDate),
+ contactId: asNumber(contact.contactId),
+ contactName: asString(contact.name),
+ completed: asBoolean(record.completed),
+ raw: record
+ };
+};
+
+export let mapPurchase = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ let supplier = asRecord(record.supplier);
+ return {
+ purchaseId: asNumber(record.purchaseId),
+ transactionId: asNumber(record.transactionId),
+ identifier: asString(record.identifier),
+ date: asString(record.date),
+ dueDate: asString(record.dueDate),
+ kind: asString(record.kind),
+ paid: asBoolean(record.paid),
+ settled: asBoolean(record.settled),
+ settledDate: asString(record.settledDate),
+ currency: asString(record.currency),
+ supplierId: asNumber(supplier.contactId),
+ supplierName: asString(supplier.name),
+ lineCount: asArray(record.lines).length,
+ paymentCount: asArray(record.payments).length,
+ attachmentCount: asArray(record.purchaseAttachments).length,
+ deleted: asBoolean(record.deleted),
+ raw: record
+ };
+};
+
+export let mapSale = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ let customer = asRecord(record.customer);
+ return {
+ saleId: asNumber(record.saleId),
+ transactionId: asNumber(record.transactionId),
+ saleNumber: asString(record.saleNumber),
+ date: asString(record.date),
+ dueDate: asString(record.dueDate),
+ kind: asString(record.kind),
+ netAmount: asNumber(record.netAmount),
+ vatAmount: asNumber(record.vatAmount),
+ settled: asBoolean(record.settled),
+ settledDate: asString(record.settledDate),
+ totalPaid: asNumber(record.totalPaid),
+ outstandingBalance: asNumber(record.outstandingBalance),
+ currency: asString(record.currency),
+ customerId: asNumber(customer.contactId),
+ customerName: asString(customer.name),
+ lineCount: asArray(record.lines).length,
+ paymentCount: asArray(record.salePayments).length,
+ attachmentCount: asArray(record.saleAttachments).length,
+ writeOff: asBoolean(record.writeOff),
+ deleted: asBoolean(record.deleted),
+ raw: record
+ };
+};
+
+export let mapAccount = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ code: asString(record.code),
+ name: asString(record.name),
+ raw: record
+ };
+};
+
+export let mapAccountBalance = (value: unknown): z.infer => {
+ let record = asRecord(value);
+ return {
+ code: asString(record.code),
+ name: asString(record.name),
+ balance: asNumber(record.balance),
+ raw: record
+ };
+};
+
+export let listMetadata = (response: FikenListResponse) => ({
+ page: response.page,
+ pageSize: response.pageSize,
+ pageCount: response.pageCount,
+ resultCount: response.resultCount,
+ nextPage: response.nextPage
+});
+
+export let paginationParams = (input: { page?: number; pageSize?: number }) =>
+ pickDefined({
+ page: input.page ?? 0,
+ pageSize: input.pageSize ?? 25
+ });
+
+export let dateRangeParams = (
+ exactKey: string,
+ fromKey: string,
+ toKey: string,
+ input: { date?: string; dateFrom?: string; dateTo?: string }
+) =>
+ pickDefined({
+ [exactKey]: input.date,
+ [fromKey]: input.dateFrom,
+ [toKey]: input.dateTo
+ });
+
+export let requireInvoiceDraftLineFields = (
+ line: z.infer,
+ index: number
+) => {
+ if (line.productId !== undefined) return;
+
+ let missing = [
+ ['description', line.description],
+ ['unitPrice', line.unitPrice],
+ ['vatType', line.vatType],
+ ['incomeAccount', line.incomeAccount]
+ ]
+ .filter(([, value]) => value === undefined || value === '')
+ .map(([key]) => key);
+
+ if (missing.length > 0) {
+ throw fikenValidationError(
+ `lines[${index}] must include productId or all free-text fields: ${missing.join(', ')}.`
+ );
+ }
+};
+
+export let cleanBody = (value: Record) => pickDefined(value);
diff --git a/integrations/fiken/src/triggers/index.ts b/integrations/fiken/src/triggers/index.ts
new file mode 100644
index 0000000000..cb0ff5c3b5
--- /dev/null
+++ b/integrations/fiken/src/triggers/index.ts
@@ -0,0 +1 @@
+export {};
diff --git a/integrations/fiken/tsconfig.json b/integrations/fiken/tsconfig.json
new file mode 100644
index 0000000000..2abe727831
--- /dev/null
+++ b/integrations/fiken/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "types": ["node"],
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+ "moduleResolution": "bundler",
+
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ },
+ "include": ["src"]
+}
diff --git a/integrations/fiken/vitest.config.ts b/integrations/fiken/vitest.config.ts
new file mode 100644
index 0000000000..77375364da
--- /dev/null
+++ b/integrations/fiken/vitest.config.ts
@@ -0,0 +1,7 @@
+import { createSlatesVitestConfig } from '@slates/test/config';
+
+export default createSlatesVitestConfig({
+ test: {
+ include: ['src/**/*.test.ts']
+ }
+});
diff --git a/integrations/finago/README.md b/integrations/finago/README.md
index 8874a12994..6db7a56e88 100644
--- a/integrations/finago/README.md
+++ b/integrations/finago/README.md
@@ -1,4 +1,4 @@
-#
Finago (24SevenOffice)
+#
Finago (24SevenOffice)
Manage Finago Office accounting and ERP workflows through the 24SevenOffice REST API. The integration supports organization/profile context, chart of accounts, reference data, customers, products, sales orders, transaction lines, account balances, and accounting document upload/download metadata.
diff --git a/integrations/finago/slate.json b/integrations/finago/slate.json
index c82fa96d9d..7f496f193c 100644
--- a/integrations/finago/slate.json
+++ b/integrations/finago/slate.json
@@ -10,5 +10,5 @@
"analyze transaction lines and balances",
"upload and retrieve accounting documents"
],
- "logoUrl": "https://finago.no/hubfs/2025%20-%20Finago%20theme/Logos/logo-black.svg"
+ "logoUrl": "https://provider-logos.metorial-cdn.com/finago.svg"
}
diff --git a/integrations/finago/src/tools.customers.test.ts b/integrations/finago/src/tools.customers.test.ts
new file mode 100644
index 0000000000..52a46b84e9
--- /dev/null
+++ b/integrations/finago/src/tools.customers.test.ts
@@ -0,0 +1,39 @@
+import { ServiceError } from '@lowerdeck/error';
+import { describe, expect, it } from 'vitest';
+import { finagoListCustomers } from './tools/customers';
+
+let invokeListCustomers = (input: unknown) =>
+ finagoListCustomers.handleInvocation({
+ input,
+ auth: { token: 'token' },
+ config: {}
+ } as any);
+
+describe('Finago list customers validation', () => {
+ it('rejects sortBy values outside the documented Customer SortInput pattern', async () => {
+ await expect(invokeListCustomers({ sortBy: 'phone:asc' })).rejects.toBeInstanceOf(
+ ServiceError
+ );
+
+ await expect(invokeListCustomers({ sortBy: 'phone:asc' })).rejects.toThrow(
+ 'sortBy must be one of'
+ );
+ });
+
+ it('rejects list-only parameters when reading a customer by ID', async () => {
+ await expect(
+ invokeListCustomers({
+ customerId: 123,
+ limit: 10,
+ maxPages: 1
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ await expect(
+ invokeListCustomers({
+ customerId: 123,
+ sortBy: 'id:asc'
+ })
+ ).rejects.toThrow('Do not provide sortBy');
+ });
+});
diff --git a/integrations/finago/src/tools.products.test.ts b/integrations/finago/src/tools.products.test.ts
new file mode 100644
index 0000000000..43a2d7c3aa
--- /dev/null
+++ b/integrations/finago/src/tools.products.test.ts
@@ -0,0 +1,184 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let clientMocks = vi.hoisted(() => ({
+ post: vi.fn(),
+ patch: vi.fn()
+}));
+
+vi.mock('./lib/helpers', () => ({
+ createClientFromContext: vi.fn(() => clientMocks)
+}));
+
+import { finagoListProducts, finagoUpsertProduct } from './tools/products';
+
+let invokeListProducts = (input: unknown) =>
+ finagoListProducts.handleInvocation({
+ auth: { token: 'token' },
+ input
+ } as any);
+
+let createCtx = (input: Record) =>
+ ({
+ auth: { token: 'token' },
+ config: {},
+ input
+ }) as any;
+
+beforeEach(() => {
+ clientMocks.post.mockReset();
+ clientMocks.patch.mockReset();
+ clientMocks.post.mockResolvedValue({ id: 123, name: 'White shoe laces' });
+ clientMocks.patch.mockResolvedValue({ id: 123, name: 'Updated shoe laces' });
+});
+
+describe('Finago product tool validation', () => {
+ it('rejects list filters when reading one product by ID', async () => {
+ await expect(
+ invokeListProducts({ productId: 123, productSearch: 'shoe' })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ await expect(invokeListProducts({ productId: 123, maxPages: 1 })).rejects.toThrow(
+ 'productId cannot be combined with list filters'
+ );
+ });
+
+ it('rejects malformed comma-separated ID filters', async () => {
+ await expect(invokeListProducts({ categoryIds: '12,abc' })).rejects.toBeInstanceOf(
+ ServiceError
+ );
+
+ await expect(invokeListProducts({ supplierIds: '0' })).rejects.toBeInstanceOf(
+ ServiceError
+ );
+ });
+});
+
+describe('Finago upsert product', () => {
+ it('creates products with documented nested request body fields', async () => {
+ await finagoUpsertProduct.handleInvocation(
+ createCtx({
+ operation: 'create',
+ name: 'White shoe laces',
+ number: 'SH-1234567',
+ type: 'default',
+ status: 'active',
+ description: '1 meter long shoe laces - white',
+ categoryId: 12,
+ unitId: 2,
+ supplierId: 67890,
+ costPrice: 50,
+ salesPrice: 400,
+ indirectCost: 12.77,
+ webshopEnabled: true,
+ ean: '0123456789000',
+ eanAlternative: '0123456789012345678901234',
+ stockManaged: true,
+ stockQuantity: 146,
+ stockLocation: 'A-313',
+ supplierProductItemCode: 'T1234',
+ supplierProductNumber: '1234',
+ supplierProductName: 'Shoe laces, white, 1m',
+ supplierProductPrice: 10.5
+ })
+ );
+
+ expect(clientMocks.post).toHaveBeenCalledWith(
+ '/products',
+ {
+ name: 'White shoe laces',
+ number: 'SH-1234567',
+ type: 'default',
+ status: 'active',
+ description: '1 meter long shoe laces - white',
+ costPrice: 50,
+ salesPrice: 400,
+ indirectCost: 12.77,
+ webshopEnabled: true,
+ ean: '0123456789000',
+ eanAlternative: '0123456789012345678901234',
+ category: { id: 12 },
+ units: { id: 2 },
+ supplier: { id: 67890 },
+ stock: {
+ isManaged: true,
+ quantity: 146,
+ location: 'A-313'
+ },
+ supplierProduct: {
+ itemCode: 'T1234',
+ number: '1234',
+ name: 'Shoe laces, white, 1m',
+ price: 10.5
+ }
+ },
+ undefined,
+ 'create product'
+ );
+ });
+
+ it('updates products with documented nullable clear fields', async () => {
+ await finagoUpsertProduct.handleInvocation(
+ createCtx({
+ operation: 'update',
+ productId: 123,
+ number: null,
+ description: null,
+ costPrice: null,
+ salesPrice: null,
+ indirectCost: null,
+ unitId: null,
+ supplierId: null,
+ ean: null,
+ eanAlternative: null,
+ stockLocation: null,
+ supplierProductItemCode: null,
+ supplierProductNumber: null,
+ supplierProductName: null,
+ supplierProductPrice: null
+ })
+ );
+
+ expect(clientMocks.patch).toHaveBeenCalledWith(
+ '/products/123',
+ {
+ number: null,
+ description: null,
+ costPrice: null,
+ salesPrice: null,
+ indirectCost: null,
+ ean: null,
+ eanAlternative: null,
+ units: { id: null },
+ supplier: { id: null },
+ stock: {
+ location: null
+ },
+ supplierProduct: {
+ itemCode: null,
+ number: null,
+ name: null,
+ price: null
+ }
+ },
+ undefined,
+ 'update product'
+ );
+ });
+
+ it('rejects productId when creating a product', async () => {
+ await expect(
+ finagoUpsertProduct.handleInvocation(
+ createCtx({
+ operation: 'create',
+ productId: 123,
+ name: 'White shoe laces',
+ categoryId: 12
+ })
+ )
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(clientMocks.post).not.toHaveBeenCalled();
+ expect(clientMocks.patch).not.toHaveBeenCalled();
+ });
+});
diff --git a/integrations/finago/src/tools/accounts.test.ts b/integrations/finago/src/tools/accounts.test.ts
new file mode 100644
index 0000000000..414ea55877
--- /dev/null
+++ b/integrations/finago/src/tools/accounts.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { finagoListAccounts } from './accounts';
+
+let mocks = vi.hoisted(() => ({
+ client: {
+ get: vi.fn(),
+ list: vi.fn()
+ }
+}));
+
+vi.mock('../lib/helpers', () => ({
+ createClientFromContext: () => mocks.client
+}));
+
+describe('finago_list_accounts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('maps the documented account id and keeps the accountId alias', async () => {
+ let record = {
+ id: 1000001,
+ number: 1900,
+ name: 'Cash, NOK',
+ taxId: 0
+ };
+ mocks.client.list.mockResolvedValue({ records: [record] });
+
+ let result = await finagoListAccounts.handleInvocation({
+ input: { query: 'salary' }
+ } as any);
+
+ expect(mocks.client.list).toHaveBeenCalledWith(
+ '/accounts',
+ { query: 'salary' },
+ 1,
+ 'list accounts'
+ );
+ expect(result.output.accounts).toEqual([
+ {
+ id: 1000001,
+ accountId: 1000001,
+ number: 1900,
+ name: 'Cash, NOK',
+ taxId: 0,
+ record
+ }
+ ]);
+ expect(result.output.count).toBe(1);
+ });
+
+ it('rejects query when reading one account by id', async () => {
+ await expect(
+ finagoListAccounts.handleInvocation({
+ input: { accountId: 1000001, query: 'salary' }
+ } as any)
+ ).rejects.toThrow(
+ 'query is only supported when listing accounts; omit accountId to search accounts.'
+ );
+
+ expect(mocks.client.get).not.toHaveBeenCalled();
+ expect(mocks.client.list).not.toHaveBeenCalled();
+ });
+});
diff --git a/integrations/finago/src/tools/customers.test.ts b/integrations/finago/src/tools/customers.test.ts
new file mode 100644
index 0000000000..2a4501d606
--- /dev/null
+++ b/integrations/finago/src/tools/customers.test.ts
@@ -0,0 +1,195 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let clientMocks = vi.hoisted(() => ({
+ post: vi.fn(),
+ patch: vi.fn()
+}));
+
+vi.mock('../lib/helpers', () => ({
+ createClientFromContext: vi.fn(() => clientMocks)
+}));
+
+import { finagoUpsertCustomer } from './customers';
+
+let createCtx = (input: Record) =>
+ ({
+ input,
+ auth: { token: 'token', baseUrl: 'https://rest.api.24sevenoffice.com/v1' },
+ config: {}
+ }) as any;
+
+beforeEach(() => {
+ clientMocks.post.mockReset();
+ clientMocks.patch.mockReset();
+ clientMocks.post.mockResolvedValue({ id: 1001, name: 'Acme AS', isCompany: true });
+ clientMocks.patch.mockResolvedValue({ id: 1001, name: 'Updated Acme AS' });
+});
+
+describe('Finago upsert customer', () => {
+ it('creates a company customer without person fields', async () => {
+ await finagoUpsertCustomer.handleInvocation(
+ createCtx({
+ operation: 'create',
+ isCompany: true,
+ name: 'Acme AS',
+ organizationNumber: '123456789',
+ externalReference: 'crm-1',
+ isSupplier: false,
+ visitAddress: {
+ street: 'Hovedgata 1',
+ postalCode: '0123',
+ postalArea: 'Fornebu',
+ countrySubdivision: 'Viken',
+ countryCode: 'NO'
+ },
+ emailContact: 'contact@example.com',
+ phone: '+47-12345678'
+ })
+ );
+
+ expect(clientMocks.post).toHaveBeenCalledWith(
+ '/customers',
+ {
+ externalReference: 'crm-1',
+ isSupplier: false,
+ phone: '+47-12345678',
+ isCompany: true,
+ name: 'Acme AS',
+ organizationNumber: '123456789',
+ address: {
+ visit: {
+ street: 'Hovedgata 1',
+ postalCode: '0123',
+ postalArea: 'Fornebu',
+ countrySubdivision: 'Viken',
+ countryCode: 'NO'
+ }
+ },
+ email: {
+ contact: 'contact@example.com'
+ }
+ },
+ undefined,
+ 'create customer'
+ );
+ });
+
+ it('creates a person customer without company-only fields', async () => {
+ await finagoUpsertCustomer.handleInvocation(
+ createCtx({
+ operation: 'create',
+ isCompany: false,
+ firstName: 'Jane',
+ lastName: 'Doe',
+ mobilePhone: '+47-98765432',
+ additionalFields: {
+ id: 321
+ }
+ })
+ );
+
+ expect(clientMocks.post).toHaveBeenCalledWith(
+ '/customers',
+ {
+ mobilePhone: '+47-98765432',
+ isCompany: false,
+ person: {
+ firstName: 'Jane',
+ lastName: 'Doe'
+ },
+ id: 321
+ },
+ undefined,
+ 'create customer'
+ );
+ });
+
+ it('rejects create payloads that mix company and person variants', async () => {
+ await expect(
+ finagoUpsertCustomer.handleInvocation(
+ createCtx({
+ operation: 'create',
+ isCompany: true,
+ name: 'Acme AS',
+ firstName: 'Jane',
+ lastName: 'Doe'
+ })
+ )
+ ).rejects.toThrow(ServiceError);
+
+ await expect(
+ finagoUpsertCustomer.handleInvocation(
+ createCtx({
+ operation: 'create',
+ isCompany: false,
+ name: 'Jane Doe',
+ firstName: 'Jane',
+ lastName: 'Doe'
+ })
+ )
+ ).rejects.toThrow(ServiceError);
+ });
+
+ it('requires both firstName and lastName for person customer creation', async () => {
+ await expect(
+ finagoUpsertCustomer.handleInvocation(
+ createCtx({
+ operation: 'create',
+ isCompany: false,
+ firstName: 'Jane'
+ })
+ )
+ ).rejects.toThrow(ServiceError);
+ });
+
+ it('updates customers with documented patch fields and nullable clears', async () => {
+ await finagoUpsertCustomer.handleInvocation(
+ createCtx({
+ operation: 'update',
+ customerId: 1001,
+ name: 'Updated Acme AS',
+ externalReference: null,
+ emailBilling: null,
+ billingAddress: {
+ name: 'Billing',
+ street: 'Billinggata 2',
+ countryCode: 'NO'
+ }
+ })
+ );
+
+ expect(clientMocks.patch).toHaveBeenCalledWith(
+ '/customers/1001',
+ {
+ externalReference: null,
+ name: 'Updated Acme AS',
+ address: {
+ billing: {
+ name: 'Billing',
+ street: 'Billinggata 2',
+ countryCode: 'NO'
+ }
+ },
+ email: {
+ billing: null
+ }
+ },
+ undefined,
+ 'update customer'
+ );
+ });
+
+ it('rejects isCompany updates because Finago PATCH does not support changing customer type', async () => {
+ await expect(
+ finagoUpsertCustomer.handleInvocation(
+ createCtx({
+ operation: 'update',
+ customerId: 1001,
+ isCompany: false,
+ name: 'Jane Doe'
+ })
+ )
+ ).rejects.toThrow(ServiceError);
+ });
+});
diff --git a/integrations/finago/src/tools/documents.test.ts b/integrations/finago/src/tools/documents.test.ts
new file mode 100644
index 0000000000..0cf2dcbd4e
--- /dev/null
+++ b/integrations/finago/src/tools/documents.test.ts
@@ -0,0 +1,289 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ finagoGetDocument,
+ finagoGetFileUploadStatus,
+ finagoUploadTransactionFile
+} from './documents';
+
+let mocks = vi.hoisted(() => ({
+ client: {
+ get: vi.fn(),
+ post: vi.fn(),
+ downloadUrl: vi.fn(),
+ putBinaryUrl: vi.fn()
+ }
+}));
+
+vi.mock('../lib/helpers', () => ({
+ createClientFromContext: () => mocks.client
+}));
+
+describe('finago_upload_transaction_file', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('initiates the documented /fileUpload request and uploads to the presigned URL', async () => {
+ let uploadUrl = 'https://attachment.api.24sevenoffice.com/upload/abc123?signature=secret';
+ mocks.client.post.mockResolvedValue({
+ uploadMethod: 'put',
+ uploadUrl,
+ fileId: 67890
+ });
+ mocks.client.putBinaryUrl.mockResolvedValue({ byteLength: 5 });
+
+ let result = await finagoUploadTransactionFile.handleInvocation({
+ input: {
+ contentType: ' application/pdf ',
+ contentBase64: ' SGVsbG8= ',
+ fileName: 'receipt.pdf'
+ }
+ } as any);
+
+ expect(mocks.client.post).toHaveBeenCalledWith(
+ '/fileUpload',
+ { contentType: 'application/pdf' },
+ undefined,
+ 'initiate file upload'
+ );
+ expect(mocks.client.putBinaryUrl).toHaveBeenCalledWith({
+ url: uploadUrl,
+ method: 'PUT',
+ contentType: 'application/pdf',
+ contentBase64: 'SGVsbG8='
+ });
+ expect(result.output).toEqual({
+ fileId: '67890',
+ uploadMethod: 'PUT',
+ byteLength: 5,
+ fileName: 'receipt.pdf',
+ contentType: 'application/pdf',
+ record: {
+ fileId: '67890',
+ uploadMethod: 'PUT'
+ }
+ });
+ expect(JSON.stringify(result.output)).not.toContain('signature=secret');
+ });
+
+ it('validates content before creating a Finago upload artifact', async () => {
+ await expect(
+ finagoUploadTransactionFile.handleInvocation({
+ input: {
+ contentType: 'application/pdf',
+ contentBase64: 'not base64!'
+ }
+ } as any)
+ ).rejects.toThrow(ServiceError);
+
+ await expect(
+ finagoUploadTransactionFile.handleInvocation({
+ input: {
+ contentType: 'applicationpdf',
+ contentBase64: 'SGVsbG8='
+ }
+ } as any)
+ ).rejects.toThrow(ServiceError);
+
+ expect(mocks.client.post).not.toHaveBeenCalled();
+ expect(mocks.client.putBinaryUrl).not.toHaveBeenCalled();
+ });
+
+ it('requires the documented upload response fields before uploading bytes', async () => {
+ mocks.client.post.mockResolvedValue({
+ uploadUrl: 'https://attachment.api.24sevenoffice.com/upload/abc123',
+ fileId: 'file-123'
+ });
+
+ await expect(
+ finagoUploadTransactionFile.handleInvocation({
+ input: {
+ contentType: 'application/pdf',
+ contentBase64: 'SGVsbG8='
+ }
+ } as any)
+ ).rejects.toThrow('Finago did not return uploadMethod required by /fileUpload.');
+
+ expect(mocks.client.putBinaryUrl).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid presigned upload URLs returned by Finago', async () => {
+ mocks.client.post.mockResolvedValue({
+ uploadMethod: 'PUT',
+ uploadUrl: 'http://attachment.api.24sevenoffice.com/upload/abc123',
+ fileId: 'file-123'
+ });
+
+ await expect(
+ finagoUploadTransactionFile.handleInvocation({
+ input: {
+ contentType: 'application/pdf',
+ contentBase64: 'SGVsbG8='
+ }
+ } as any)
+ ).rejects.toThrow('Finago returned an invalid uploadUrl for /fileUpload.');
+
+ expect(mocks.client.putBinaryUrl).not.toHaveBeenCalled();
+ });
+});
+
+describe('finago_get_file_upload_status', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('passes the required fileId path parameter and maps the documented response', async () => {
+ let record = {
+ fileId: 67890,
+ status: 'Completed',
+ documentId: 12345
+ };
+ mocks.client.get.mockResolvedValue(record);
+
+ let result = await finagoGetFileUploadStatus.handleInvocation({
+ input: { fileId: ' 67890 ' }
+ } as any);
+
+ expect(mocks.client.get).toHaveBeenCalledWith(
+ '/fileUpload/67890',
+ undefined,
+ 'get file upload status'
+ );
+ expect(result.output).toEqual({
+ fileId: '67890',
+ status: 'Completed',
+ documentId: 12345,
+ record
+ });
+ });
+
+ it('rejects an empty fileId before calling Finago', async () => {
+ await expect(
+ finagoGetFileUploadStatus.handleInvocation({
+ input: { fileId: ' ' }
+ } as any)
+ ).rejects.toThrow(ServiceError);
+
+ expect(mocks.client.get).not.toHaveBeenCalled();
+ });
+
+ it('rejects a response that omits the documented required status', async () => {
+ mocks.client.get.mockResolvedValue({ fileId: 'file-123' });
+
+ await expect(
+ finagoGetFileUploadStatus.handleInvocation({
+ input: { fileId: 'file-123' }
+ } as any)
+ ).rejects.toThrow('Finago did not return a file upload status.');
+ });
+});
+
+describe('finago_get_document', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('passes the documented documentId path parameter and maps document metadata', async () => {
+ let record = {
+ documentId: 12345,
+ contentType: 'application/pdf',
+ downloadUrl: 'https://attachment.api.24sevenoffice.com/download/abc123?signature=xyz',
+ pages: [
+ {
+ sequenceNumber: 1,
+ thumbnailUrl: 'https://attachment.api.24sevenoffice.com/thumbnail/abc123?page=1',
+ previewUrl: 'https://attachment.api.24sevenoffice.com/preview/abc123?page=1'
+ }
+ ]
+ };
+ mocks.client.get.mockResolvedValue(record);
+
+ let result = await finagoGetDocument.handleInvocation({
+ input: { documentId: 12345 }
+ } as any);
+
+ expect(mocks.client.get).toHaveBeenCalledWith(
+ '/documents/12345',
+ undefined,
+ 'get document'
+ );
+ expect(mocks.client.downloadUrl).not.toHaveBeenCalled();
+ expect(result.output).toEqual({
+ documentId: 12345,
+ contentType: 'application/pdf',
+ downloadUrl: 'https://attachment.api.24sevenoffice.com/download/abc123?signature=xyz',
+ pages: [
+ {
+ sequenceNumber: 1,
+ thumbnailUrl: 'https://attachment.api.24sevenoffice.com/thumbnail/abc123?page=1',
+ previewUrl: 'https://attachment.api.24sevenoffice.com/preview/abc123?page=1'
+ }
+ ],
+ byteLength: undefined,
+ attachmentCount: 0,
+ record
+ });
+ expect(result.output).not.toHaveProperty('contentBase64');
+ expect(result.attachments).toEqual([]);
+ });
+
+ it('downloads through the documented downloadUrl and returns file bytes only as an attachment', async () => {
+ let record = {
+ documentId: 12345,
+ contentType: 'application/pdf',
+ downloadUrl: 'https://attachment.api.24sevenoffice.com/download/abc123?signature=xyz'
+ };
+ mocks.client.get.mockResolvedValue(record);
+ mocks.client.downloadUrl.mockResolvedValue({
+ contentBase64: 'JVBERi0=',
+ byteLength: 5,
+ contentType: 'application/pdf'
+ });
+
+ let result = await finagoGetDocument.handleInvocation({
+ input: { documentId: 12345, download: true }
+ } as any);
+
+ expect(mocks.client.downloadUrl).toHaveBeenCalledWith(
+ record.downloadUrl,
+ 'application/pdf'
+ );
+ expect(result.output.byteLength).toBe(5);
+ expect(result.output.attachmentCount).toBe(1);
+ expect(result.output).not.toHaveProperty('contentBase64');
+ expect(result.attachments).toHaveLength(1);
+ });
+
+ it('throws ServiceError when download is requested but Finago omits downloadUrl', async () => {
+ mocks.client.get.mockResolvedValue({
+ documentId: 12345,
+ contentType: 'application/pdf'
+ });
+
+ await expect(
+ finagoGetDocument.handleInvocation({
+ input: { documentId: 12345, download: true }
+ } as any)
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.downloadUrl).not.toHaveBeenCalled();
+ });
+
+ it('throws ServiceError when Finago returns malformed page metadata', async () => {
+ mocks.client.get.mockResolvedValue({
+ documentId: 12345,
+ contentType: 'application/pdf',
+ downloadUrl: 'https://attachment.api.24sevenoffice.com/download/abc123?signature=xyz',
+ pages: [{ sequenceNumber: 1 }]
+ });
+
+ await expect(
+ finagoGetDocument.handleInvocation({
+ input: { documentId: 12345 }
+ } as any)
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.downloadUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/integrations/finago/src/tools/ledger.test.ts b/integrations/finago/src/tools/ledger.test.ts
new file mode 100644
index 0000000000..48ed0a1c28
--- /dev/null
+++ b/integrations/finago/src/tools/ledger.test.ts
@@ -0,0 +1,472 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ finagoGetAccountBalances,
+ finagoListTransactionLines,
+ finagoPostTransaction
+} from './ledger';
+
+let mocks = vi.hoisted(() => ({
+ client: {
+ get: vi.fn(),
+ list: vi.fn(),
+ post: vi.fn()
+ }
+}));
+
+vi.mock('../lib/helpers', () => ({
+ createClientFromContext: () => mocks.client
+}));
+
+describe('finago_list_transaction_lines', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('passes documented filters and pagination to /transactionlines', async () => {
+ let record = { id: '123e4567-e89b-12d3-a456-426614174001' };
+ mocks.client.list.mockResolvedValue({
+ records: [record],
+ count: 1,
+ pageCount: 2,
+ hasNextPage: true,
+ nextLink: 'https://rest.api.24sevenoffice.com/v1/transactionlines?page=3'
+ });
+
+ let result = await finagoListTransactionLines.handleInvocation({
+ input: {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ createdFrom: '2024-01-02T03:04:05Z',
+ modifiedFrom: '2024-01-03T04:05:06+01:00',
+ transactionId: '123e4567-e89b-12d3-a456-426614174002',
+ transactionNumber: 1001,
+ transactionTypeId: 5001,
+ customerId: 6001,
+ accountId: 1000001,
+ accountNumber: 1900,
+ invoiceNumber: 'INV-2024-001',
+ currencyCode: 'NOK',
+ includeDimensions: true,
+ page: 2,
+ limit: 10,
+ maxPages: 3
+ }
+ } as any);
+
+ expect(mocks.client.list).toHaveBeenCalledWith(
+ '/transactionlines',
+ {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ createdFrom: '2024-01-02T03:04:05Z',
+ modifiedFrom: '2024-01-03T04:05:06+01:00',
+ transactionId: '123e4567-e89b-12d3-a456-426614174002',
+ transactionNumber: 1001,
+ transactionTypeId: 5001,
+ customerId: 6001,
+ accountId: 1000001,
+ accountNumber: 1900,
+ invoiceNumber: 'INV-2024-001',
+ currencyCode: 'NOK',
+ includeDimensions: true,
+ page: 2,
+ limit: 10
+ },
+ 3,
+ 'list transaction lines'
+ );
+ expect(result.output).toEqual({
+ transactionLines: [record],
+ count: 1,
+ pageCount: 2,
+ hasNextPage: true,
+ nextLink: 'https://rest.api.24sevenoffice.com/v1/transactionlines?page=3'
+ });
+ });
+
+ it('rejects malformed required date range before calling Finago', async () => {
+ await expect(
+ finagoListTransactionLines.handleInvocation({
+ input: {
+ dateFrom: '2024-02-30',
+ dateTo: '2024-03-01'
+ }
+ } as any)
+ ).rejects.toThrow('dateFrom must be a valid date in YYYY-MM-DD format.');
+
+ expect(mocks.client.list).not.toHaveBeenCalled();
+ });
+
+ it('requires the exclusive end date to be later than the start date', async () => {
+ await expect(
+ finagoListTransactionLines.handleInvocation({
+ input: {
+ dateFrom: '2024-02-01',
+ dateTo: '2024-02-01'
+ }
+ } as any)
+ ).rejects.toThrow(
+ 'dateTo must be later than dateFrom because Finago treats dateTo as exclusive.'
+ );
+
+ expect(mocks.client.list).not.toHaveBeenCalled();
+ });
+
+ it('rejects malformed created and modified date-time filters before calling Finago', async () => {
+ await expect(
+ finagoListTransactionLines.handleInvocation({
+ input: {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ createdFrom: '2024-01-01'
+ }
+ } as any)
+ ).rejects.toThrow('createdFrom must be a valid ISO 8601 date-time.');
+
+ await expect(
+ finagoListTransactionLines.handleInvocation({
+ input: {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ modifiedFrom: 'not-a-date-time'
+ }
+ } as any)
+ ).rejects.toThrow('modifiedFrom must be a valid ISO 8601 date-time.');
+
+ expect(mocks.client.list).not.toHaveBeenCalled();
+ });
+
+ it('rejects malformed transactionId before calling Finago', async () => {
+ await expect(
+ finagoListTransactionLines.handleInvocation({
+ input: {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ transactionId: 'not-a-uuid'
+ }
+ } as any)
+ ).rejects.toThrow('transactionId must be a valid UUID.');
+
+ expect(mocks.client.list).not.toHaveBeenCalled();
+ });
+});
+
+describe('finago_get_account_balances', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('passes documented filters to account-specific /accountbalances/{id}', async () => {
+ let record = {
+ account: { id: 1000001, number: 1900, name: 'Cash, NOK' },
+ balances: [{ date: '2024-01-01', opening: 50000, closing: 55000, change: 5000 }]
+ };
+ mocks.client.get.mockResolvedValue([record]);
+
+ let result = await finagoGetAccountBalances.handleInvocation({
+ input: {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ accountId: 1000001,
+ periods: '2024-01-01,2024-02-01Z',
+ type: 'Period',
+ keepIncoming: true
+ }
+ } as any);
+
+ expect(mocks.client.get).toHaveBeenCalledWith(
+ '/accountbalances/1000001',
+ {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ periods: '2024-01-01,2024-02-01Z',
+ type: 'Period',
+ keepIncoming: true
+ },
+ 'get account balances'
+ );
+ expect(result.output).toEqual({
+ balances: [record],
+ count: 1,
+ beginningAt: undefined,
+ endingAt: undefined,
+ fiscals: undefined
+ });
+ });
+
+ it('normalizes documented HAL account balance responses', async () => {
+ let record = {
+ account: { id: 1000002, number: 1920, name: 'Bank Account' },
+ balances: [{ date: '2024-01-01', opening: 80000, closing: 85000, change: 5000 }]
+ };
+ let fiscals = [{ startingDate: '2024-01-01', endingAt: '2024-12-31' }];
+ mocks.client.get.mockResolvedValue({
+ _embedded: { records: [record] },
+ beginningAt: '2024-01-01',
+ endingAt: '2024-02-01',
+ fiscals
+ });
+
+ let result = await finagoGetAccountBalances.handleInvocation({
+ input: {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01'
+ }
+ } as any);
+
+ expect(mocks.client.get).toHaveBeenCalledWith(
+ '/accountbalances',
+ {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ periods: undefined,
+ type: undefined,
+ keepIncoming: undefined
+ },
+ 'get account balances'
+ );
+ expect(result.output).toEqual({
+ balances: [record],
+ count: 1,
+ beginningAt: '2024-01-01',
+ endingAt: '2024-02-01',
+ fiscals
+ });
+ });
+
+ it('rejects malformed dates and periods before calling Finago', async () => {
+ await expect(
+ finagoGetAccountBalances.handleInvocation({
+ input: {
+ dateFrom: '2024-02-30',
+ dateTo: '2024-03-01'
+ }
+ } as any)
+ ).rejects.toThrow('dateFrom must be a valid date in YYYY-MM-DD format.');
+
+ await expect(
+ finagoGetAccountBalances.handleInvocation({
+ input: {
+ dateFrom: '2024-03-01',
+ dateTo: '2024-02-01'
+ }
+ } as any)
+ ).rejects.toThrow('dateTo must be the same as or later than dateFrom.');
+
+ await expect(
+ finagoGetAccountBalances.handleInvocation({
+ input: {
+ dateFrom: '2024-01-01',
+ dateTo: '2024-02-01',
+ periods: '2024-01-01,2024-02-30'
+ }
+ } as any)
+ ).rejects.toThrow(
+ 'periods must be a comma-separated list of valid dates in YYYY-MM-DD format'
+ );
+
+ expect(mocks.client.get).not.toHaveBeenCalled();
+ });
+});
+
+describe('finago_post_transaction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('posts the documented /transactions request body and returns transactionId', async () => {
+ let record = { transactionId: 'abc123' };
+ mocks.client.post.mockResolvedValue(record);
+
+ let result = await finagoPostTransaction.handleInvocation({
+ input: {
+ confirm: true,
+ transactionTypeNumber: 1,
+ date: '2025-04-09',
+ comment: 'Invoice #12345',
+ documentId: 12345,
+ lines: [
+ {
+ accountNumber: 1920,
+ amount: 1500,
+ taxNumber: 0,
+ comment: 'Payment for services',
+ periodDate: '2025-04-01',
+ currencyCode: 'USD',
+ currencyRate: 10.5,
+ dimensions: [{ dimensionType: 1, value: '13' }],
+ invoiceNumber: 'INV-12345',
+ invoiceDueDate: '2025-05-05',
+ invoiceRemittanceReference: '1234567890',
+ invoiceBankAccount: '1234.56.78901'
+ },
+ {
+ accountNumber: 3000,
+ amount: -1500,
+ taxNumber: 1,
+ taxAmount: -300,
+ taxBaseRate: 50,
+ taxSpecificationNumber: 10
+ }
+ ],
+ additionalFields: {
+ externalReference: 'slates-test-transaction'
+ }
+ }
+ } as any);
+
+ expect(mocks.client.post).toHaveBeenCalledWith(
+ '/transactions',
+ {
+ transactionTypeNumber: 1,
+ date: '2025-04-09',
+ comment: 'Invoice #12345',
+ documentId: 12345,
+ lines: [
+ {
+ accountNumber: 1920,
+ amount: 1500,
+ tax: { number: 0 },
+ comment: 'Payment for services',
+ periodDate: '2025-04-01',
+ dimensions: [{ dimensionType: 1, value: '13' }],
+ currency: { code: 'USD', rate: 10.5 },
+ invoice: {
+ number: 'INV-12345',
+ dueDate: '2025-05-05',
+ remittanceReference: '1234567890',
+ bankAccount: '1234.56.78901'
+ }
+ },
+ {
+ accountNumber: 3000,
+ amount: -1500,
+ tax: {
+ number: 1,
+ amount: -300,
+ baseRate: 50,
+ specificationNumber: 10
+ }
+ }
+ ],
+ externalReference: 'slates-test-transaction'
+ },
+ undefined,
+ 'post transaction'
+ );
+ expect(result.output).toEqual({ transactionId: 'abc123', record });
+ });
+
+ it('requires explicit confirmation before posting', async () => {
+ await expect(
+ finagoPostTransaction.handleInvocation({
+ input: {
+ confirm: false,
+ transactionTypeNumber: 1,
+ date: '2025-04-09',
+ lines: [
+ { accountNumber: 1920, amount: 100, taxNumber: 0 },
+ { accountNumber: 3000, amount: -100, taxNumber: 0 }
+ ]
+ }
+ } as any)
+ ).rejects.toThrow('confirm must be true to post a transaction.');
+
+ expect(mocks.client.post).not.toHaveBeenCalled();
+ });
+
+ it('requires lines to balance to zero per effective transaction date', async () => {
+ await expect(
+ finagoPostTransaction.handleInvocation({
+ input: {
+ confirm: true,
+ transactionTypeNumber: 1,
+ date: '2025-04-09',
+ lines: [
+ { accountNumber: 1920, amount: 100, taxNumber: 0 },
+ { accountNumber: 3000, amount: -100, taxNumber: 0, date: '2025-04-10' }
+ ]
+ }
+ } as any)
+ ).rejects.toThrow(
+ 'Transaction lines must balance to zero per date; 2025-04-09 balances to 100.'
+ );
+
+ expect(mocks.client.post).not.toHaveBeenCalled();
+ });
+
+ it('validates documented date and currency constraints before posting', async () => {
+ await expect(
+ finagoPostTransaction.handleInvocation({
+ input: {
+ confirm: true,
+ transactionTypeNumber: 1,
+ date: '2025-02-30',
+ lines: [
+ { accountNumber: 1920, amount: 100, taxNumber: 0 },
+ { accountNumber: 3000, amount: -100, taxNumber: 0 }
+ ]
+ }
+ } as any)
+ ).rejects.toThrow('date must be a valid date in YYYY-MM-DD format.');
+
+ await expect(
+ finagoPostTransaction.handleInvocation({
+ input: {
+ confirm: true,
+ transactionTypeNumber: 1,
+ date: '2025-04-09',
+ lines: [
+ { accountNumber: 1920, amount: 100, taxNumber: 0, currencyCode: 'US' },
+ { accountNumber: 3000, amount: -100, taxNumber: 0 }
+ ]
+ }
+ } as any)
+ ).rejects.toThrow(
+ 'lines[0].currencyCode and lines[0].currencyRate must be provided together.'
+ );
+
+ expect(mocks.client.post).not.toHaveBeenCalled();
+ });
+
+ it('rejects additionalFields that override validated transaction fields', async () => {
+ await expect(
+ finagoPostTransaction.handleInvocation({
+ input: {
+ confirm: true,
+ transactionTypeNumber: 1,
+ date: '2025-04-09',
+ lines: [
+ { accountNumber: 1920, amount: 100, taxNumber: 0 },
+ { accountNumber: 3000, amount: -100, taxNumber: 0 }
+ ],
+ additionalFields: {
+ lines: []
+ }
+ }
+ } as any)
+ ).rejects.toThrow(
+ 'lines cannot be supplied in additionalFields when posting a transaction.'
+ );
+
+ expect(mocks.client.post).not.toHaveBeenCalled();
+ });
+
+ it('requires the documented transactionId response', async () => {
+ mocks.client.post.mockResolvedValue({});
+
+ await expect(
+ finagoPostTransaction.handleInvocation({
+ input: {
+ confirm: true,
+ transactionTypeNumber: 1,
+ date: '2025-04-09',
+ lines: [
+ { accountNumber: 1920, amount: 100, taxNumber: 0 },
+ { accountNumber: 3000, amount: -100, taxNumber: 0 }
+ ]
+ }
+ } as any)
+ ).rejects.toThrow('Finago did not return transactionId for the posted transaction.');
+ });
+});
diff --git a/integrations/finago/src/tools/profile.test.ts b/integrations/finago/src/tools/profile.test.ts
new file mode 100644
index 0000000000..c274fd4894
--- /dev/null
+++ b/integrations/finago/src/tools/profile.test.ts
@@ -0,0 +1,112 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let mocks = vi.hoisted(() => ({
+ get: vi.fn()
+}));
+
+vi.mock('../lib/helpers', () => ({
+ createClientFromContext: () => ({
+ get: mocks.get
+ })
+}));
+
+import { finagoGetProfile } from './profile';
+
+let invokeTool = async (input: Record) =>
+ await finagoGetProfile.handleInvocation({
+ input,
+ auth: { token: 'token' },
+ config: {}
+ } as any);
+
+let responseForPath = (path: string) => {
+ if (path === '/me') return { id: 'profile-id' };
+ if (path === '/organization/information') return { id: 123, name: 'Current organization' };
+ if (path === '/me/identifiers') return [{ id: 'identifier-id' }];
+ if (path === '/me/licenses') return [{ id: 'license-id' }];
+ if (path === '/organization/people') return [{ id: 456 }];
+ if (path.includes('/organization')) return { id: 'license-organization-id' };
+ throw new Error(`Unexpected path ${path}`);
+};
+
+describe('finago_get_profile', () => {
+ beforeEach(() => {
+ mocks.get.mockReset();
+ mocks.get.mockImplementation(async (path: string) => responseForPath(path));
+ });
+
+ it('reads the profile and documented current organization endpoint by default', async () => {
+ let result = await invokeTool({});
+
+ expect(result.output.profile).toEqual({ id: 'profile-id' });
+ expect(result.output.organization).toEqual({ id: 123, name: 'Current organization' });
+ expect(mocks.get).toHaveBeenCalledTimes(2);
+ expect(mocks.get).toHaveBeenNthCalledWith(
+ 1,
+ '/me',
+ { thumb: undefined, bigthumb: undefined, maxAge: undefined },
+ 'read profile'
+ );
+ expect(mocks.get).toHaveBeenNthCalledWith(
+ 2,
+ '/organization/information',
+ undefined,
+ 'read organization'
+ );
+ });
+
+ it('forwards documented profile, identifier, license, and people parameters', async () => {
+ let result = await invokeTool({
+ thumb: true,
+ bigthumb: true,
+ maxAge: 60,
+ identifierType: 'email',
+ identifierStatus: 'Confirmed',
+ licenseOrganizationId: 123,
+ licensePersonId: 456,
+ personType: 'Client',
+ licenseId: '11111111-1111-4111-8111-111111111111'
+ });
+
+ expect(result.output.identifiers).toEqual([{ id: 'identifier-id' }]);
+ expect(result.output.licenses).toEqual([{ id: 'license-id' }]);
+ expect(result.output.people).toEqual([{ id: 456 }]);
+ expect(result.output.peopleCount).toBe(1);
+ expect(mocks.get).toHaveBeenNthCalledWith(
+ 1,
+ '/me',
+ { thumb: true, bigthumb: true, maxAge: 60 },
+ 'read profile'
+ );
+ expect(mocks.get).toHaveBeenNthCalledWith(
+ 2,
+ '/me/licenses/11111111-1111-4111-8111-111111111111/organization',
+ undefined,
+ 'read license organization'
+ );
+ expect(mocks.get).toHaveBeenNthCalledWith(
+ 3,
+ '/me/identifiers',
+ { type: 'email', status: 'Confirmed' },
+ 'read identifiers'
+ );
+ expect(mocks.get).toHaveBeenNthCalledWith(
+ 4,
+ '/me/licenses',
+ { organizationId: 123, personId: 456 },
+ 'read licenses'
+ );
+ expect(mocks.get).toHaveBeenNthCalledWith(
+ 5,
+ '/organization/people',
+ { personType: 'Client' },
+ 'read people'
+ );
+ });
+
+ it('throws ServiceError for invalid license IDs before calling Finago', async () => {
+ await expect(invokeTool({ licenseId: 'not-a-uuid' })).rejects.toBeInstanceOf(ServiceError);
+ expect(mocks.get).not.toHaveBeenCalled();
+ });
+});
diff --git a/integrations/finago/src/tools/reference-data.test.ts b/integrations/finago/src/tools/reference-data.test.ts
new file mode 100644
index 0000000000..52e85e1d1e
--- /dev/null
+++ b/integrations/finago/src/tools/reference-data.test.ts
@@ -0,0 +1,132 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let finagoClientMocks = vi.hoisted(() => ({
+ get: vi.fn(),
+ list: vi.fn()
+}));
+
+vi.mock('../lib/helpers', () => ({
+ createClientFromContext: vi.fn(() => finagoClientMocks)
+}));
+
+import { finagoListReferenceData } from './reference-data';
+
+let createCtx = (input: Record) =>
+ ({
+ input,
+ auth: { token: 'token' },
+ config: {}
+ }) as any;
+
+beforeEach(() => {
+ finagoClientMocks.get.mockReset();
+ finagoClientMocks.list.mockReset();
+});
+
+describe('Finago reference data tool', () => {
+ it('wraps documented single-record endpoint responses in list output', async () => {
+ finagoClientMocks.get.mockResolvedValue({ id: 7, name: 'Retail sales' });
+
+ let result = await finagoListReferenceData.handleInvocation(
+ createCtx({
+ referenceType: 'sales_types',
+ id: 7
+ })
+ );
+
+ expect(finagoClientMocks.get).toHaveBeenCalledWith(
+ '/salestypes/7',
+ undefined,
+ 'read sales_types'
+ );
+ expect(finagoClientMocks.list).not.toHaveBeenCalled();
+ expect(result.output).toEqual({
+ records: [{ id: 7, name: 'Retail sales' }],
+ count: 1,
+ pageCount: 1,
+ hasNextPage: false
+ });
+ });
+
+ it('uses documented query parameters for dimension element list requests', async () => {
+ finagoClientMocks.list.mockResolvedValue({
+ records: [{ dimensionType: 1, value: 'P-1', name: 'Project 1' }],
+ count: 1,
+ pageCount: 1,
+ hasNextPage: false
+ });
+
+ await finagoListReferenceData.handleInvocation(
+ createCtx({
+ referenceType: 'dimension_elements',
+ dimensionType: 1,
+ limit: 50,
+ continuationToken: 'next-token',
+ maxPages: 3
+ })
+ );
+
+ expect(finagoClientMocks.list).toHaveBeenCalledWith(
+ '/dimensions/1/elements',
+ { limit: 50, continuationToken: 'next-token' },
+ 3,
+ 'read dimension_elements'
+ );
+ expect(finagoClientMocks.get).not.toHaveBeenCalled();
+ });
+
+ it('uses the documented pricelist prices path and productIds filter', async () => {
+ finagoClientMocks.list.mockResolvedValue({
+ records: [{ productId: 123, price: 99.99 }],
+ count: 1,
+ pageCount: 1,
+ hasNextPage: false
+ });
+
+ await finagoListReferenceData.handleInvocation(
+ createCtx({
+ referenceType: 'price_list_prices',
+ id: 2,
+ productIds: '1..10,20'
+ })
+ );
+
+ expect(finagoClientMocks.list).toHaveBeenCalledWith(
+ '/pricelists/2/prices',
+ { productIds: '1..10,20' },
+ 1,
+ 'read price_list_prices'
+ );
+ });
+
+ it('rejects branch-specific inputs that the selected endpoint does not support', async () => {
+ await expect(
+ finagoListReferenceData.handleInvocation(
+ createCtx({
+ referenceType: 'taxes',
+ productIds: '1'
+ })
+ )
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ await expect(
+ finagoListReferenceData.handleInvocation(
+ createCtx({
+ referenceType: 'price_list_prices'
+ })
+ )
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ await expect(
+ finagoListReferenceData.handleInvocation(
+ createCtx({
+ referenceType: 'dimension_elements',
+ dimensionType: 1,
+ value: 'P-1',
+ limit: 10
+ })
+ )
+ ).rejects.toBeInstanceOf(ServiceError);
+ });
+});
diff --git a/integrations/finago/src/tools/sales-orders.test.ts b/integrations/finago/src/tools/sales-orders.test.ts
new file mode 100644
index 0000000000..3f36cd60a7
--- /dev/null
+++ b/integrations/finago/src/tools/sales-orders.test.ts
@@ -0,0 +1,625 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let mocks = vi.hoisted(() => ({
+ client: {
+ get: vi.fn(),
+ list: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn()
+ }
+}));
+
+vi.mock('../lib/helpers', () => ({
+ createClientFromContext: () => mocks.client
+}));
+
+import {
+ finagoCreateSalesOrder,
+ finagoGetSalesOrder,
+ finagoInvoiceSalesOrder,
+ finagoListSalesOrders
+} from './sales-orders';
+
+let invokeListSalesOrders = (input: Record) =>
+ finagoListSalesOrders.handleInvocation({
+ input,
+ auth: { token: 'token' },
+ config: {}
+ } as any);
+
+let invokeGetSalesOrder = (input: Record) =>
+ finagoGetSalesOrder.handleInvocation({
+ input,
+ auth: { token: 'token' },
+ config: {}
+ } as any);
+
+let invokeCreateSalesOrder = (input: Record) =>
+ finagoCreateSalesOrder.handleInvocation({
+ input,
+ auth: { token: 'token' },
+ config: {}
+ } as any);
+
+let invokeInvoiceSalesOrder = (input: Record) =>
+ finagoInvoiceSalesOrder.handleInvocation({
+ input,
+ auth: { token: 'token' },
+ config: {}
+ } as any);
+
+describe('finago_list_sales_orders', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('uses the documented GET /salesorders query parameters', async () => {
+ mocks.client.list.mockResolvedValue({
+ records: [],
+ pageCount: 1,
+ hasNextPage: true,
+ nextLink:
+ 'https://rest.api.24sevenoffice.com/v1/salesorders?continuationToken=next-token'
+ });
+
+ let result = await invokeListSalesOrders({
+ limit: 25,
+ continuationToken: 'start-token',
+ date: '2024-01-15',
+ dateFrom: '2024-01-01',
+ dateTo: '2024-01-31',
+ status: 'Confirmed',
+ customerId: '12345',
+ invoiceNumber: '9876',
+ createdFrom: '2024-01-01T00:00:00Z',
+ createdTo: '2024-01-31T23:59:59Z',
+ modifiedFrom: '2024-02-01T00:00:00Z',
+ modifiedTo: '2024-02-29T23:59:59Z',
+ maxPages: 2
+ });
+
+ expect(mocks.client.list).toHaveBeenCalledWith(
+ '/salesorders',
+ {
+ limit: 25,
+ continuationToken: 'start-token',
+ date: '2024-01-15',
+ dateFrom: '2024-01-01',
+ dateTo: '2024-01-31',
+ status: 'Confirmed',
+ customerId: '12345',
+ invoiceNumber: '9876',
+ createdFrom: '2024-01-01T00:00:00Z',
+ createdTo: '2024-01-31T23:59:59Z',
+ modifiedFrom: '2024-02-01T00:00:00Z',
+ modifiedTo: '2024-02-29T23:59:59Z'
+ },
+ 2,
+ 'list sales orders'
+ );
+ expect(result.output).toMatchObject({
+ salesOrders: [],
+ count: 0,
+ pageCount: 1,
+ hasNextPage: true,
+ nextLink:
+ 'https://rest.api.24sevenoffice.com/v1/salesorders?continuationToken=next-token'
+ });
+ });
+
+ it('uses documented sales order ID paths when lines and attachments are included', async () => {
+ let salesOrder = {
+ id: 1234,
+ customer: { id: 5678, name: 'ABC Corporation' },
+ status: 'Confirmed',
+ date: '2024-01-15',
+ invoice: { number: 9876 },
+ createdAt: '2024-01-15T10:00:00Z',
+ modifiedAt: '2024-01-16T11:00:00Z'
+ };
+ let line = { id: 11, type: 'text', description: 'Implementation services' };
+ let attachment = {
+ fileId: 'file-123',
+ orderId: 1234,
+ fileName: 'attachment.pdf',
+ mediaType: 'application/pdf'
+ };
+
+ mocks.client.list
+ .mockResolvedValueOnce({
+ records: [salesOrder],
+ pageCount: 1,
+ hasNextPage: false
+ })
+ .mockResolvedValueOnce({
+ records: [line],
+ pageCount: 1,
+ hasNextPage: false
+ })
+ .mockResolvedValueOnce({
+ records: [attachment],
+ pageCount: 1,
+ hasNextPage: false
+ });
+
+ let result = await invokeListSalesOrders({
+ includeLines: true,
+ includeAttachments: true
+ });
+
+ expect(mocks.client.list).toHaveBeenNthCalledWith(
+ 1,
+ '/salesorders',
+ {
+ limit: undefined,
+ continuationToken: undefined,
+ date: undefined,
+ dateFrom: undefined,
+ dateTo: undefined,
+ status: undefined,
+ customerId: undefined,
+ invoiceNumber: undefined,
+ createdFrom: undefined,
+ createdTo: undefined,
+ modifiedFrom: undefined,
+ modifiedTo: undefined
+ },
+ 1,
+ 'list sales orders'
+ );
+ expect(mocks.client.list).toHaveBeenNthCalledWith(
+ 2,
+ '/salesorders/1234/lines',
+ undefined,
+ 1,
+ 'list sales order lines'
+ );
+ expect(mocks.client.list).toHaveBeenNthCalledWith(
+ 3,
+ '/salesorders/1234/attachments',
+ undefined,
+ 1,
+ 'list sales order attachments'
+ );
+ expect(result.output.salesOrders[0]).toMatchObject({
+ salesOrderId: 1234,
+ status: 'Confirmed',
+ customerId: 5678,
+ customerName: 'ABC Corporation',
+ date: '2024-01-15',
+ invoiceNumber: 9876,
+ createdAt: '2024-01-15T10:00:00Z',
+ modifiedAt: '2024-01-16T11:00:00Z',
+ lines: [line],
+ attachments: [attachment]
+ });
+ });
+
+ it('throws ServiceError when include flags require a documented integer sales order ID path', async () => {
+ mocks.client.list.mockResolvedValueOnce({
+ records: [{ status: 'Draft' }],
+ pageCount: 1,
+ hasNextPage: false
+ });
+
+ await expect(invokeListSalesOrders({ includeLines: true })).rejects.toBeInstanceOf(
+ ServiceError
+ );
+ expect(mocks.client.list).toHaveBeenCalledTimes(1);
+
+ mocks.client.list.mockResolvedValueOnce({
+ records: [{ id: 2_147_483_648, status: 'Draft' }],
+ pageCount: 1,
+ hasNextPage: false
+ });
+
+ await expect(invokeListSalesOrders({ includeAttachments: true })).rejects.toBeInstanceOf(
+ ServiceError
+ );
+ expect(mocks.client.list).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('finago_create_sales_order', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('posts documented sales order and line request bodies', async () => {
+ let order = {
+ id: 1234,
+ customer: { id: 1001, name: 'ABC Corporation' },
+ status: 'Draft',
+ date: '2026-06-01'
+ };
+ let productLine = { id: 501, type: 'product', product: { id: 2001 } };
+ let textLine = { id: 502, type: 'text', description: 'Gift wrapping' };
+ mocks.client.post
+ .mockResolvedValueOnce(order)
+ .mockResolvedValueOnce(productLine)
+ .mockResolvedValueOnce(textLine);
+
+ let result = await invokeCreateSalesOrder({
+ customerId: 1001,
+ customerName: 'ABC Corporation',
+ customerOrganizationNumber: '123456789',
+ customerInvoiceEmailAddresses: ['billing@example.com'],
+ customerGln: '1234567890123',
+ customerStreet: 'Hovedgata 1',
+ customerPostalCode: '0123',
+ customerPostalArea: 'Fornebu',
+ customerCity: 'Fornebu',
+ customerCountrySubdivision: 'Viken',
+ customerCountryCode: 'NO',
+ status: 'Draft',
+ date: '2026-06-01',
+ deliveryDate: '2026-06-10',
+ deliveryCustomer: {
+ id: 1001,
+ name: 'Warehouse',
+ street: 'Delivery road 2',
+ postalCode: '0456',
+ postalArea: 'Oslo',
+ city: 'Oslo',
+ countrySubdivision: 'Oslo',
+ countryCode: 'NO'
+ },
+ currencyCode: 'USD',
+ currencyRate: 1,
+ memo: 'Customer memo',
+ internalMemo: 'Internal memo',
+ referenceNumber: 'PO12345',
+ paymentMethodId: null,
+ salesTypeId: -100,
+ invoiceDate: '2026-06-01',
+ invoiceDueDate: '2026-06-15',
+ invoiceDistributionMethod: 'emaildistribution',
+ invoiceRemittanceReference: 'KID123',
+ invoicePaymentTermsType: 'NumberOfDays',
+ invoicePaymentTermsDays: 14,
+ accrual: { startDate: '2026-06-01', length: 12 },
+ yourReferenceId: 44,
+ yourReferenceName: 'John Doe',
+ ourReferenceId: 55,
+ dimensions: [{ dimensionType: 1, value: '13', name: 'Project with ID 13' }],
+ lines: [
+ {
+ type: 'product',
+ productId: 2001,
+ productNumber: 'IGNORED-BY-FINAGO',
+ description: 'Leather handbag with adjustable strap.',
+ quantity: 2,
+ price: 49.99,
+ costPrice: null,
+ discountRate: 10,
+ taxId: 1,
+ taxNumber: 1,
+ taxRate: 25,
+ accountId: 200001,
+ accountNumber: 1500,
+ accountName: 'Accounts Receivable',
+ isHidden: false,
+ dimensions: [{ dimensionType: 1, value: '13', name: 'Project with ID 13' }],
+ accrual: {}
+ },
+ {
+ type: 'text',
+ description: 'Gift wrapping',
+ quantity: 1,
+ price: 4.99
+ }
+ ]
+ });
+
+ expect(mocks.client.post).toHaveBeenNthCalledWith(
+ 1,
+ '/salesorders',
+ {
+ status: 'Draft',
+ date: '2026-06-01',
+ deliveryDate: '2026-06-10',
+ memo: 'Customer memo',
+ internalMemo: 'Internal memo',
+ referenceNumber: 'PO12345',
+ dimensions: [{ dimensionType: 1, value: '13', name: 'Project with ID 13' }],
+ accrual: { startDate: '2026-06-01', length: 12 },
+ customer: {
+ id: 1001,
+ name: 'ABC Corporation',
+ organizationNumber: '123456789',
+ invoiceEmailAddresses: ['billing@example.com'],
+ gln: '1234567890123',
+ street: 'Hovedgata 1',
+ postalCode: '0123',
+ postalArea: 'Fornebu',
+ city: 'Fornebu',
+ countrySubdivision: 'Viken',
+ countryCode: 'NO'
+ },
+ currency: { code: 'USD', rate: 1 },
+ deliveryCustomer: {
+ id: 1001,
+ name: 'Warehouse',
+ street: 'Delivery road 2',
+ postalCode: '0456',
+ postalArea: 'Oslo',
+ city: 'Oslo',
+ countrySubdivision: 'Oslo',
+ countryCode: 'NO'
+ },
+ paymentMethod: { id: null },
+ salesType: { id: -100 },
+ invoice: {
+ date: '2026-06-01',
+ dueDate: '2026-06-15',
+ distributionMethod: 'emaildistribution',
+ remittanceReference: 'KID123',
+ paymentTerms: { type: 'NumberOfDays', value: 14 }
+ },
+ yourReference: { id: 44, name: 'John Doe' },
+ ourReference: { id: 55 }
+ },
+ undefined,
+ 'create sales order'
+ );
+ expect(mocks.client.post).toHaveBeenNthCalledWith(
+ 2,
+ '/salesorders/1234/lines',
+ {
+ type: 'product',
+ description: 'Leather handbag with adjustable strap.',
+ quantity: 2,
+ price: 49.99,
+ costPrice: null,
+ discountRate: 10,
+ isHidden: false,
+ dimensions: [{ dimensionType: 1, value: '13', name: 'Project with ID 13' }],
+ accrual: {},
+ product: { id: 2001 },
+ tax: { id: 1, number: 1, rate: 25 },
+ account: { id: 200001, number: 1500, name: 'Accounts Receivable' }
+ },
+ undefined,
+ 'create sales order line'
+ );
+ expect(mocks.client.post).toHaveBeenNthCalledWith(
+ 3,
+ '/salesorders/1234/lines',
+ {
+ type: 'text',
+ description: 'Gift wrapping',
+ quantity: 1,
+ price: 4.99
+ },
+ undefined,
+ 'create sales order line'
+ );
+ expect(result.output).toMatchObject({
+ salesOrderId: 1234,
+ lineCount: 2,
+ lines: [productLine, textLine]
+ });
+ });
+
+ it('validates local line bodies before creating the upstream sales order', async () => {
+ await expect(
+ invokeCreateSalesOrder({
+ customerId: 1001,
+ customerName: 'ABC Corporation',
+ lines: [{ type: 'product', productNumber: 'SKU-001' }]
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.post).not.toHaveBeenCalled();
+ });
+
+ it('rejects additionalFields conflicts before creating the upstream sales order', async () => {
+ await expect(
+ invokeCreateSalesOrder({
+ customerId: 1001,
+ customerName: 'ABC Corporation',
+ additionalFields: {
+ customer: { id: 2002, name: 'Override' }
+ }
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.post).not.toHaveBeenCalled();
+ });
+});
+
+describe('finago_invoice_sales_order', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('patches status and documented invoice fields to invoice a sales order', async () => {
+ mocks.client.patch.mockResolvedValue({
+ id: 1234,
+ status: 'Invoice',
+ invoice: {
+ number: 9876,
+ date: '2024-02-01',
+ dueDate: '2024-02-15',
+ distributionMethod: 'emaildistribution',
+ paymentTerms: { type: 'NumberOfDays', value: 14 },
+ remittanceReference: 'KID123',
+ transaction: { id: '63293496-884f-4358-b489-f641fe51cdaa' }
+ }
+ });
+
+ let result = await invokeInvoiceSalesOrder({
+ salesOrderId: 1234,
+ confirm: true,
+ invoiceDate: '2024-02-01',
+ invoiceDueDate: '2024-02-15',
+ invoiceDistributionMethod: 'emaildistribution',
+ invoiceRemittanceReference: 'KID123',
+ invoicePaymentTermsType: 'NumberOfDays',
+ invoicePaymentTermsDays: 14,
+ additionalFields: {
+ ourReference: { id: 44 }
+ }
+ });
+
+ expect(mocks.client.patch).toHaveBeenCalledWith(
+ '/salesorders/1234',
+ {
+ status: 'Invoice',
+ invoice: {
+ date: '2024-02-01',
+ dueDate: '2024-02-15',
+ distributionMethod: 'emaildistribution',
+ remittanceReference: 'KID123',
+ paymentTerms: { type: 'NumberOfDays', value: 14 }
+ },
+ ourReference: { id: 44 }
+ },
+ undefined,
+ 'invoice sales order'
+ );
+ expect(result.output).toMatchObject({
+ salesOrderId: 1234,
+ status: 'Invoice',
+ invoiceNumber: 9876,
+ invoiceDate: '2024-02-01',
+ invoiceDueDate: '2024-02-15',
+ invoiceDistributionMethod: 'emaildistribution',
+ invoiceRemittanceReference: 'KID123',
+ invoicePaymentTerms: { type: 'NumberOfDays', value: 14 },
+ invoiceTransactionId: '63293496-884f-4358-b489-f641fe51cdaa'
+ });
+ });
+
+ it('requires explicit confirmation before invoicing', async () => {
+ await expect(
+ invokeInvoiceSalesOrder({
+ salesOrderId: 1234,
+ confirm: false
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.patch).not.toHaveBeenCalled();
+ });
+
+ it('requires a documented int32 sales order path ID', async () => {
+ await expect(
+ invokeInvoiceSalesOrder({
+ salesOrderId: 2_147_483_648,
+ confirm: true
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.patch).not.toHaveBeenCalled();
+ });
+
+ it('rejects incompatible invoice payment term fields with ServiceError', async () => {
+ await expect(
+ invokeInvoiceSalesOrder({
+ salesOrderId: 1234,
+ confirm: true,
+ invoicePaymentTermsType: 'FixedDate',
+ invoicePaymentTermsDays: 14
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.patch).not.toHaveBeenCalled();
+ });
+});
+
+describe('finago_get_sales_order', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('reads the documented sales order, lines, and attachment endpoints', async () => {
+ let order = {
+ id: 1234,
+ customer: { id: 1001, name: 'Acme AS' },
+ status: 'Invoice',
+ date: '2026-06-01',
+ invoice: { number: 9001 },
+ grossAmount: 125,
+ netAmount: 100,
+ taxAmount: 25
+ };
+ let line = {
+ id: 987,
+ type: 'product',
+ product: { id: 10, number: 'PROD-10' },
+ description: 'Consulting',
+ quantity: 1,
+ price: 100
+ };
+ let attachment = {
+ fileId: 'file-123',
+ orderId: 1234,
+ fileName: 'invoice.pdf',
+ mediaType: 'application/pdf',
+ size: 2048,
+ timestamp: '2026-06-01T12:00:00Z',
+ tags: ['invoice']
+ };
+
+ mocks.client.get.mockResolvedValue(order);
+ mocks.client.list
+ .mockResolvedValueOnce({ records: [line] })
+ .mockResolvedValueOnce({ records: [attachment] });
+
+ let result = await invokeGetSalesOrder({
+ salesOrderId: 1234,
+ includeLines: true,
+ includeAttachments: true
+ });
+
+ expect(mocks.client.get).toHaveBeenCalledWith(
+ '/salesorders/1234',
+ undefined,
+ 'read sales order'
+ );
+ expect(mocks.client.list).toHaveBeenNthCalledWith(
+ 1,
+ '/salesorders/1234/lines',
+ undefined,
+ 1,
+ 'list sales order lines'
+ );
+ expect(mocks.client.list).toHaveBeenNthCalledWith(
+ 2,
+ '/salesorders/1234/attachments',
+ undefined,
+ 1,
+ 'list sales order attachments'
+ );
+ expect(result.output).toEqual({
+ salesOrderId: 1234,
+ status: 'Invoice',
+ customerId: 1001,
+ customerName: 'Acme AS',
+ date: '2026-06-01',
+ invoiceNumber: 9001,
+ grossAmount: 125,
+ netAmount: 100,
+ taxAmount: 25,
+ createdAt: undefined,
+ modifiedAt: undefined,
+ lines: [line],
+ attachments: [attachment],
+ record: order,
+ lineCount: 1,
+ attachmentCount: 1
+ });
+ });
+
+ it('rejects sales order IDs outside the documented int32 path range', async () => {
+ await expect(
+ invokeGetSalesOrder({
+ salesOrderId: 2_147_483_648
+ })
+ ).rejects.toBeInstanceOf(ServiceError);
+
+ expect(mocks.client.get).not.toHaveBeenCalled();
+ expect(mocks.client.list).not.toHaveBeenCalled();
+ });
+});
diff --git a/integrations/ifs-applications/README.md b/integrations/ifs-applications/README.md
new file mode 100644
index 0000000000..6d7ced0300
--- /dev/null
+++ b/integrations/ifs-applications/README.md
@@ -0,0 +1,35 @@
+#
IFS Cloud / IFS Applications
+
+Discover IFS Cloud / IFS Applications projection APIs, export tenant OpenAPI documents, and query bounded OData records from enabled projections.
+
+## Authentication
+
+IFS Cloud uses OAuth 2.0 through IAM clients. This integration supports service integrations with the OAuth 2.0 client credentials flow. Provide the tenant token URL, client ID, client secret, and optional scope from the IFS IAM client configuration.
+
+Configure the tenant `baseUrl` separately in integration config. Use the root tenant URL, such as `https://example.ifscloud.com` or `https://example.com:48080`, without `/main`, `/int`, or `/b2b`.
+
+## Tools
+
+### List API Projections
+
+List projection metadata from `AllProjections.svc/Projections`. Use this first to discover which Premium, Integration, Standard, or entity-service APIs are enabled for the tenant.
+
+### Export Projection OpenAPI
+
+Export an OpenAPI v3 or v2 document for one projection. The JSON is returned as a Slate attachment so large schemas do not appear inline in tool output.
+
+### Query Projection Records
+
+Query one entity set from a selected projection with bounded OData `$select`, `$filter`, `$orderby`, `$top`, and skip-token pagination. Defaults to the `int` projection endpoint, with `main` and `b2b` available when API Explorer, OpenAPI, or a service URL shows that route. Projection and entity-set names are strict identifiers to prevent arbitrary URL access.
+
+## Notes
+
+IFS API availability is tenant and release dependent. This initial package intentionally exposes discovery and generic read-only querying only. Customer, supplier, order, invoice, project, inventory, and write tools should be added after a customer-provided API Explorer or `$openapi` export confirms the exact projection and entity-set names.
+
+## License
+
+This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE).
+
+
diff --git a/integrations/ifs-applications/docs/SPEC.md b/integrations/ifs-applications/docs/SPEC.md
new file mode 100644
index 0000000000..0e6d74f66b
--- /dev/null
+++ b/integrations/ifs-applications/docs/SPEC.md
@@ -0,0 +1,65 @@
+# Slates Specification for IFS Cloud / IFS Applications
+
+## Overview
+
+IFS Cloud exposes REST APIs as OData projection services. The customer's wording may be "IFS Applications", but current public technical documentation describes IFS Cloud. This integration uses the current IFS Cloud endpoint model and starts with read-only discovery tools because projection names and entity sets are tenant-specific.
+
+## Authentication
+
+Authentication uses an IFS IAM OAuth 2.0 client credentials flow for service integrations.
+
+- Token endpoint: tenant-specific IAM token URL.
+- Token request body: `grant_type=client_credentials`, `client_id`, `client_secret`, and optional `scope`.
+- API authorization header: `Authorization: Bearer `.
+- The client refreshes short-lived client-credentials tokens during tool invocation when the stored `expiresAt` is expired or near expiry.
+
+Basic authentication is intentionally not implemented for this package because IFS documentation describes it as an upgrade convenience, not the recommended integration method.
+
+## Configuration
+
+- `baseUrl`: required tenant base URL. Do not include `/main`, `/int`, or `/b2b`.
+- `defaultCompany`: optional default IFS company value for future business tools.
+- `defaultSite`: optional default IFS site value for future business tools.
+- `apiRelease`: optional tenant release label, such as `26R1`, for operator context.
+- `defaultPageSize`: optional default page size for bounded list/query tools. Defaults to 50.
+
+## Endpoint Behavior
+
+The client constructs only the documented IFS paths:
+
+- Projection discovery: `/main/ifsapplications/projection/v1/AllProjections.svc/Projections`
+- Projection service root: `//ifsapplications/projection/v1/.svc`
+- Projection OpenAPI export: `/int/ifsapplications/projection/v1/.svc/$openapi`
+- Entity-set query: `//ifsapplications/projection/v1/.svc/`
+
+Projection names, entity set names, and selected field names must be simple OData identifiers. They cannot contain slashes, URLs, or path traversal. OData filter and order-by expressions are sent only as query parameters.
+
+## Pagination
+
+List/query tools request bounded pages only. `top` is capped at 100 and defaults to `defaultPageSize` or 50. OData next links are normalized into `nextPageToken`; pass that value as `skipToken` on the next call.
+
+## Implemented Tools
+
+### `list_api_projections`
+
+Lists projection metadata from `AllProjections.svc/Projections`, defaulting to the Integration API category. Supports `apiClass`, `category`, `nameContains`, `top`, and `skipToken`.
+
+### `export_projection_openapi`
+
+Exports one projection's OpenAPI JSON as a Slate text attachment. Supports OpenAPI `v3` and `v2`.
+
+### `query_projection_records`
+
+Queries one entity set from a projection. Supports optional `projectionEndpoint` (`main`, `int`, or `b2b`; defaults to `int`), `$select`, `$filter`, `$orderby`, `$top`, `$count`, and skip-token pagination. Returns bounded records and pagination metadata.
+
+## Deferred Scope
+
+Business tools for customers, suppliers, customer orders, purchase orders, invoices, projects, inventory parts, and master-data writes are deferred until tenant OpenAPI exports confirm exact projection/entity names and safe behavior in a non-production tenant.
+
+## Primary References
+
+- IFS Cloud Technical Documentation 26R1: https://docs.ifs.com/techdocs/26r1/
+- API Documentation and `$openapi`: https://docs.ifs.com/techdocs/26r1/040_tailoring/300_extensibility/010_get_started/100_api_documentation/
+- API Explorer overview: https://docs.ifs.com/techdocs/26r1/040_tailoring/300_extensibility/020_api_explorer/
+- IFS OData Provider: https://docs.ifs.com/techdocs/26r1/040_tailoring/300_extensibility/040_ifs_odata/
+- External integration authentication: https://docs.ifs.com/techdocs/26r1/030_administration/010_security/040_iam_settings/035_iam_clients/020_authenticate_external_integration/
diff --git a/integrations/ifs-applications/package.json b/integrations/ifs-applications/package.json
new file mode 100644
index 0000000000..6ff2a31bd7
--- /dev/null
+++ b/integrations/ifs-applications/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@slates-integrations/ifs-applications",
+ "main": "src/index.ts",
+ "type": "module",
+ "scripts": {
+ "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s",
+ "test": "vitest run --passWithNoTests",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2"
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2"
+ },
+ "version": "0.1.1-rc.2"
+}
diff --git a/integrations/ifs-applications/slate.json b/integrations/ifs-applications/slate.json
new file mode 100644
index 0000000000..ea9a52041d
--- /dev/null
+++ b/integrations/ifs-applications/slate.json
@@ -0,0 +1,16 @@
+{
+ "name": "@ifs/applications",
+ "description": "Discover IFS Cloud / IFS Applications projection APIs, export tenant OpenAPI documents, and query bounded OData records from enabled projections.",
+ "categories": [
+ "apis-and-http-requests",
+ "financial-data-and-stock-market",
+ "crm-and-sales-tools"
+ ],
+ "skills": [
+ "discover IFS Cloud integration projections",
+ "export IFS projection OpenAPI schemas",
+ "query IFS Cloud OData projection records",
+ "inspect tenant-specific ERP API capabilities"
+ ],
+ "logoUrl": "https://ifs-p-001.sitecorecontenthub.cloud/api/public/content/ifs_logo_negative_rgb-1.svg-ac1100?v=1194de8e"
+}
diff --git a/integrations/ifs-applications/src/auth.ts b/integrations/ifs-applications/src/auth.ts
new file mode 100644
index 0000000000..a4881ac5c9
--- /dev/null
+++ b/integrations/ifs-applications/src/auth.ts
@@ -0,0 +1,63 @@
+import { SlateAuth } from 'slates';
+import { z } from 'zod';
+import { normalizeAbsoluteUrl, requestIfsAccessToken } from './lib/oauth';
+
+export let auth = SlateAuth.create()
+ .output(
+ z.object({
+ token: z.string(),
+ tokenType: z.string().optional(),
+ expiresAt: z.string().optional(),
+ refreshToken: z.string().optional(),
+ tokenUrl: z.string(),
+ clientId: z.string(),
+ clientSecret: z.string(),
+ scope: z.string().optional()
+ })
+ )
+ .addCustomAuth({
+ type: 'auth.custom',
+ name: 'OAuth2 Client Credentials',
+ key: 'client_credentials',
+ inputSchema: z.object({
+ tokenUrl: z.string().describe('IFS IAM OAuth2 token endpoint URL for the tenant.'),
+ clientId: z.string().describe('IFS IAM client ID for the service integration.'),
+ clientSecret: z.string().describe('IFS IAM client secret for the service integration.'),
+ scope: z
+ .string()
+ .optional()
+ .describe('Optional OAuth scope string required by the IFS IAM client.')
+ }),
+ getOutput: async ctx => {
+ let tokenUrl = normalizeAbsoluteUrl(ctx.input.tokenUrl, 'IFS token URL');
+ let token = await requestIfsAccessToken({
+ tokenUrl,
+ clientId: ctx.input.clientId,
+ clientSecret: ctx.input.clientSecret,
+ scope: ctx.input.scope
+ });
+
+ return {
+ output: {
+ ...token,
+ tokenUrl,
+ clientId: ctx.input.clientId,
+ clientSecret: ctx.input.clientSecret,
+ scope: token.scope ?? ctx.input.scope
+ }
+ };
+ },
+ getProfile: async (ctx: {
+ output: {
+ clientId: string;
+ tokenUrl: string;
+ };
+ }) => {
+ return {
+ profile: {
+ id: ctx.output.clientId,
+ name: 'IFS IAM Client'
+ }
+ };
+ }
+ });
diff --git a/integrations/ifs-applications/src/config.ts b/integrations/ifs-applications/src/config.ts
new file mode 100644
index 0000000000..cdd154fe7c
--- /dev/null
+++ b/integrations/ifs-applications/src/config.ts
@@ -0,0 +1,31 @@
+import { SlateConfig } from 'slates';
+import { z } from 'zod';
+
+export let config = SlateConfig.create(
+ z.object({
+ baseUrl: z
+ .string()
+ .describe(
+ 'Base URL for the IFS Cloud tenant, without /main or /int (for example, https://example.ifscloud.com).'
+ ),
+ defaultCompany: z
+ .string()
+ .optional()
+ .describe('Optional default IFS company for future company-scoped business tools.'),
+ defaultSite: z
+ .string()
+ .optional()
+ .describe('Optional default IFS site for future site-scoped business tools.'),
+ apiRelease: z
+ .string()
+ .optional()
+ .describe('Optional IFS Cloud release label for operator context, such as 26R1.'),
+ defaultPageSize: z
+ .number()
+ .int()
+ .min(1)
+ .max(100)
+ .optional()
+ .describe('Default number of rows to request for bounded projection queries.')
+ })
+);
diff --git a/integrations/ifs-applications/src/index.ts b/integrations/ifs-applications/src/index.ts
new file mode 100644
index 0000000000..72f9152f87
--- /dev/null
+++ b/integrations/ifs-applications/src/index.ts
@@ -0,0 +1,9 @@
+import { Slate } from 'slates';
+import { spec } from './spec';
+import { exportProjectionOpenApi, listApiProjections, queryProjectionRecords } from './tools';
+
+export let provider = Slate.create({
+ spec,
+ tools: [listApiProjections, exportProjectionOpenApi, queryProjectionRecords],
+ triggers: []
+});
diff --git a/integrations/ifs-applications/src/lib/client.test.ts b/integrations/ifs-applications/src/lib/client.test.ts
new file mode 100644
index 0000000000..054a677b62
--- /dev/null
+++ b/integrations/ifs-applications/src/lib/client.test.ts
@@ -0,0 +1,216 @@
+import { ServiceError } from '@lowerdeck/error';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+let axiosMocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn()
+ },
+ createAuthenticatedAxios: vi.fn()
+}));
+
+vi.mock('slates', async importOriginal => {
+ let actual = await importOriginal();
+
+ return {
+ ...actual,
+ createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios
+ };
+});
+
+import {
+ IfsApplicationsClient,
+ nextPageTokenFromLink,
+ paginationParamsFromSkipToken
+} from './client';
+
+let createClient = () =>
+ new IfsApplicationsClient({
+ baseUrl: 'https://tenant.example.com',
+ auth: {
+ token: 'access-token'
+ },
+ defaultPageSize: 25
+ });
+
+beforeEach(() => {
+ axiosMocks.api.get.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReset();
+ axiosMocks.createAuthenticatedAxios.mockReturnValue(axiosMocks.api);
+});
+
+describe('IFS Applications OData pagination helpers', () => {
+ it('extracts skip tokens from relative next links', () => {
+ expect(
+ nextPageTokenFromLink(
+ '/int/ifsapplications/projection/v1/Foo.svc/Things?$skiptoken=abc%20123'
+ )
+ ).toBe('abc 123');
+
+ expect(
+ paginationParamsFromSkipToken(
+ '/int/ifsapplications/projection/v1/Foo.svc/Things?$skiptoken=abc%20123'
+ )
+ ).toEqual({ $skiptoken: 'abc 123' });
+ });
+
+ it('round-trips offset pagination as an opaque next page token', () => {
+ expect(
+ nextPageTokenFromLink(
+ 'https://tenant.example.com/int/ifsapplications/projection/v1/Foo.svc/Things?$skip=100'
+ )
+ ).toBe('skip:100');
+
+ expect(paginationParamsFromSkipToken('skip:100')).toEqual({ $skip: '100' });
+ expect(
+ paginationParamsFromSkipToken(
+ 'https://tenant.example.com/int/ifsapplications/projection/v1/Foo.svc/Things?$skip=100'
+ )
+ ).toEqual({ $skip: '100' });
+ });
+
+ it('keeps bare opaque tokens as skip tokens', () => {
+ expect(paginationParamsFromSkipToken('opaque-token==')).toEqual({
+ $skiptoken: 'opaque-token=='
+ });
+ });
+});
+
+describe('IfsApplicationsClient listApiProjections', () => {
+ it('uses entity service URLs for standard entity API summaries', async () => {
+ axiosMocks.api.get.mockResolvedValue({
+ data: {
+ value: [
+ {
+ Name: 'AccountEntity',
+ Categories: 'EntityService',
+ Description: 'Account entity service'
+ }
+ ]
+ }
+ });
+
+ let client = new IfsApplicationsClient({
+ baseUrl: 'https://tenant.example.com',
+ auth: {
+ token: 'token'
+ }
+ });
+
+ let result = await client.listApiProjections({
+ apiClass: 'standardEntity',
+ top: 5
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/main/ifsapplications/projection/v1/AllProjections.svc/Projections',
+ {
+ params: expect.objectContaining({
+ $format: 'json',
+ $filter: "Categories eq 'StandardEntity'",
+ $top: 5
+ })
+ }
+ );
+ expect(result.projections).toHaveLength(1);
+ expect(result.projections[0]).toMatchObject({
+ name: 'AccountEntity',
+ category: 'EntityService',
+ serviceUrl:
+ 'https://tenant.example.com/int/ifsapplications/entity/v1/AccountEntity.svc/',
+ openApiUrl:
+ 'https://tenant.example.com/int/ifsapplications/entity/v1/AccountEntity.svc/$openapi'
+ });
+ });
+});
+
+describe('IfsApplicationsClient queryProjectionRecords', () => {
+ it('queries the selected projection endpoint with OData query parameters', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: [{ Id: 'A1', Name: 'Acme' }],
+ '@odata.count': 1,
+ '@odata.nextLink':
+ '/main/ifsapplications/projection/v1/CustomerHandling.svc/Customers?$skiptoken=next%201'
+ }
+ });
+
+ let result = await createClient().queryProjectionRecords({
+ projectionName: 'CustomerHandling',
+ projectionEndpoint: 'main',
+ entitySet: 'Customers',
+ select: ['Id', 'Name'],
+ filter: "Name eq 'Acme'",
+ orderBy: 'Name asc',
+ top: 5,
+ includeCount: true
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/main/ifsapplications/projection/v1/CustomerHandling.svc/Customers',
+ {
+ params: {
+ $format: 'json',
+ $select: 'Id,Name',
+ $filter: "Name eq 'Acme'",
+ $orderby: 'Name asc',
+ $top: 5,
+ $count: true
+ }
+ }
+ );
+ expect(result).toEqual({
+ records: [{ Id: 'A1', Name: 'Acme' }],
+ count: 1,
+ nextPageToken: 'next 1'
+ });
+ });
+
+ it('defaults projection record queries to the integration endpoint', async () => {
+ axiosMocks.api.get.mockResolvedValueOnce({
+ data: {
+ value: []
+ }
+ });
+
+ await createClient().queryProjectionRecords({
+ projectionName: 'CustomerHandling',
+ entitySet: 'Customers'
+ });
+
+ expect(axiosMocks.api.get).toHaveBeenCalledWith(
+ '/int/ifsapplications/projection/v1/CustomerHandling.svc/Customers',
+ {
+ params: {
+ $format: 'json',
+ $select: undefined,
+ $filter: undefined,
+ $orderby: undefined,
+ $top: 25,
+ $count: undefined
+ }
+ }
+ );
+ });
+
+ it('rejects unsupported projection endpoints with ServiceError', async () => {
+ await expect(
+ createClient().queryProjectionRecords({
+ projectionName: 'CustomerHandling',
+ projectionEndpoint: 'svc' as never,
+ entitySet: 'Customers'
+ })
+ ).rejects.toThrow(ServiceError);
+ });
+
+ it('rejects base URLs that include an IFS endpoint segment', () => {
+ expect(
+ () =>
+ new IfsApplicationsClient({
+ baseUrl: 'https://tenant.example.com/b2b',
+ auth: {
+ token: 'access-token'
+ }
+ })
+ ).toThrow(ServiceError);
+ });
+});
diff --git a/integrations/ifs-applications/src/lib/client.ts b/integrations/ifs-applications/src/lib/client.ts
new file mode 100644
index 0000000000..d1a3498ea5
--- /dev/null
+++ b/integrations/ifs-applications/src/lib/client.ts
@@ -0,0 +1,582 @@
+import { createAuthenticatedAxios, requestAxios, requestAxiosData } from 'slates';
+import { ifsApplicationsApiError, ifsApplicationsServiceError } from './errors';
+import { normalizeAbsoluteUrl, requestIfsAccessToken } from './oauth';
+
+export type ApiClass = 'premium' | 'integration' | 'standard' | 'standardEntity';
+export let projectionEndpoints = ['main', 'int', 'b2b'] as const;
+export type ProjectionEndpoint = (typeof projectionEndpoints)[number];
+
+export type IfsAuthState = {
+ token: string;
+ tokenType?: string;
+ expiresAt?: string;
+ refreshToken?: string;
+ tokenUrl?: string;
+ clientId?: string;
+ clientSecret?: string;
+ scope?: string;
+};
+
+export type ProjectionSummary = {
+ name?: string;
+ apiClass?: string;
+ category?: string;
+ description?: string;
+ version?: string;
+ source?: string;
+ entitySetCount?: number;
+ serviceUrl?: string;
+ openApiUrl?: string;
+ metadata: Record;
+};
+
+export type ProjectionListResult = {
+ projections: ProjectionSummary[];
+ nextPageToken?: string;
+};
+
+export type ProjectionQueryResult = {
+ records: Record[];
+ count?: number;
+ nextPageToken?: string;
+};
+
+type IfsApiResourceKind = 'projection' | 'entity';
+
+let IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
+let TOKEN_EXPIRY_SKEW_MS = 60_000;
+
+let apiClassToCategory: Record = {
+ premium: 'Premium',
+ integration: 'Integration',
+ standard: 'Standard',
+ standardEntity: 'StandardEntity'
+};
+
+let apiClassAliases: Record = {
+ premium: ['premium'],
+ integration: ['integration'],
+ standard: ['standard'],
+ standardEntity: ['standardentity', 'standard entity', 'entityservice', 'entity service']
+};
+
+let escapeODataString = (value: string) => value.replace(/'/g, "''");
+
+let isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+let firstString = (record: Record, keys: string[]) => {
+ for (let key of keys) {
+ let value = record[key];
+ if (typeof value === 'string' && value.trim()) return value.trim();
+ }
+
+ return undefined;
+};
+
+let firstNumber = (record: Record, keys: string[]) => {
+ for (let key of keys) {
+ let value = record[key];
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
+ return Number(value);
+ }
+ }
+
+ return undefined;
+};
+
+let categoryValue = (record: Record) => {
+ let categories =
+ record.Categories ?? record.categories ?? record.Category ?? record.category;
+
+ if (Array.isArray(categories)) {
+ let values = categories.filter((value): value is string => typeof value === 'string');
+ return values.length > 0 ? values.join(', ') : undefined;
+ }
+
+ return typeof categories === 'string' && categories.trim() ? categories.trim() : undefined;
+};
+
+let stringValues = (value: unknown) => {
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
+
+ if (Array.isArray(value)) {
+ return value.filter((item): item is string => typeof item === 'string' && !!item.trim());
+ }
+
+ return [];
+};
+
+let scalarMetadata = (record: Record) => {
+ let metadata: Record = {};
+
+ for (let [key, value] of Object.entries(record)) {
+ if (Object.keys(metadata).length >= 30) break;
+ if (
+ value === null ||
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ ) {
+ metadata[key] = value;
+ }
+ }
+
+ return metadata;
+};
+
+let normalizeComparable = (value: string | undefined) =>
+ value?.toLowerCase().replace(/[^a-z0-9]+/g, '');
+
+let matchesStandardEntityMarker = (value: string | undefined) => {
+ let normalized = normalizeComparable(value);
+ if (!normalized) return false;
+
+ return apiClassAliases.standardEntity.some(alias => {
+ let normalizedAlias = normalizeComparable(alias);
+ return normalizedAlias
+ ? normalized === normalizedAlias || normalized.includes(normalizedAlias)
+ : false;
+ });
+};
+
+let apiResourceKindForProjectionRecord = (
+ record: Record
+): IfsApiResourceKind => {
+ let values = [
+ ...stringValues(record.ApiClass),
+ ...stringValues(record.APIClass),
+ ...stringValues(record.apiClass),
+ ...stringValues(record.Class),
+ ...stringValues(record.Categories),
+ ...stringValues(record.categories),
+ ...stringValues(record.Category),
+ ...stringValues(record.category)
+ ];
+
+ return values.some(matchesStandardEntityMarker) ? 'entity' : 'projection';
+};
+
+let matchesApiClass = (record: ProjectionSummary, apiClass: ApiClass | undefined) => {
+ if (!apiClass) return true;
+
+ let aliases = apiClassAliases[apiClass];
+ let values = [
+ record.apiClass,
+ record.category,
+ record.metadata.ApiClass,
+ record.metadata.APIClass,
+ record.metadata.Categories,
+ record.metadata.Category
+ ]
+ .map(value => (typeof value === 'string' ? value : undefined))
+ .filter((value): value is string => value !== undefined);
+
+ if (values.length === 0) return true;
+
+ return values.some(value => {
+ let normalized = normalizeComparable(value);
+ return aliases.some(alias => normalized === normalizeComparable(alias));
+ });
+};
+
+let extractRecords = (data: unknown) => {
+ if (isRecord(data)) {
+ if (Array.isArray(data.value)) {
+ return data.value.filter(isRecord);
+ }
+
+ let d = data.d;
+ if (isRecord(d) && Array.isArray(d.results)) {
+ return d.results.filter(isRecord);
+ }
+ }
+
+ return [];
+};
+
+let extractCount = (data: unknown) => {
+ if (!isRecord(data)) return undefined;
+
+ let count =
+ data['@odata.count'] ??
+ data['odata.count'] ??
+ (isRecord(data.d) ? data.d.__count : undefined);
+
+ if (typeof count === 'number' && Number.isFinite(count)) return count;
+ if (typeof count === 'string' && Number.isFinite(Number(count))) return Number(count);
+ return undefined;
+};
+
+let extractNextLink = (data: unknown) => {
+ if (!isRecord(data)) return undefined;
+
+ let nextLink =
+ data['@odata.nextLink'] ??
+ data['odata.nextLink'] ??
+ (isRecord(data.d) ? data.d.__next : undefined);
+
+ return typeof nextLink === 'string' && nextLink.trim() ? nextLink.trim() : undefined;
+};
+
+let searchParamsFromUrlOrQuery = (value: string) => {
+ let trimmed = value.trim();
+ if (!trimmed) return undefined;
+
+ if (trimmed.startsWith('?')) {
+ return new URLSearchParams(trimmed.slice(1));
+ }
+
+ let queryIndex = trimmed.indexOf('?');
+ if (queryIndex >= 0) {
+ return new URLSearchParams(trimmed.slice(queryIndex + 1));
+ }
+
+ try {
+ let parsed = new URL(trimmed, 'https://ifs-cloud.local');
+ return parsed.search ? parsed.searchParams : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+let firstSearchParam = (params: URLSearchParams, keys: string[]) => {
+ for (let key of keys) {
+ let value = params.get(key);
+ if (value?.trim()) return value.trim();
+ }
+
+ return undefined;
+};
+
+export let nextPageTokenFromLink = (nextLink: string | undefined) => {
+ if (!nextLink) return undefined;
+
+ let params = searchParamsFromUrlOrQuery(nextLink);
+ if (!params) return nextLink;
+
+ let skipToken = firstSearchParam(params, ['$skiptoken', '$skipToken', 'skiptoken']);
+ if (skipToken) return skipToken;
+
+ let skip = firstSearchParam(params, ['$skip', 'skip']);
+ return skip ? `skip:${skip}` : nextLink;
+};
+
+export let paginationParamsFromSkipToken = (skipToken: string | undefined) => {
+ let trimmed = skipToken?.trim();
+ if (!trimmed) return {};
+
+ if (trimmed.startsWith('skip:')) {
+ return { $skip: trimmed.slice('skip:'.length) };
+ }
+
+ let params = searchParamsFromUrlOrQuery(trimmed);
+ if (params) {
+ let parsedSkipToken = firstSearchParam(params, ['$skiptoken', '$skipToken', 'skiptoken']);
+ if (parsedSkipToken) return { $skiptoken: parsedSkipToken };
+
+ let parsedSkip = firstSearchParam(params, ['$skip', 'skip']);
+ if (parsedSkip) return { $skip: parsedSkip };
+ }
+
+ return { $skiptoken: trimmed };
+};
+
+export let validateIfsIdentifier = (value: string, label: string) => {
+ let trimmed = value.trim();
+
+ if (!IDENTIFIER_PATTERN.test(trimmed)) {
+ throw ifsApplicationsServiceError(
+ `${label} must be a simple IFS OData identifier using letters, numbers, and underscores only, starting with a letter.`,
+ { reason: 'ifs_identifier_invalid' }
+ );
+ }
+
+ return trimmed;
+};
+
+let normalizeBaseUrl = (value: string) => {
+ let baseUrl = normalizeAbsoluteUrl(value, 'IFS base URL');
+
+ if (/\/(?:main|int|b2b)(?:\/|$)/i.test(new URL(baseUrl).pathname)) {
+ throw ifsApplicationsServiceError(
+ 'IFS baseUrl must be the tenant root URL without /main, /int, or /b2b.',
+ { reason: 'ifs_base_url_includes_endpoint_segment' }
+ );
+ }
+
+ return baseUrl;
+};
+
+let topWithDefault = (top: number | undefined, defaultPageSize: number | undefined) =>
+ top ?? defaultPageSize ?? 50;
+
+let projectionEndpointWithDefault = (value: string | undefined): ProjectionEndpoint => {
+ if (!value) return 'int';
+
+ if ((projectionEndpoints as readonly string[]).includes(value)) {
+ return value as ProjectionEndpoint;
+ }
+
+ throw ifsApplicationsServiceError('projectionEndpoint must be one of main, int, or b2b.', {
+ reason: 'ifs_projection_endpoint_invalid'
+ });
+};
+
+let isTokenExpiring = (expiresAt: string | undefined) => {
+ if (!expiresAt) return false;
+
+ let parsed = Date.parse(expiresAt);
+ if (!Number.isFinite(parsed)) return false;
+ return parsed <= Date.now() + TOKEN_EXPIRY_SKEW_MS;
+};
+
+export class IfsApplicationsClient {
+ private token: string;
+ private tokenType?: string;
+ private expiresAt?: string;
+ private refreshToken?: string;
+ private tokenUrl?: string;
+ private clientId?: string;
+ private clientSecret?: string;
+ private scope?: string;
+ private defaultPageSize?: number;
+
+ constructor(config: {
+ baseUrl: string;
+ auth: IfsAuthState;
+ defaultPageSize?: number;
+ }) {
+ this.baseUrl = normalizeBaseUrl(config.baseUrl);
+ this.token = config.auth.token;
+ this.tokenType = config.auth.tokenType;
+ this.expiresAt = config.auth.expiresAt;
+ this.refreshToken = config.auth.refreshToken;
+ this.tokenUrl = config.auth.tokenUrl;
+ this.clientId = config.auth.clientId;
+ this.clientSecret = config.auth.clientSecret;
+ this.scope = config.auth.scope;
+ this.defaultPageSize = config.defaultPageSize;
+ }
+
+ private baseUrl: string;
+
+ private async getToken() {
+ if (!isTokenExpiring(this.expiresAt)) {
+ return this.token;
+ }
+
+ if (!this.tokenUrl || !this.clientId || !this.clientSecret) {
+ throw ifsApplicationsServiceError(
+ 'IFS access token is expired and client credential details are not available for refresh.',
+ { reason: 'ifs_oauth_refresh_unavailable' }
+ );
+ }
+
+ let refreshed = await requestIfsAccessToken({
+ tokenUrl: this.tokenUrl,
+ clientId: this.clientId,
+ clientSecret: this.clientSecret,
+ scope: this.scope,
+ previousRefreshToken: this.refreshToken
+ });
+
+ this.token = refreshed.token;
+ this.tokenType = refreshed.tokenType;
+ this.expiresAt = refreshed.expiresAt;
+ this.refreshToken = refreshed.refreshToken;
+ this.scope = refreshed.scope ?? this.scope;
+
+ return this.token;
+ }
+
+ private async createHttp() {
+ let token = await this.getToken();
+
+ return createAuthenticatedAxios({
+ baseURL: this.baseUrl,
+ authHeader: {
+ value: `${this.tokenType ?? 'Bearer'} ${token}`
+ },
+ contentType: false,
+ headers: {
+ Accept: 'application/json'
+ }
+ });
+ }
+
+ private projectionServicePath(
+ projectionName: string,
+ resourceKind: IfsApiResourceKind = 'projection',
+ projectionEndpoint?: ProjectionEndpoint
+ ) {
+ let projection = validateIfsIdentifier(projectionName, 'projectionName');
+ let endpoint = projectionEndpointWithDefault(projectionEndpoint);
+ return `/${endpoint}/ifsapplications/${resourceKind}/v1/${projection}.svc`;
+ }
+
+ private projectionOpenApiUrl(
+ projectionName: string,
+ resourceKind: IfsApiResourceKind = 'projection'
+ ) {
+ return `${this.baseUrl}${this.projectionServicePath(projectionName, resourceKind)}/$openapi`;
+ }
+
+ private projectionServiceUrl(
+ projectionName: string,
+ resourceKind: IfsApiResourceKind = 'projection'
+ ) {
+ return `${this.baseUrl}${this.projectionServicePath(projectionName, resourceKind)}/`;
+ }
+
+ private summarizeProjection(record: Record): ProjectionSummary {
+ let name = firstString(record, [
+ 'Name',
+ 'ProjectionName',
+ 'Projection',
+ 'ServiceName',
+ 'name',
+ 'projectionName'
+ ]);
+ let category = categoryValue(record);
+ let entitySetCount =
+ firstNumber(record, ['EntitySetCount', 'EntitySetsCount', 'entitySetCount']) ??
+ (Array.isArray(record.EntitySets) ? record.EntitySets.length : undefined);
+ let resourceKind = apiResourceKindForProjectionRecord(record);
+
+ return {
+ name,
+ apiClass: firstString(record, ['ApiClass', 'APIClass', 'apiClass', 'Class']),
+ category,
+ description: firstString(record, ['Description', 'description', 'Title', 'title']),
+ version: firstString(record, ['Version', 'version', 'Release', 'release']),
+ source: firstString(record, ['Source', 'source', 'Origin', 'origin']),
+ entitySetCount,
+ serviceUrl: name ? this.projectionServiceUrl(name, resourceKind) : undefined,
+ openApiUrl: name ? this.projectionOpenApiUrl(name, resourceKind) : undefined,
+ metadata: scalarMetadata(record)
+ };
+ }
+
+ async listApiProjections(options: {
+ apiClass?: ApiClass;
+ nameContains?: string;
+ category?: string;
+ top?: number;
+ skipToken?: string;
+ }): Promise {
+ let http = await this.createHttp();
+ let category =
+ options.category?.trim() || apiClassToCategory[options.apiClass ?? 'integration'];
+ let params: Record = {
+ $format: 'json',
+ $filter: `Categories eq '${escapeODataString(category)}'`,
+ $top: topWithDefault(options.top, this.defaultPageSize),
+ ...paginationParamsFromSkipToken(options.skipToken)
+ };
+
+ let data = await requestAxiosData(
+ 'list API projections',
+ () =>
+ http.get('/main/ifsapplications/projection/v1/AllProjections.svc/Projections', {
+ params
+ }),
+ ifsApplicationsApiError
+ );
+
+ let nameQuery = options.nameContains?.trim().toLowerCase();
+ let projections = extractRecords(data)
+ .map(record => this.summarizeProjection(record))
+ .filter(projection => matchesApiClass(projection, options.apiClass))
+ .filter(projection => {
+ if (!nameQuery) return true;
+ return (
+ projection.name?.toLowerCase().includes(nameQuery) ||
+ projection.description?.toLowerCase().includes(nameQuery)
+ );
+ });
+
+ return {
+ projections,
+ nextPageToken: nextPageTokenFromLink(extractNextLink(data))
+ };
+ }
+
+ async exportProjectionOpenApi(options: {
+ projectionName: string;
+ openApiVersion: 'v3' | 'v2';
+ }): Promise<{
+ content: string;
+ mimeType: string;
+ byteLength: number;
+ }> {
+ let http = await this.createHttp();
+ let path = `${this.projectionServicePath(options.projectionName)}/$openapi${
+ options.openApiVersion === 'v2' ? '?V2' : ''
+ }`;
+
+ let response = await requestAxios(
+ 'export projection OpenAPI',
+ () => http.get(path),
+ ifsApplicationsApiError
+ );
+
+ let content =
+ typeof response.data === 'string'
+ ? response.data
+ : JSON.stringify(response.data ?? {}, null, 2);
+
+ return {
+ content,
+ mimeType: 'application/json',
+ byteLength: Buffer.byteLength(content, 'utf8')
+ };
+ }
+
+ async queryProjectionRecords(options: {
+ projectionName: string;
+ projectionEndpoint?: ProjectionEndpoint;
+ entitySet: string;
+ select?: string[];
+ filter?: string;
+ orderBy?: string;
+ top?: number;
+ skipToken?: string;
+ includeCount?: boolean;
+ }): Promise {
+ let http = await this.createHttp();
+ let projectionName = validateIfsIdentifier(options.projectionName, 'projectionName');
+ let projectionEndpoint = projectionEndpointWithDefault(options.projectionEndpoint);
+ let entitySet = validateIfsIdentifier(options.entitySet, 'entitySet');
+ let select = options.select?.map(field => validateIfsIdentifier(field, 'select field'));
+
+ let params: Record = {
+ $format: 'json',
+ $select: select && select.length > 0 ? select.join(',') : undefined,
+ $filter: options.filter?.trim() || undefined,
+ $orderby: options.orderBy?.trim() || undefined,
+ $top: topWithDefault(options.top, this.defaultPageSize),
+ ...paginationParamsFromSkipToken(options.skipToken),
+ $count: options.includeCount || undefined
+ };
+
+ let data = await requestAxiosData(
+ 'query projection records',
+ () =>
+ http.get(
+ `${this.projectionServicePath(projectionName, 'projection', projectionEndpoint)}/${entitySet}`,
+ {
+ params
+ }
+ ),
+ ifsApplicationsApiError
+ );
+
+ return {
+ records: extractRecords(data),
+ count: extractCount(data),
+ nextPageToken: nextPageTokenFromLink(extractNextLink(data))
+ };
+ }
+}
diff --git a/integrations/ifs-applications/src/lib/errors.ts b/integrations/ifs-applications/src/lib/errors.ts
new file mode 100644
index 0000000000..d2dbf77443
--- /dev/null
+++ b/integrations/ifs-applications/src/lib/errors.ts
@@ -0,0 +1,76 @@
+import {
+ buildApiServiceError,
+ collectApiErrorDetails,
+ createApiServiceError,
+ isApiErrorRecord
+} from 'slates';
+
+let collectIfsDetails = (value: unknown, details: string[]) => {
+ collectApiErrorDetails(value, details, {
+ detailKeys: ['message', 'detail', 'error', 'error_description', 'code', 'reason', 'title'],
+ nestedKeys: ['errors', 'details', 'innererror'],
+ includeNumbers: true
+ });
+};
+
+let extractODataMessage = (error: unknown) => {
+ let details: string[] = [];
+ let response = isApiErrorRecord(error) ? error.response : undefined;
+ let responseData = isApiErrorRecord(response) ? response.data : undefined;
+
+ collectIfsDetails(responseData, details);
+
+ if (isApiErrorRecord(responseData)) {
+ collectIfsDetails(responseData['odata.error'], details);
+ collectIfsDetails(responseData.error, details);
+ }
+
+ if (details.length > 0) {
+ let message = details.join(' - ');
+ return message.includes('MI_MODIFIED_ERROR')
+ ? `${message}. IFS returned MI_MODIFIED_ERROR; retry the request after the current modification completes.`
+ : message;
+ }
+
+ return undefined;
+};
+
+let extractIfsCode = (value: unknown): string | undefined => {
+ if (!isApiErrorRecord(value)) return undefined;
+
+ for (let key of ['code', 'errorCode', 'error_code']) {
+ let code = value[key];
+ if (typeof code === 'string' || typeof code === 'number') return String(code);
+ }
+
+ let nested = value.error ?? value['odata.error'];
+ return extractIfsCode(nested);
+};
+
+export let ifsApplicationsServiceError = (
+ message: string,
+ options: {
+ reason?: string;
+ upstreamStatus?: number | string;
+ upstreamCode?: string;
+ parent?: unknown;
+ } = {}
+) =>
+ createApiServiceError(message, {
+ reason: options.reason ?? 'ifs_applications_validation_error',
+ upstreamStatus: options.upstreamStatus,
+ upstreamCode: options.upstreamCode,
+ parent: options.parent
+ });
+
+export let ifsApplicationsApiError = (error: unknown, operation = 'request') =>
+ buildApiServiceError(error, {
+ providerLabel: 'IFS Cloud',
+ reason: 'ifs_applications_api_error',
+ operation,
+ detailKeys: ['message', 'detail', 'error', 'error_description', 'code', 'reason', 'title'],
+ nestedKeys: ['errors', 'details', 'innererror'],
+ includeNumbers: true,
+ extractMessage: extractODataMessage,
+ extractUpstreamCode: (_error, response) => extractIfsCode(response?.data)
+ });
diff --git a/integrations/ifs-applications/src/lib/oauth.ts b/integrations/ifs-applications/src/lib/oauth.ts
new file mode 100644
index 0000000000..45eb68cb65
--- /dev/null
+++ b/integrations/ifs-applications/src/lib/oauth.ts
@@ -0,0 +1,103 @@
+import { createAxios, normalizeOAuthTokenResponse, requestAxiosData } from 'slates';
+import { ifsApplicationsApiError, ifsApplicationsServiceError } from './errors';
+
+type IfsOAuthTokenResponse = {
+ access_token?: unknown;
+ refresh_token?: unknown;
+ expires_in?: unknown;
+ token_type?: unknown;
+ scope?: unknown;
+};
+
+export type IfsAccessToken = {
+ token: string;
+ refreshToken?: string;
+ expiresAt?: string;
+ tokenType?: string;
+ scope?: string;
+};
+
+export let normalizeAbsoluteUrl = (value: string, label: string) => {
+ let trimmed = value.trim();
+
+ if (!trimmed) {
+ throw ifsApplicationsServiceError(`${label} is required.`, {
+ reason: 'ifs_url_required'
+ });
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch (error) {
+ throw ifsApplicationsServiceError(`${label} must be a valid absolute URL.`, {
+ reason: 'ifs_url_invalid',
+ parent: error
+ });
+ }
+
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
+ throw ifsApplicationsServiceError(`${label} must use http or https.`, {
+ reason: 'ifs_url_invalid_protocol'
+ });
+ }
+
+ return parsed.toString().replace(/\/+$/, '');
+};
+
+export let requestIfsAccessToken = async (input: {
+ tokenUrl: string;
+ clientId: string;
+ clientSecret: string;
+ scope?: string;
+ previousRefreshToken?: string;
+}): Promise => {
+ let http = createAxios();
+ let body = new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: input.clientId,
+ client_secret: input.clientSecret
+ });
+
+ let scope = input.scope?.trim();
+ if (scope) {
+ body.set('scope', scope);
+ }
+
+ let data = await requestAxiosData(
+ 'OAuth client credentials token exchange',
+ () =>
+ http.post(input.tokenUrl, body.toString(), {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Accept: 'application/json'
+ }
+ }),
+ ifsApplicationsApiError
+ );
+
+ let normalized = normalizeOAuthTokenResponse(data, {
+ providerLabel: 'IFS Cloud',
+ operation: 'client credentials token exchange',
+ previousRefreshToken: input.previousRefreshToken,
+ refreshTokenFallbackMode: 'falsy'
+ });
+
+ let tokenType = typeof data.token_type === 'string' ? data.token_type : 'Bearer';
+ let responseScope = typeof data.scope === 'string' ? data.scope : scope;
+
+ if (tokenType.toLowerCase() !== 'bearer') {
+ throw ifsApplicationsServiceError(
+ `IFS OAuth token response returned unsupported token type "${tokenType}".`,
+ { reason: 'ifs_oauth_token_type_unsupported' }
+ );
+ }
+
+ return {
+ token: normalized.token,
+ refreshToken: normalized.refreshToken,
+ expiresAt: normalized.expiresAt,
+ tokenType,
+ scope: responseScope
+ };
+};
diff --git a/integrations/ifs-applications/src/spec.ts b/integrations/ifs-applications/src/spec.ts
new file mode 100644
index 0000000000..9d7e0a44d9
--- /dev/null
+++ b/integrations/ifs-applications/src/spec.ts
@@ -0,0 +1,13 @@
+import { SlateSpecification } from 'slates';
+import { auth } from './auth';
+import { config } from './config';
+
+export let spec = SlateSpecification.create({
+ key: 'ifs-applications',
+ name: 'IFS Cloud / IFS Applications',
+ description:
+ 'Discover tenant-specific IFS Cloud projection APIs, export OpenAPI schemas, and query bounded OData records from enabled projections.',
+ metadata: {},
+ config,
+ auth
+});
diff --git a/integrations/ifs-applications/src/tools.schema.test.ts b/integrations/ifs-applications/src/tools.schema.test.ts
new file mode 100644
index 0000000000..dc5c60a7c6
--- /dev/null
+++ b/integrations/ifs-applications/src/tools.schema.test.ts
@@ -0,0 +1,4 @@
+import { describeMcpCompatibleToolSchemas } from '@slates/test';
+import { provider } from './index';
+
+describeMcpCompatibleToolSchemas('IFS Applications tool input schemas', provider.actions);
diff --git a/integrations/ifs-applications/src/tools/common.ts b/integrations/ifs-applications/src/tools/common.ts
new file mode 100644
index 0000000000..4d1bcc33ae
--- /dev/null
+++ b/integrations/ifs-applications/src/tools/common.ts
@@ -0,0 +1,69 @@
+import { z } from 'zod';
+import { IfsApplicationsClient, type IfsAuthState, projectionEndpoints } from '../lib/client';
+
+export let apiClassSchema = z
+ .enum(['premium', 'integration', 'standard', 'standardEntity'])
+ .optional()
+ .describe('Optional IFS API class/category to discover. Defaults to integration APIs.');
+
+export let projectionNameSchema = z
+ .string()
+ .min(1)
+ .max(128)
+ .describe(
+ 'IFS projection service name without .svc, such as CustomerHandling. Must come from API discovery or API Explorer.'
+ );
+
+export let projectionEndpointSchema = z
+ .enum(projectionEndpoints)
+ .optional()
+ .describe(
+ 'IFS route segment for the projection service. Defaults to int. Use main or b2b only when API Explorer, OpenAPI, or a service URL shows that endpoint.'
+ );
+
+export let entitySetSchema = z
+ .string()
+ .min(1)
+ .max(128)
+ .describe(
+ 'Entity set name within the projection. Use an entity set returned by the projection OpenAPI or API Explorer.'
+ );
+
+export let topSchema = z
+ .number()
+ .int()
+ .min(1)
+ .max(100)
+ .optional()
+ .describe('Maximum number of records to return. Defaults to integration config or 50.');
+
+export let skipTokenSchema = z
+ .string()
+ .optional()
+ .describe('Opaque skip token returned as nextPageToken by a previous OData response.');
+
+export let projectionSummarySchema = z.object({
+ name: z.string().optional(),
+ apiClass: z.string().optional(),
+ category: z.string().optional(),
+ description: z.string().optional(),
+ version: z.string().optional(),
+ source: z.string().optional(),
+ entitySetCount: z.number().optional(),
+ serviceUrl: z.string().optional(),
+ openApiUrl: z.string().optional(),
+ metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
+});
+
+export let createIfsClient = (ctx: {
+ auth: IfsAuthState;
+ config: {
+ baseUrl: string;
+ defaultPageSize?: number;
+ };
+}) =>
+ new IfsApplicationsClient({
+ baseUrl: ctx.config.baseUrl,
+ auth: ctx.auth,
+ defaultPageSize: ctx.config.defaultPageSize
+ });
diff --git a/integrations/ifs-applications/src/tools/export-projection-openapi.ts b/integrations/ifs-applications/src/tools/export-projection-openapi.ts
new file mode 100644
index 0000000000..04233bdece
--- /dev/null
+++ b/integrations/ifs-applications/src/tools/export-projection-openapi.ts
@@ -0,0 +1,57 @@
+import { createTextAttachment, SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import { createIfsClient, projectionNameSchema } from './common';
+
+export let exportProjectionOpenApi = SlateTool.create(spec, {
+ name: 'Export Projection OpenAPI',
+ key: 'export_projection_openapi',
+ description:
+ 'Export the OpenAPI JSON document for a specific IFS projection and return it as a Slate attachment.',
+ instructions: [
+ 'Use list_api_projections first when the projection name is unknown.',
+ 'Inspect the attached OpenAPI document to identify entity sets and fields before using query_projection_records.'
+ ],
+ tags: {
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ projectionName: projectionNameSchema,
+ openApiVersion: z
+ .enum(['v3', 'v2'])
+ .optional()
+ .describe('OpenAPI format to export. Defaults to v3.')
+ })
+ )
+ .output(
+ z.object({
+ projectionName: z.string(),
+ openApiVersion: z.enum(['v3', 'v2']),
+ mimeType: z.string(),
+ byteLength: z.number(),
+ attachmentCount: z.number()
+ })
+ )
+ .handleInvocation(async ctx => {
+ let openApiVersion = ctx.input.openApiVersion ?? 'v3';
+ let client = createIfsClient(ctx);
+ let result = await client.exportProjectionOpenApi({
+ projectionName: ctx.input.projectionName,
+ openApiVersion
+ });
+
+ return {
+ output: {
+ projectionName: ctx.input.projectionName,
+ openApiVersion,
+ mimeType: result.mimeType,
+ byteLength: result.byteLength,
+ attachmentCount: 1
+ },
+ message: `Exported OpenAPI ${openApiVersion.toUpperCase()} for **${ctx.input.projectionName}**.`,
+ attachments: [createTextAttachment(result.content, result.mimeType)]
+ };
+ })
+ .build();
diff --git a/integrations/ifs-applications/src/tools/index.ts b/integrations/ifs-applications/src/tools/index.ts
new file mode 100644
index 0000000000..14af492e4a
--- /dev/null
+++ b/integrations/ifs-applications/src/tools/index.ts
@@ -0,0 +1,3 @@
+export * from './export-projection-openapi';
+export * from './list-api-projections';
+export * from './query-projection-records';
diff --git a/integrations/ifs-applications/src/tools/list-api-projections.ts b/integrations/ifs-applications/src/tools/list-api-projections.ts
new file mode 100644
index 0000000000..edfac134a4
--- /dev/null
+++ b/integrations/ifs-applications/src/tools/list-api-projections.ts
@@ -0,0 +1,64 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ apiClassSchema,
+ createIfsClient,
+ projectionSummarySchema,
+ skipTokenSchema,
+ topSchema
+} from './common';
+
+export let listApiProjections = SlateTool.create(spec, {
+ name: 'List API Projections',
+ key: 'list_api_projections',
+ description:
+ 'Discover IFS Cloud projection APIs enabled for the tenant before choosing projection names for OpenAPI export or record queries.',
+ instructions: [
+ 'Use this tool first when the projection name is unknown.',
+ 'IFS tenants can expose different projection names by release, license, and admin configuration.'
+ ],
+ tags: {
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ apiClass: apiClassSchema,
+ nameContains: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional case-insensitive text filter applied to projection names/descriptions.'
+ ),
+ category: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional exact IFS projection category for AllProjections filtering. Defaults from apiClass, or Integration when omitted.'
+ ),
+ top: topSchema,
+ skipToken: skipTokenSchema
+ })
+ )
+ .output(
+ z.object({
+ projections: z.array(projectionSummarySchema),
+ count: z.number(),
+ nextPageToken: z.string().optional()
+ })
+ )
+ .handleInvocation(async ctx => {
+ let client = createIfsClient(ctx);
+ let result = await client.listApiProjections(ctx.input);
+
+ return {
+ output: {
+ projections: result.projections,
+ count: result.projections.length,
+ nextPageToken: result.nextPageToken
+ },
+ message: `Found **${result.projections.length}** IFS projection(s).`
+ };
+ })
+ .build();
diff --git a/integrations/ifs-applications/src/tools/query-projection-records.ts b/integrations/ifs-applications/src/tools/query-projection-records.ts
new file mode 100644
index 0000000000..7c77d16238
--- /dev/null
+++ b/integrations/ifs-applications/src/tools/query-projection-records.ts
@@ -0,0 +1,80 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import { spec } from '../spec';
+import {
+ createIfsClient,
+ entitySetSchema,
+ projectionEndpointSchema,
+ projectionNameSchema,
+ skipTokenSchema,
+ topSchema
+} from './common';
+
+export let queryProjectionRecords = SlateTool.create(spec, {
+ name: 'Query Projection Records',
+ key: 'query_projection_records',
+ description:
+ 'Query a bounded page of records from one entity set in an enabled IFS OData projection.',
+ instructions: [
+ 'Use list_api_projections and export_projection_openapi first to confirm the projection name, entity set, and fields.',
+ 'This tool blocks arbitrary URL paths; projectionName and entitySet must be simple identifiers.'
+ ],
+ tags: {
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ projectionName: projectionNameSchema,
+ projectionEndpoint: projectionEndpointSchema,
+ entitySet: entitySetSchema,
+ select: z
+ .array(z.string().min(1).max(128).describe('Field name to include in OData $select.'))
+ .max(50)
+ .optional()
+ .describe('Optional list of field names for OData $select.'),
+ filter: z
+ .string()
+ .max(1000)
+ .optional()
+ .describe('Optional OData $filter expression for the selected entity set.'),
+ orderBy: z
+ .string()
+ .max(500)
+ .optional()
+ .describe('Optional OData $orderby expression for the selected entity set.'),
+ top: topSchema,
+ skipToken: skipTokenSchema,
+ includeCount: z
+ .boolean()
+ .optional()
+ .describe('Set true to request an OData count when the projection supports it.')
+ })
+ )
+ .output(
+ z.object({
+ projectionName: z.string(),
+ entitySet: z.string(),
+ records: z.array(z.record(z.string(), z.unknown())),
+ count: z.number().optional(),
+ returnedCount: z.number(),
+ nextPageToken: z.string().optional()
+ })
+ )
+ .handleInvocation(async ctx => {
+ let client = createIfsClient(ctx);
+ let result = await client.queryProjectionRecords(ctx.input);
+
+ return {
+ output: {
+ projectionName: ctx.input.projectionName,
+ entitySet: ctx.input.entitySet,
+ records: result.records,
+ count: result.count,
+ returnedCount: result.records.length,
+ nextPageToken: result.nextPageToken
+ },
+ message: `Returned **${result.records.length}** record(s) from **${ctx.input.projectionName}.${ctx.input.entitySet}**.`
+ };
+ })
+ .build();
diff --git a/integrations/ifs-applications/tsconfig.json b/integrations/ifs-applications/tsconfig.json
new file mode 100644
index 0000000000..9584b078a4
--- /dev/null
+++ b/integrations/ifs-applications/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "types": ["node"],
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ },
+ "include": ["src"]
+}
diff --git a/integrations/ifs-applications/vitest.config.ts b/integrations/ifs-applications/vitest.config.ts
new file mode 100644
index 0000000000..0b05bc07ad
--- /dev/null
+++ b/integrations/ifs-applications/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['src/**/*.test.ts']
+ }
+});
diff --git a/integrations/sap-s4hana/README.md b/integrations/sap-s4hana/README.md
new file mode 100644
index 0000000000..aa3bb8916f
--- /dev/null
+++ b/integrations/sap-s4hana/README.md
@@ -0,0 +1,53 @@
+#
SAP S/4HANA
+
+Connect to SAP S/4HANA Cloud or compatible private/on-premise OData APIs for tenant-configured ERP read workflows. This integration reads business partners, sales orders, billing documents, products/materials, purchase orders, and supplier invoices from a configured tenant or the SAP Business Accelerator Hub sandbox.
+
+## Authentication
+
+Configure `baseUrl` in Slate config with the SAP tenant root URL, or use `https://sandbox.api.sap.com/s4hanacloud` for SAP API Hub sandbox calls. Optional config fields include `sapClient`, default company/sales/purchasing organization values, and `sandboxMode`.
+
+Use custom auth with one of these modes:
+
+- `basic`: SAP communication user and password.
+- `bearer`: pre-issued SAP bearer/access token.
+- `apiHubKey`: SAP Business Accelerator Hub API key for sandbox access.
+
+SAP S/4HANA access depends on enabled Communication Management scenarios, tenant host, tenant edition, and customer security policy. Client certificate authentication and write/posting flows are intentionally not exposed in this initial package.
+
+## Tools
+
+### List Business Partners / Get Business Partner
+
+Read business partners from `API_BUSINESS_PARTNER`, including customer/supplier identity, roles, addresses, tax metadata, and bank metadata where the tenant authorizes those expansions.
+
+### List Sales Orders / Get Sales Order
+
+Read sales order headers from `API_SALES_ORDER_SRV`, with optional item and partner expansion for order-to-cash discovery.
+
+### List Billing Documents / Get Billing Document
+
+Read billing documents from `API_BILLING_DOCUMENT_SRV`, with optional billing item expansion for invoice lookup and reconciliation workflows.
+
+### List Products / Get Product
+
+Read product/material master data from `API_PRODUCT_SRV`, including descriptions, plant metadata, and sales metadata where available.
+
+### List Purchase Orders / Get Purchase Order
+
+Read purchase order headers from `API_PURCHASEORDER_PROCESS_SRV`, with optional item expansion for procure-to-pay visibility.
+
+### List Supplier Invoices / Get Supplier Invoice
+
+Read supplier invoices from `API_SUPPLIERINVOICE_PROCESS_SRV`. `get_supplier_invoice` requires both `supplierInvoice` and `fiscalYear`, matching the SAP key.
+
+## Deferred Tools
+
+Journal entry posting, sales order creation, attachment download, and event/webhook support are deferred until a customer non-production tenant, communication scenarios, and reversal/cleanup strategy are confirmed.
+
+## License
+
+This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE).
+
+
diff --git a/integrations/sap-s4hana/docs/SPEC.md b/integrations/sap-s4hana/docs/SPEC.md
new file mode 100644
index 0000000000..a89c9ca910
--- /dev/null
+++ b/integrations/sap-s4hana/docs/SPEC.md
@@ -0,0 +1,56 @@
+# SAP S/4HANA Integration Spec
+
+## Scope
+
+This package implements the initial low-risk SAP S/4HANA read surface from the ERP research plan:
+
+- Business partners through `API_BUSINESS_PARTNER`
+- Sales orders through `API_SALES_ORDER_SRV`
+- Billing documents through `API_BILLING_DOCUMENT_SRV`
+- Products/materials through `API_PRODUCT_SRV`
+- Purchase orders through `API_PURCHASEORDER_PROCESS_SRV`
+- Supplier invoices through `API_SUPPLIERINVOICE_PROCESS_SRV`
+
+The package targets SAP S/4HANA Cloud Public Edition APIs first, while allowing compatible tenant roots for private cloud or on-premise systems when the same OData services and communication scenarios are enabled.
+
+## Configuration
+
+`baseUrl` is required and should be the SAP tenant root, for example `https://mytenant-api.s4hana.cloud.sap`, or the API Hub sandbox root `https://sandbox.api.sap.com/s4hanacloud`.
+
+Optional config:
+
+- `sapClient`: sent as the `sap-client` OData query parameter.
+- `defaultCompanyCode`, `defaultSalesOrganization`, `defaultPurchasingOrganization`: reserved defaults for later write workflows.
+- `sandboxMode`: marks SAP API Hub sandbox usage.
+
+## Authentication
+
+The integration uses custom auth with one input object and `authMethod` discriminator:
+
+- `basic`: communication user/password.
+- `bearer`: pre-issued bearer/access token.
+- `apiHubKey`: SAP Business Accelerator Hub `apikey` header.
+
+`getProfile` verifies service availability by reading `API_BUSINESS_PARTNER/$metadata`.
+
+## Tool Contracts
+
+All tool inputs are top-level `z.object` schemas. List tools use a conservative default `top` of 25, maximum 100, and require at least one useful filter unless `allowBroadQuery=true` or a SAP `skipToken` is supplied.
+
+List outputs include:
+
+- Stable normalized records for agent workflows.
+- `record` with the raw SAP OData object for tenant-specific fields.
+- `page.nextPageToken` preserving SAP server next links when present.
+
+## Deferred Capabilities
+
+The research plan identifies `post_journal_entry`, `create_sales_order`, and `download_document_attachment` as later work. They are not implemented here because posting has accounting/legal impact, sales-order creation requires customer sales-area defaults and live validation, and SAP attachment APIs vary by object/tenant.
+
+Webhooks/events are also deferred because SAP business events usually require SAP Event Mesh or BTP setup rather than a simple provider-hosted webhook.
+
+## Verification
+
+Package-level schema regression coverage uses `describeMcpCompatibleToolSchemas('SAP S/4HANA tool input schemas', provider.actions)`.
+
+Private live E2E coverage is in `tests/integrations/sap-s4hana/tools.e2e.ts` and expects a non-production tenant or SAP API Hub sandbox credentials with readable sample data.
diff --git a/integrations/sap-s4hana/logo.svg b/integrations/sap-s4hana/logo.svg
new file mode 100644
index 0000000000..201e3e4583
--- /dev/null
+++ b/integrations/sap-s4hana/logo.svg
@@ -0,0 +1,4 @@
+
diff --git a/integrations/sap-s4hana/package.json b/integrations/sap-s4hana/package.json
new file mode 100644
index 0000000000..8ff2dec02f
--- /dev/null
+++ b/integrations/sap-s4hana/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@slates-integrations/sap-s4hana",
+ "main": "src/index.ts",
+ "type": "module",
+ "scripts": {
+ "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s",
+ "test": "vitest run --passWithNoTests",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@lowerdeck/error": "^1.1.0",
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.15",
+ "zod": "^4.2"
+ },
+ "devDependencies": {
+ "@slates/test": "1.0.0-rc.9",
+ "typescript": "^5",
+ "vitest": "^3.1.2"
+ },
+ "version": "0.1.1-rc.2"
+}
diff --git a/integrations/sap-s4hana/slate.json b/integrations/sap-s4hana/slate.json
new file mode 100644
index 0000000000..88791180f7
--- /dev/null
+++ b/integrations/sap-s4hana/slate.json
@@ -0,0 +1,16 @@
+{
+ "name": "@sap/sap-s4hana",
+ "description": "Connect to SAP S/4HANA OData APIs for enterprise ERP read workflows. Read business partners, sales orders, billing documents, product master data, purchase orders, and supplier invoices from a configured tenant or SAP API Hub sandbox.",
+ "categories": ["apis-and-http-requests", "financial-data-and-stock-market"],
+ "skills": [
+ "read business partners",
+ "inspect customer and supplier master data",
+ "list sales orders",
+ "review billing documents",
+ "read product master data",
+ "list purchase orders",
+ "review supplier invoices",
+ "query SAP OData pages"
+ ],
+ "logoUrl": "logo.svg"
+}
diff --git a/integrations/sap-s4hana/src/auth.ts b/integrations/sap-s4hana/src/auth.ts
new file mode 100644
index 0000000000..b5afbd2654
--- /dev/null
+++ b/integrations/sap-s4hana/src/auth.ts
@@ -0,0 +1,116 @@
+import { createApiServiceError, SlateAuth } from 'slates';
+import { z } from 'zod';
+import type { SapAuthMethod, SapAuthOutput } from './lib/client';
+
+let authInputSchema = z.object({
+ authMethod: z
+ .enum(['basic', 'bearer', 'apiHubKey'])
+ .describe(
+ 'Authentication mode. Use basic for SAP communication users, bearer for a pre-issued OAuth/access token, or apiHubKey for the SAP Business Accelerator Hub sandbox.'
+ ),
+ username: z
+ .string()
+ .optional()
+ .describe('SAP communication user. Required when authMethod is basic.'),
+ password: z
+ .string()
+ .optional()
+ .describe('SAP communication user password. Required when authMethod is basic.'),
+ bearerToken: z
+ .string()
+ .optional()
+ .describe('Pre-issued SAP bearer token. Required when authMethod is bearer.'),
+ apiHubKey: z
+ .string()
+ .optional()
+ .describe('SAP Business Accelerator Hub API key. Required when authMethod is apiHubKey.')
+});
+
+type SapAuthInput = z.infer;
+
+let requireSecret = (value: string | undefined, message: string) => {
+ if (!value) throw createApiServiceError(message, { reason: 'sap_s4hana_validation_error' });
+ return value;
+};
+
+let buildAuthOutput = (input: SapAuthInput): SapAuthOutput => {
+ if (input.authMethod === 'basic') {
+ let username = requireSecret(input.username, 'username is required for SAP basic auth.');
+ let password = requireSecret(input.password, 'password is required for SAP basic auth.');
+ return {
+ authMethod: 'basic',
+ token: Buffer.from(`${username}:${password}`).toString('base64')
+ };
+ }
+
+ if (input.authMethod === 'bearer') {
+ return {
+ authMethod: 'bearer',
+ token: requireSecret(input.bearerToken, 'bearerToken is required for SAP bearer auth.')
+ };
+ }
+
+ return {
+ authMethod: 'apiHubKey',
+ apiKey: requireSecret(input.apiHubKey, 'apiHubKey is required for SAP API Hub auth.')
+ };
+};
+
+export let auth = SlateAuth.create()
+ .output(
+ z.object({
+ authMethod: z
+ .enum(['basic', 'bearer', 'apiHubKey'])
+ .describe('Authentication mode used for SAP S/4HANA API calls.'),
+ token: z
+ .string()
+ .optional()
+ .describe('Opaque basic or bearer credential material for SAP API calls.'),
+ apiKey: z
+ .string()
+ .optional()
+ .describe('SAP Business Accelerator Hub API key for sandbox calls.')
+ })
+ )
+ .addCustomAuth({
+ type: 'auth.custom',
+ name: 'SAP S/4HANA Credentials',
+ key: 'sap_credentials',
+ docs: [
+ {
+ type: 'docs.auth.custom',
+ name: 'SAP S/4HANA APIs',
+ url: 'https://api.sap.com/products/SAPS4HANACloud/apis/all'
+ }
+ ],
+ inputSchema: authInputSchema,
+ getOutput: async (ctx: { input: SapAuthInput }) => ({
+ output: buildAuthOutput(ctx.input)
+ }),
+ getProfile: async (ctx: { output: SapAuthOutput; config?: any }) => {
+ if (!ctx.config?.baseUrl) {
+ throw createApiServiceError(
+ 'SAP S/4HANA baseUrl config is required before validating credentials.',
+ { reason: 'sap_s4hana_validation_error' }
+ );
+ }
+
+ let { SapS4HanaClient } = await import('./lib/client');
+ let client = new SapS4HanaClient({
+ auth: ctx.output,
+ config: ctx.config
+ });
+
+ await client.getMetadata('API_BUSINESS_PARTNER');
+
+ return {
+ profile: {
+ id: client.profileId,
+ name: `SAP S/4HANA ${client.profileId}`,
+ authMethod: ctx.output.authMethod as SapAuthMethod,
+ baseUrl: client.normalizedBaseUrl,
+ sandboxMode: Boolean(ctx.config?.sandboxMode)
+ }
+ };
+ }
+ });
diff --git a/integrations/sap-s4hana/src/config.ts b/integrations/sap-s4hana/src/config.ts
new file mode 100644
index 0000000000..77920a92e8
--- /dev/null
+++ b/integrations/sap-s4hana/src/config.ts
@@ -0,0 +1,33 @@
+import { SlateConfig } from 'slates';
+import { z } from 'zod';
+
+export let config = SlateConfig.create(
+ z.object({
+ baseUrl: z
+ .string()
+ .url()
+ .describe(
+ 'SAP S/4HANA tenant root URL or SAP API Hub sandbox root, for example https://mytenant-api.s4hana.cloud.sap or https://sandbox.api.sap.com/s4hanacloud.'
+ ),
+ sapClient: z
+ .string()
+ .optional()
+ .describe('Optional SAP client number to send as the sap-client OData query parameter.'),
+ defaultCompanyCode: z
+ .string()
+ .optional()
+ .describe('Optional default company code for future SAP finance workflows.'),
+ defaultSalesOrganization: z
+ .string()
+ .optional()
+ .describe('Optional default sales organization for sales-order workflows.'),
+ defaultPurchasingOrganization: z
+ .string()
+ .optional()
+ .describe('Optional default purchasing organization for purchase-order workflows.'),
+ sandboxMode: z
+ .boolean()
+ .optional()
+ .describe('Whether this connection targets the SAP Business Accelerator Hub sandbox.')
+ })
+);
diff --git a/integrations/sap-s4hana/src/index.ts b/integrations/sap-s4hana/src/index.ts
new file mode 100644
index 0000000000..ddf4143eca
--- /dev/null
+++ b/integrations/sap-s4hana/src/index.ts
@@ -0,0 +1,35 @@
+import { Slate } from 'slates';
+import { spec } from './spec';
+import {
+ getBillingDocument,
+ getBusinessPartner,
+ getProduct,
+ getPurchaseOrder,
+ getSalesOrder,
+ getSupplierInvoice,
+ listBillingDocuments,
+ listBusinessPartners,
+ listProducts,
+ listPurchaseOrders,
+ listSalesOrders,
+ listSupplierInvoices
+} from './tools';
+
+export let provider = Slate.create({
+ spec,
+ tools: [
+ listBusinessPartners,
+ getBusinessPartner,
+ listSalesOrders,
+ getSalesOrder,
+ listBillingDocuments,
+ getBillingDocument,
+ listProducts,
+ getProduct,
+ listPurchaseOrders,
+ getPurchaseOrder,
+ listSupplierInvoices,
+ getSupplierInvoice
+ ],
+ triggers: []
+});
diff --git a/integrations/sap-s4hana/src/lib/client.test.ts b/integrations/sap-s4hana/src/lib/client.test.ts
new file mode 100644
index 0000000000..4509c61b38
--- /dev/null
+++ b/integrations/sap-s4hana/src/lib/client.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import { odataDateTimeLiteral, SapS4HanaClient } from './client';
+
+let resolvePageToken = (client: SapS4HanaClient, pageToken: string) =>
+ (
+ client as unknown as {
+ resolvePageToken(pageToken: string): string | undefined;
+ }
+ ).resolvePageToken(pageToken);
+
+let createClient = (baseUrl: string) =>
+ new SapS4HanaClient({
+ auth: {
+ authMethod: 'apiHubKey',
+ apiKey: 'test-api-key'
+ },
+ config: {
+ baseUrl
+ }
+ });
+
+describe('SAP S/4HANA OData client helpers', () => {
+ it('formats datetime literals without milliseconds or timezone suffix', () => {
+ expect(odataDateTimeLiteral('2026-01-02T03:04:05.678Z', 'datetime')).toBe(
+ "datetime'2026-01-02T03:04:05'"
+ );
+ });
+
+ it('keeps datetimeoffset literals as ISO timestamps', () => {
+ expect(odataDateTimeLiteral('2026-01-02T03:04:05.678Z')).toBe(
+ "datetimeoffset'2026-01-02T03:04:05.678Z'"
+ );
+ });
+
+ it('replays SAP API Hub next links relative to a base URL path', () => {
+ let client = createClient('https://sandbox.api.sap.com/s4hanacloud');
+
+ expect(
+ resolvePageToken(
+ client,
+ 'https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner?$skiptoken=abc'
+ )
+ ).toBe('/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner?$skiptoken=abc');
+ });
+
+ it('normalizes relative next links that already include the base URL path', () => {
+ let client = createClient('https://sandbox.api.sap.com/s4hanacloud');
+
+ expect(
+ resolvePageToken(
+ client,
+ '/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner?$skiptoken=abc'
+ )
+ ).toBe('/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner?$skiptoken=abc');
+ });
+});
diff --git a/integrations/sap-s4hana/src/lib/client.ts b/integrations/sap-s4hana/src/lib/client.ts
new file mode 100644
index 0000000000..e8f92a9cee
--- /dev/null
+++ b/integrations/sap-s4hana/src/lib/client.ts
@@ -0,0 +1,365 @@
+import {
+ createAuthenticatedAxios,
+ requestAxios,
+ requestAxiosData,
+ setIfDefined
+} from 'slates';
+import { sapApiError, sapValidationError } from './errors';
+
+export type SapAuthMethod = 'basic' | 'bearer' | 'apiHubKey';
+
+export type SapAuthOutput = {
+ authMethod: SapAuthMethod;
+ token?: string;
+ apiKey?: string;
+};
+
+export type SapConfig = {
+ baseUrl: string;
+ sapClient?: string;
+ sandboxMode?: boolean;
+};
+
+export type ODataListResult = {
+ items: T[];
+ count?: number;
+ nextPageToken?: string;
+};
+
+type ODataEnvelope = {
+ d?: T | { results?: T[]; __count?: string | number; __next?: string };
+ value?: T[];
+ '@odata.nextLink'?: string;
+};
+
+let isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+let normalizeBaseUrl = (baseUrl: string) => {
+ let trimmed = baseUrl.trim().replace(/\/+$/, '');
+ if (!trimmed) throw sapValidationError('SAP S/4HANA baseUrl is required.');
+
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ throw sapValidationError('SAP S/4HANA baseUrl must be a valid URL.');
+ }
+
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
+ throw sapValidationError('SAP S/4HANA baseUrl must use http or https.');
+ }
+
+ return trimmed;
+};
+
+let serializeParams = (params: Record) => {
+ let search = new URLSearchParams();
+
+ for (let [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null || value === '') continue;
+
+ if (Array.isArray(value)) {
+ for (let item of value) {
+ if (item !== undefined && item !== null && item !== '') {
+ search.append(key, String(item));
+ }
+ }
+ continue;
+ }
+
+ search.append(key, String(value));
+ }
+
+ return search.toString();
+};
+
+let authHeaderFor = (auth: SapAuthOutput) => {
+ if (auth.authMethod === 'basic') {
+ if (!auth.token) throw sapValidationError('SAP basic authentication is missing a token.');
+ return { name: 'Authorization', value: `Basic ${auth.token}` };
+ }
+
+ if (auth.authMethod === 'bearer') {
+ if (!auth.token) throw sapValidationError('SAP bearer authentication is missing a token.');
+ return { name: 'Authorization', value: `Bearer ${auth.token}` };
+ }
+
+ if (!auth.apiKey)
+ throw sapValidationError('SAP API Hub authentication is missing an API key.');
+ return { name: 'apikey', value: auth.apiKey };
+};
+
+let ensureRecord = (value: unknown, operation: string) => {
+ if (!isRecord(value)) {
+ throw sapValidationError(`SAP S/4HANA ${operation} did not return an object.`);
+ }
+
+ return value;
+};
+
+let normalizeEntity = >(data: unknown, operation: string): T => {
+ let envelope = ensureRecord(data, operation) as ODataEnvelope;
+ let value = envelope.d ?? data;
+ if (!isRecord(value)) {
+ throw sapValidationError(`SAP S/4HANA ${operation} did not return an entity.`);
+ }
+
+ return value as T;
+};
+
+let normalizeList = >(
+ data: unknown,
+ operation: string
+): ODataListResult => {
+ let envelope = ensureRecord(data, operation) as ODataEnvelope;
+
+ if (Array.isArray(envelope.value)) {
+ return {
+ items: envelope.value as T[],
+ nextPageToken: envelope['@odata.nextLink']
+ };
+ }
+
+ if (isRecord(envelope.d)) {
+ let results = envelope.d.results;
+ if (Array.isArray(results)) {
+ let count =
+ envelope.d.__count === undefined
+ ? undefined
+ : Number.parseInt(String(envelope.d.__count), 10);
+
+ return {
+ items: results as T[],
+ count: count === undefined || Number.isFinite(count) ? count : undefined,
+ nextPageToken: typeof envelope.d.__next === 'string' ? envelope.d.__next : undefined
+ };
+ }
+ }
+
+ throw sapValidationError(`SAP S/4HANA ${operation} did not return a list.`);
+};
+
+export let odataStringLiteral = (value: string | number) =>
+ `'${String(value).replace(/'/g, "''")}'`;
+
+export let odataKeyLiteral = (value: string | number) =>
+ `'${encodeURIComponent(String(value).replace(/'/g, "''"))}'`;
+
+export let odataDateTimeLiteral = (
+ value: string,
+ kind: 'datetime' | 'datetimeoffset' = 'datetimeoffset'
+) => {
+ let date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ throw sapValidationError(`"${value}" is not a valid date or datetime.`);
+ }
+
+ let iso = date.toISOString();
+ return `${kind}'${kind === 'datetime' ? iso.replace(/\.\d{3}Z$/, '') : iso}'`;
+};
+
+export let substringFilter = (field: string, value: string) =>
+ `substringof(${odataStringLiteral(value)}, ${field})`;
+
+export let andFilters = (filters: Array) =>
+ filters.filter((filter): filter is string => Boolean(filter)).join(' and ');
+
+export let orFilters = (filters: Array) => {
+ let concrete = filters.filter((filter): filter is string => Boolean(filter));
+ if (concrete.length === 0) return undefined;
+ if (concrete.length === 1) return concrete[0];
+ return `(${concrete.join(' or ')})`;
+};
+
+export let compoundKey = (keys: Record) =>
+ Object.entries(keys)
+ .map(([key, value]) => `${key}=${odataKeyLiteral(value)}`)
+ .join(',');
+
+export class SapS4HanaClient {
+ private http;
+ private baseUrl: string;
+ private sapClient?: string;
+
+ constructor(params: { auth: SapAuthOutput; config: SapConfig }) {
+ if (!params.config?.baseUrl) {
+ throw sapValidationError('SAP S/4HANA baseUrl config is required.');
+ }
+
+ this.baseUrl = normalizeBaseUrl(params.config.baseUrl);
+ this.sapClient = params.config.sapClient;
+ this.http = createAuthenticatedAxios({
+ baseURL: this.baseUrl,
+ authHeader: authHeaderFor(params.auth),
+ headers: {
+ Accept: 'application/json'
+ },
+ paramsSerializer: { serialize: serializeParams }
+ });
+ }
+
+ get profileId() {
+ return new URL(this.baseUrl).host;
+ }
+
+ get normalizedBaseUrl() {
+ return this.baseUrl;
+ }
+
+ private serviceRoot(serviceName: string) {
+ return `/sap/opu/odata/sap/${serviceName}`;
+ }
+
+ private queryParams(params: Record = {}) {
+ return {
+ $format: 'json',
+ ...params,
+ 'sap-client': this.sapClient
+ };
+ }
+
+ private requestPathFor(pathname: string, search = '') {
+ let basePath = new URL(this.baseUrl).pathname.replace(/\/+$/, '');
+ let requestPath = pathname;
+
+ if (
+ basePath &&
+ basePath !== '/' &&
+ (requestPath === basePath || requestPath.startsWith(`${basePath}/`))
+ ) {
+ requestPath = requestPath.slice(basePath.length) || '/';
+ }
+
+ return `${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}${search}`;
+ }
+
+ private resolvePageToken(pageToken: string) {
+ let trimmed = pageToken.trim();
+ if (!trimmed) throw sapValidationError('skipToken cannot be empty.');
+
+ let base = new URL(this.baseUrl);
+
+ if (/^https?:\/\//i.test(trimmed)) {
+ let nextUrl: URL;
+ try {
+ nextUrl = new URL(trimmed);
+ } catch {
+ throw sapValidationError('skipToken next link is not a valid URL.');
+ }
+
+ if (nextUrl.origin !== base.origin) {
+ throw sapValidationError(
+ 'skipToken next link must point to the configured SAP tenant.'
+ );
+ }
+
+ return this.requestPathFor(nextUrl.pathname, nextUrl.search);
+ }
+
+ if (trimmed.startsWith('/')) {
+ let nextUrl = new URL(trimmed, base.origin);
+ return this.requestPathFor(nextUrl.pathname, nextUrl.search);
+ }
+
+ return undefined;
+ }
+
+ async getMetadata(serviceName: string) {
+ let response = await requestAxios(
+ `read ${serviceName} metadata`,
+ () =>
+ this.http.get(`${this.serviceRoot(serviceName)}/$metadata`, {
+ params: this.sapClient ? { 'sap-client': this.sapClient } : undefined,
+ responseType: 'text'
+ }),
+ sapApiError
+ );
+
+ return String(response.data ?? '');
+ }
+
+ async queryEntitySet>(params: {
+ serviceName: string;
+ entitySet: string;
+ query?: Record;
+ pageToken?: string;
+ }) {
+ let nextPath = params.pageToken ? this.resolvePageToken(params.pageToken) : undefined;
+
+ if (nextPath) {
+ let data = await requestAxiosData(
+ `query ${params.entitySet} page`,
+ () => this.http.get(nextPath),
+ sapApiError
+ );
+ return normalizeList(data, `query ${params.entitySet} page`);
+ }
+
+ let query = this.queryParams({
+ ...params.query,
+ ...(params.pageToken ? { $skiptoken: params.pageToken } : {})
+ });
+
+ let data = await requestAxiosData(
+ `query ${params.entitySet}`,
+ () =>
+ this.http.get(`${this.serviceRoot(params.serviceName)}/${params.entitySet}`, {
+ params: query
+ }),
+ sapApiError
+ );
+
+ return normalizeList(data, `query ${params.entitySet}`);
+ }
+
+ async getEntity>(params: {
+ serviceName: string;
+ entitySet: string;
+ key: string | Record;
+ query?: Record;
+ }) {
+ let key =
+ typeof params.key === 'string' ? odataKeyLiteral(params.key) : compoundKey(params.key);
+ let path = `${this.serviceRoot(params.serviceName)}/${params.entitySet}(${key})`;
+
+ let data = await requestAxiosData(
+ `get ${params.entitySet}`,
+ () =>
+ this.http.get(path, {
+ params: this.queryParams(params.query)
+ }),
+ sapApiError
+ );
+
+ return normalizeEntity(data, `get ${params.entitySet}`);
+ }
+
+ async queryEntityIds(params: {
+ serviceName: string;
+ entitySet: string;
+ idField: string;
+ filter?: string;
+ top?: number;
+ }) {
+ let query: Record = {
+ $select: params.idField,
+ $top: params.top ?? 100
+ };
+ setIfDefined(query, '$filter', params.filter);
+
+ let result = await this.queryEntitySet>({
+ serviceName: params.serviceName,
+ entitySet: params.entitySet,
+ query
+ });
+
+ return result.items
+ .map(item => item[params.idField])
+ .filter(
+ (value): value is string | number =>
+ typeof value === 'string' || typeof value === 'number'
+ )
+ .map(String);
+ }
+}
diff --git a/integrations/sap-s4hana/src/lib/errors.ts b/integrations/sap-s4hana/src/lib/errors.ts
new file mode 100644
index 0000000000..dd4f75eb96
--- /dev/null
+++ b/integrations/sap-s4hana/src/lib/errors.ts
@@ -0,0 +1,41 @@
+import { buildApiServiceError, createApiServiceError } from 'slates';
+
+export let sapValidationError = (message: string) =>
+ createApiServiceError(message, { reason: 'sap_s4hana_validation_error' });
+
+export let sapApiError = (error: unknown, operation = 'request') =>
+ buildApiServiceError(error, {
+ providerLabel: 'SAP S/4HANA',
+ operation,
+ reason: 'sap_s4hana_api_error',
+ detailKeys: [
+ 'message',
+ 'value',
+ 'code',
+ 'error',
+ 'error_description',
+ 'detail',
+ 'severity'
+ ],
+ nestedKeys: ['error', 'innererror', 'errordetails', 'details'],
+ extractMessage: (input, helpers) => {
+ let response = helpers.getResponse(input);
+ let details: string[] = [];
+ helpers.collectDetails(response?.data, details, {
+ detailKeys: [
+ 'message',
+ 'value',
+ 'code',
+ 'error',
+ 'error_description',
+ 'detail',
+ 'severity'
+ ],
+ nestedKeys: ['error', 'innererror', 'errordetails', 'details']
+ });
+
+ if (details.length > 0) return details.join(' - ');
+ if (input instanceof Error && input.message) return input.message;
+ return undefined;
+ }
+ });
diff --git a/integrations/sap-s4hana/src/spec.ts b/integrations/sap-s4hana/src/spec.ts
new file mode 100644
index 0000000000..e2aeaa4a85
--- /dev/null
+++ b/integrations/sap-s4hana/src/spec.ts
@@ -0,0 +1,13 @@
+import { SlateSpecification } from 'slates';
+import { auth } from './auth';
+import { config } from './config';
+
+export let spec = SlateSpecification.create({
+ key: 'sap-s4hana',
+ name: 'SAP S/4HANA',
+ description:
+ 'Read SAP S/4HANA ERP data from tenant-configured OData APIs, including business partners, orders, billing documents, products, purchasing, and supplier invoices.',
+ metadata: {},
+ config,
+ auth
+});
diff --git a/integrations/sap-s4hana/src/tools.schema.test.ts b/integrations/sap-s4hana/src/tools.schema.test.ts
new file mode 100644
index 0000000000..6d225fb627
--- /dev/null
+++ b/integrations/sap-s4hana/src/tools.schema.test.ts
@@ -0,0 +1,35 @@
+import { describeMcpCompatibleToolSchemas } from '@slates/test';
+import { describe, expect, it } from 'vitest';
+import { provider } from './index';
+
+describeMcpCompatibleToolSchemas('SAP S/4HANA tool input schemas', provider.actions);
+
+let readOnlyToolIds = [
+ 'list_business_partners',
+ 'get_business_partner',
+ 'list_sales_orders',
+ 'get_sales_order',
+ 'list_billing_documents',
+ 'get_billing_document',
+ 'list_products',
+ 'get_product',
+ 'list_purchase_orders',
+ 'get_purchase_order',
+ 'list_supplier_invoices',
+ 'get_supplier_invoice'
+];
+
+describe('SAP S/4HANA tool metadata', () => {
+ it('registers the expected read-only tool surface', () => {
+ expect(provider.actions.map(action => action.key).sort()).toEqual(
+ [...readOnlyToolIds].sort()
+ );
+
+ for (let action of provider.actions) {
+ expect(action.parameters.tags?.readOnly ?? false, `${action.key} readOnly`).toBe(true);
+ expect(action.parameters.tags?.destructive ?? false, `${action.key} destructive`).toBe(
+ false
+ );
+ }
+ });
+});
diff --git a/integrations/sap-s4hana/src/tools/billing-documents.ts b/integrations/sap-s4hana/src/tools/billing-documents.ts
new file mode 100644
index 0000000000..410bf9315d
--- /dev/null
+++ b/integrations/sap-s4hana/src/tools/billing-documents.ts
@@ -0,0 +1,216 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import { andFilters, odataDateTimeLiteral, odataStringLiteral } from '../lib/client';
+import { spec } from '../spec';
+import {
+ compactOutput,
+ createClient,
+ ensureFilteredQuery,
+ navigationArray,
+ pageInputSchema,
+ pageOutputSchema,
+ pageSummary,
+ rawRecordSchema,
+ stringValue,
+ topValue
+} from './shared';
+
+const serviceName = 'API_BILLING_DOCUMENT_SRV';
+
+let billingDocumentItemSchema = z.object({
+ billingDocument: z.string().optional().describe('Billing document id.'),
+ billingDocumentItem: z.string().optional().describe('Billing document item number.'),
+ salesDocument: z.string().optional().describe('Related sales document id.'),
+ material: z.string().optional().describe('Material/product id.'),
+ billingQuantity: z.string().optional().describe('Billing quantity.'),
+ netAmount: z.string().optional().describe('Item net amount.'),
+ transactionCurrency: z.string().optional().describe('Transaction currency.'),
+ record: rawRecordSchema
+});
+
+let billingDocumentSchema = z.object({
+ billingDocument: z.string().optional().describe('SAP billing document id.'),
+ billingDocumentType: z.string().optional().describe('Billing document type.'),
+ billingDocumentDate: z.string().optional().describe('Billing date.'),
+ payerParty: z.string().optional().describe('Payer party id.'),
+ soldToParty: z.string().optional().describe('Sold-to party id.'),
+ accountingDocument: z.string().optional().describe('Accounting document id.'),
+ accountingPostingStatus: z
+ .string()
+ .optional()
+ .describe('Posting status of the billing document.'),
+ accountingTransferStatus: z
+ .string()
+ .optional()
+ .describe('Status for transfer to accounting.'),
+ totalNetAmount: z.string().optional().describe('Total net amount.'),
+ totalGrossAmount: z.string().optional().describe('Total gross amount.'),
+ transactionCurrency: z.string().optional().describe('Transaction currency.'),
+ items: z.array(billingDocumentItemSchema).optional().describe('Expanded billing items.'),
+ record: rawRecordSchema
+});
+
+let mapBillingDocumentItem = (item: Record) => ({
+ ...compactOutput({
+ billingDocument: stringValue(item, 'BillingDocument'),
+ billingDocumentItem: stringValue(item, 'BillingDocumentItem'),
+ salesDocument: stringValue(item, 'SalesDocument'),
+ material: stringValue(item, 'Material'),
+ billingQuantity: stringValue(item, 'BillingQuantity'),
+ netAmount: stringValue(item, 'NetAmount'),
+ transactionCurrency: stringValue(item, 'TransactionCurrency')
+ }),
+ record: item
+});
+
+let mapBillingDocument = (document: Record) => {
+ let items = navigationArray(document, 'to_Item').map(mapBillingDocumentItem);
+
+ return {
+ ...compactOutput({
+ billingDocument: stringValue(document, 'BillingDocument'),
+ billingDocumentType: stringValue(document, 'BillingDocumentType'),
+ billingDocumentDate: stringValue(document, 'BillingDocumentDate'),
+ payerParty: stringValue(document, 'PayerParty'),
+ soldToParty: stringValue(document, 'SoldToParty'),
+ accountingDocument: stringValue(document, 'AccountingDocument'),
+ accountingPostingStatus: stringValue(document, 'AccountingPostingStatus'),
+ accountingTransferStatus: stringValue(document, 'AccountingTransferStatus'),
+ totalNetAmount: stringValue(document, 'TotalNetAmount'),
+ totalGrossAmount: stringValue(document, 'TotalGrossAmount'),
+ transactionCurrency: stringValue(document, 'TransactionCurrency'),
+ items: items.length > 0 ? items : undefined
+ }),
+ record: document
+ };
+};
+
+let buildBillingDocumentFilters = (input: {
+ billingDocument?: string;
+ payer?: string;
+ soldToParty?: string;
+ billingDateFrom?: string;
+ billingDateTo?: string;
+ accountingDocument?: string;
+}) =>
+ andFilters([
+ input.billingDocument
+ ? `BillingDocument eq ${odataStringLiteral(input.billingDocument)}`
+ : undefined,
+ input.payer ? `PayerParty eq ${odataStringLiteral(input.payer)}` : undefined,
+ input.soldToParty ? `SoldToParty eq ${odataStringLiteral(input.soldToParty)}` : undefined,
+ input.accountingDocument
+ ? `AccountingDocument eq ${odataStringLiteral(input.accountingDocument)}`
+ : undefined,
+ input.billingDateFrom
+ ? `BillingDocumentDate ge ${odataDateTimeLiteral(input.billingDateFrom, 'datetime')}`
+ : undefined,
+ input.billingDateTo
+ ? `BillingDocumentDate le ${odataDateTimeLiteral(input.billingDateTo, 'datetime')}`
+ : undefined
+ ]);
+
+export let listBillingDocuments = SlateTool.create(spec, {
+ name: 'List Billing Documents',
+ key: 'list_billing_documents',
+ description:
+ 'List SAP S/4HANA billing documents for invoice lookup, customer reporting, and accounting reconciliation.',
+ tags: {
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ billingDocument: z.string().optional().describe('Exact SAP billing document id.'),
+ payer: z.string().optional().describe('Payer party id.'),
+ soldToParty: z.string().optional().describe('Sold-to party id.'),
+ billingDateFrom: z.string().optional().describe('Billing date from.'),
+ billingDateTo: z.string().optional().describe('Billing date to.'),
+ accountingDocument: z.string().optional().describe('Related accounting document id.'),
+ expandItems: z.boolean().optional().describe('Expand billing document items.'),
+ ...pageInputSchema
+ })
+ )
+ .output(
+ z.object({
+ billingDocuments: z.array(billingDocumentSchema).describe('Billing documents.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ ensureFilteredQuery(
+ ctx.input,
+ {
+ billingDocument: ctx.input.billingDocument,
+ payer: ctx.input.payer,
+ soldToParty: ctx.input.soldToParty,
+ billingDateFrom: ctx.input.billingDateFrom,
+ billingDateTo: ctx.input.billingDateTo,
+ accountingDocument: ctx.input.accountingDocument
+ },
+ 'billing document'
+ );
+
+ let client = createClient(ctx);
+ let result = await client.queryEntitySet>({
+ serviceName,
+ entitySet: 'A_BillingDocument',
+ pageToken: ctx.input.skipToken,
+ query: {
+ $top: topValue(ctx.input),
+ $filter: buildBillingDocumentFilters(ctx.input) || undefined,
+ $orderby: ctx.input.orderBy,
+ $expand: ctx.input.expandItems ? 'to_Item' : undefined
+ }
+ });
+ let billingDocuments = result.items.map(mapBillingDocument);
+
+ return {
+ output: {
+ billingDocuments,
+ page: pageSummary(ctx.input, billingDocuments.length, result.nextPageToken)
+ },
+ message: `Retrieved **${billingDocuments.length}** SAP S/4HANA billing document(s).`
+ };
+ })
+ .build();
+
+export let getBillingDocument = SlateTool.create(spec, {
+ name: 'Get Billing Document',
+ key: 'get_billing_document',
+ description:
+ 'Retrieve a SAP S/4HANA billing document by id, including item lines where authorized.',
+ tags: {
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ billingDocument: z.string().min(1).describe('SAP billing document id.')
+ })
+ )
+ .output(
+ z.object({
+ billingDocument: billingDocumentSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let client = createClient(ctx);
+ let document = await client.getEntity>({
+ serviceName,
+ entitySet: 'A_BillingDocument',
+ key: ctx.input.billingDocument,
+ query: {
+ $expand: 'to_Item'
+ }
+ });
+ let mapped = mapBillingDocument(document);
+
+ return {
+ output: {
+ billingDocument: mapped
+ },
+ message: `Retrieved SAP S/4HANA billing document **${mapped.billingDocument ?? ctx.input.billingDocument}**.`
+ };
+ })
+ .build();
diff --git a/integrations/sap-s4hana/src/tools/business-partners.test.ts b/integrations/sap-s4hana/src/tools/business-partners.test.ts
new file mode 100644
index 0000000000..b4bb93d4b9
--- /dev/null
+++ b/integrations/sap-s4hana/src/tools/business-partners.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+import { buildBusinessPartnerFilters, expandFor, mapAddress } from './business-partners';
+
+describe('SAP S/4HANA business partners tool helpers', () => {
+ it('uses nested address communication expansions for get_business_partner details', () => {
+ expect(expandFor('addresses')).toEqual([
+ 'to_BusinessPartnerAddress',
+ 'to_Customer',
+ 'to_Supplier'
+ ]);
+
+ expect(expandFor('addresses', { includeAddressCommunication: true })).toEqual([
+ 'to_BusinessPartnerAddress',
+ 'to_BusinessPartnerAddress/to_EmailAddress',
+ 'to_BusinessPartnerAddress/to_PhoneNumber',
+ 'to_BusinessPartnerAddress/to_MobilePhoneNumber',
+ 'to_Customer',
+ 'to_Supplier'
+ ]);
+ });
+
+ it('maps documented nested address communication records', () => {
+ expect(
+ mapAddress({
+ AddressID: '28238',
+ Country: 'US',
+ CityName: 'Newtown Square',
+ to_EmailAddress: {
+ results: [
+ {
+ EmailAddress: 'business.partner@example.com'
+ }
+ ]
+ },
+ to_PhoneNumber: {
+ results: [
+ {
+ PhoneNumber: '+1 610 555 0100'
+ }
+ ]
+ }
+ })
+ ).toMatchObject({
+ addressId: '28238',
+ country: 'US',
+ cityName: 'Newtown Square',
+ emailAddress: 'business.partner@example.com',
+ phoneNumber: '+1 610 555 0100'
+ });
+ });
+
+ it('uses documented business partner fields for list filters', () => {
+ expect(
+ buildBusinessPartnerFilters({
+ businessPartner: '1000000',
+ search: 'Acme',
+ createdSince: '2026-01-02T03:04:05.678Z',
+ updatedSince: '2026-02-03T04:05:06.789Z',
+ customer: true,
+ supplier: true
+ })
+ ).toBe(
+ "BusinessPartner eq '1000000' and (substringof('Acme', BusinessPartner) or substringof('Acme', BusinessPartnerFullName) or substringof('Acme', OrganizationBPName1) or substringof('Acme', FirstName) or substringof('Acme', LastName)) and CreationDate ge datetime'2026-01-02T03:04:05' and LastChangeDate ge datetime'2026-02-03T04:05:06' and Customer ne '' and Supplier ne ''"
+ );
+ });
+});
diff --git a/integrations/sap-s4hana/src/tools/business-partners.ts b/integrations/sap-s4hana/src/tools/business-partners.ts
new file mode 100644
index 0000000000..7181c5d1ca
--- /dev/null
+++ b/integrations/sap-s4hana/src/tools/business-partners.ts
@@ -0,0 +1,404 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import {
+ andFilters,
+ odataDateTimeLiteral,
+ odataStringLiteral,
+ orFilters,
+ substringFilter
+} from '../lib/client';
+import { spec } from '../spec';
+import {
+ compactOutput,
+ createClient,
+ ensureFilteredQuery,
+ firstNavigationRecord,
+ navigationArray,
+ pageInputSchema,
+ pageOutputSchema,
+ pageSummary,
+ rawRecordSchema,
+ stringValue,
+ topValue,
+ uniqueStrings
+} from './shared';
+
+const serviceName = 'API_BUSINESS_PARTNER';
+
+let addressSchema = z.object({
+ addressId: z.string().optional().describe('SAP address id.'),
+ country: z.string().optional().describe('Country/region code.'),
+ cityName: z.string().optional().describe('City.'),
+ postalCode: z.string().optional().describe('Postal code.'),
+ streetName: z.string().optional().describe('Street name.'),
+ houseNumber: z.string().optional().describe('House number.'),
+ emailAddress: z.string().optional().describe('Email address when expanded and authorized.'),
+ phoneNumber: z.string().optional().describe('Phone number when expanded and authorized.'),
+ record: rawRecordSchema
+});
+
+let roleSchema = z.object({
+ role: z.string().optional().describe('SAP business partner role code.'),
+ validFrom: z.string().optional().describe('Role validity start.'),
+ validTo: z.string().optional().describe('Role validity end.'),
+ record: rawRecordSchema
+});
+
+let businessPartnerSchema = z.object({
+ businessPartner: z.string().optional().describe('SAP business partner id.'),
+ businessPartnerCategory: z.string().optional().describe('Business partner category.'),
+ fullName: z.string().optional().describe('Person or organization display name.'),
+ organizationName: z.string().optional().describe('Organization name.'),
+ firstName: z.string().optional().describe('Person first name.'),
+ lastName: z.string().optional().describe('Person last name.'),
+ customer: z.string().optional().describe('SAP customer id when the partner is a customer.'),
+ supplier: z.string().optional().describe('SAP supplier id when the partner is a supplier.'),
+ isCustomer: z.boolean().optional().describe('Whether a customer role/entity was expanded.'),
+ isSupplier: z.boolean().optional().describe('Whether a supplier role/entity was expanded.'),
+ roles: z.array(roleSchema).optional().describe('Expanded business partner roles.'),
+ addresses: z
+ .array(addressSchema)
+ .optional()
+ .describe('Expanded business partner addresses.'),
+ taxNumbers: z.array(rawRecordSchema).optional().describe('Expanded tax number records.'),
+ bankAccounts: z.array(rawRecordSchema).optional().describe('Expanded bank account records.'),
+ lastChangeDate: z.string().optional().describe('Last changed date.'),
+ lastChangeTime: z.string().optional().describe('Last changed time.'),
+ lastChangeDateTime: z
+ .string()
+ .optional()
+ .describe('Last changed timestamp when returned by the SAP tenant.'),
+ record: rawRecordSchema
+});
+
+let firstNavigationString = (
+ record: Record,
+ navigation: string,
+ field: string
+) => {
+ let value = firstNavigationRecord(record, navigation);
+ return value ? stringValue(value, field) : undefined;
+};
+
+export let mapAddress = (address: Record) => ({
+ ...compactOutput({
+ addressId: stringValue(address, 'AddressID'),
+ country: stringValue(address, 'Country'),
+ cityName: stringValue(address, 'CityName'),
+ postalCode: stringValue(address, 'PostalCode'),
+ streetName: stringValue(address, 'StreetName'),
+ houseNumber: stringValue(address, 'HouseNumber'),
+ emailAddress:
+ stringValue(address, 'EmailAddress') ??
+ firstNavigationString(address, 'to_EmailAddress', 'EmailAddress'),
+ phoneNumber:
+ stringValue(address, 'PhoneNumber') ??
+ firstNavigationString(address, 'to_PhoneNumber', 'PhoneNumber') ??
+ firstNavigationString(address, 'to_MobilePhoneNumber', 'PhoneNumber')
+ }),
+ record: address
+});
+
+let mapRole = (role: Record) => ({
+ ...compactOutput({
+ role: stringValue(role, 'BusinessPartnerRole'),
+ validFrom: stringValue(role, 'ValidFrom'),
+ validTo: stringValue(role, 'ValidTo')
+ }),
+ record: role
+});
+
+let mapBusinessPartner = (partner: Record) => {
+ let roles = navigationArray(partner, 'to_BusinessPartnerRole').map(mapRole);
+ let addresses = navigationArray(partner, 'to_BusinessPartnerAddress').map(mapAddress);
+ let customer = firstNavigationRecord(partner, 'to_Customer');
+ let supplier = firstNavigationRecord(partner, 'to_Supplier');
+ let partnerCustomer = stringValue(partner, 'Customer') || undefined;
+ let partnerSupplier = stringValue(partner, 'Supplier') || undefined;
+ let taxNumbers = navigationArray(partner, 'to_BusinessPartnerTax');
+ let bankAccounts = navigationArray(partner, 'to_BusinessPartnerBank');
+
+ return {
+ ...compactOutput({
+ businessPartner: stringValue(partner, 'BusinessPartner'),
+ businessPartnerCategory: stringValue(partner, 'BusinessPartnerCategory'),
+ fullName:
+ stringValue(partner, 'BusinessPartnerFullName') ??
+ stringValue(partner, 'FullName') ??
+ stringValue(partner, 'BusinessPartnerName'),
+ organizationName:
+ stringValue(partner, 'OrganizationBPName1') ??
+ stringValue(partner, 'OrganizationBPName2'),
+ firstName: stringValue(partner, 'FirstName'),
+ lastName: stringValue(partner, 'LastName'),
+ customer: partnerCustomer ?? (customer ? stringValue(customer, 'Customer') : undefined),
+ supplier: partnerSupplier ?? (supplier ? stringValue(supplier, 'Supplier') : undefined),
+ isCustomer: partnerCustomer || customer ? true : undefined,
+ isSupplier: partnerSupplier || supplier ? true : undefined,
+ roles: roles.length > 0 ? roles : undefined,
+ addresses: addresses.length > 0 ? addresses : undefined,
+ taxNumbers: taxNumbers.length > 0 ? taxNumbers : undefined,
+ bankAccounts: bankAccounts.length > 0 ? bankAccounts : undefined,
+ lastChangeDate: stringValue(partner, 'LastChangeDate'),
+ lastChangeTime: stringValue(partner, 'LastChangeTime'),
+ lastChangeDateTime: stringValue(partner, 'LastChangeDateTime')
+ }),
+ record: partner
+ };
+};
+
+let addressExpansions = (includeCommunication = false) => [
+ 'to_BusinessPartnerAddress',
+ ...(includeCommunication
+ ? [
+ 'to_BusinessPartnerAddress/to_EmailAddress',
+ 'to_BusinessPartnerAddress/to_PhoneNumber',
+ 'to_BusinessPartnerAddress/to_MobilePhoneNumber'
+ ]
+ : [])
+];
+
+export let expandFor = (
+ expand?: 'summary' | 'roles' | 'addresses' | 'customerSupplier' | 'financial' | 'all',
+ options: { includeAddressCommunication?: boolean } = {}
+) => {
+ if (expand === 'summary' || !expand) return ['to_Customer', 'to_Supplier'];
+ if (expand === 'roles') return ['to_BusinessPartnerRole', 'to_Customer', 'to_Supplier'];
+ if (expand === 'addresses')
+ return [
+ ...addressExpansions(options.includeAddressCommunication),
+ 'to_Customer',
+ 'to_Supplier'
+ ];
+ if (expand === 'customerSupplier') return ['to_Customer', 'to_Supplier'];
+ if (expand === 'financial') {
+ return ['to_BusinessPartnerTax', 'to_BusinessPartnerBank', 'to_Customer', 'to_Supplier'];
+ }
+ return [
+ ...addressExpansions(options.includeAddressCommunication),
+ 'to_BusinessPartnerRole',
+ 'to_BusinessPartnerTax',
+ 'to_BusinessPartnerBank',
+ 'to_Customer',
+ 'to_Supplier'
+ ];
+};
+
+export let buildBusinessPartnerFilters = (input: {
+ search?: string;
+ businessPartner?: string;
+ customer?: boolean;
+ supplier?: boolean;
+ createdSince?: string;
+ updatedSince?: string;
+}) =>
+ andFilters([
+ input.businessPartner
+ ? `BusinessPartner eq ${odataStringLiteral(input.businessPartner)}`
+ : undefined,
+ input.search
+ ? orFilters([
+ substringFilter('BusinessPartner', input.search),
+ substringFilter('BusinessPartnerFullName', input.search),
+ substringFilter('OrganizationBPName1', input.search),
+ substringFilter('FirstName', input.search),
+ substringFilter('LastName', input.search)
+ ])
+ : undefined,
+ input.createdSince
+ ? `CreationDate ge ${odataDateTimeLiteral(input.createdSince, 'datetime')}`
+ : undefined,
+ input.updatedSince
+ ? `LastChangeDate ge ${odataDateTimeLiteral(input.updatedSince, 'datetime')}`
+ : undefined,
+ input.customer ? "Customer ne ''" : undefined,
+ input.supplier ? "Supplier ne ''" : undefined
+ ]);
+
+export let listBusinessPartners = SlateTool.create(spec, {
+ name: 'List Business Partners',
+ key: 'list_business_partners',
+ description:
+ 'List SAP S/4HANA business partners with optional identity, search, role, customer, supplier, created, and changed filters.',
+ tags: {
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ search: z
+ .string()
+ .optional()
+ .describe(
+ 'Search business partner id, full name, organization name, first name, or last name.'
+ ),
+ businessPartner: z.string().optional().describe('Exact SAP business partner id.'),
+ role: z
+ .string()
+ .optional()
+ .describe('Business partner role code such as FLCU01 or FLVN01.'),
+ customer: z
+ .boolean()
+ .optional()
+ .describe('Set true to restrict to partners with a documented customer id.'),
+ supplier: z
+ .boolean()
+ .optional()
+ .describe('Set true to restrict to partners with a documented supplier id.'),
+ createdSince: z
+ .string()
+ .optional()
+ .describe('Return partners created on or after this date/datetime.'),
+ updatedSince: z
+ .string()
+ .optional()
+ .describe('Return partners changed on or after this date/datetime.'),
+ expand: z
+ .enum(['summary', 'roles', 'addresses', 'customerSupplier', 'financial', 'all'])
+ .optional()
+ .describe(
+ 'Related business partner data to expand. Defaults to customer/supplier summary.'
+ ),
+ ...pageInputSchema
+ })
+ )
+ .output(
+ z.object({
+ businessPartners: z.array(businessPartnerSchema).describe('Business partners.'),
+ page: pageOutputSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ ensureFilteredQuery(
+ ctx.input,
+ {
+ search: ctx.input.search,
+ businessPartner: ctx.input.businessPartner,
+ role: ctx.input.role,
+ customer: ctx.input.customer,
+ supplier: ctx.input.supplier,
+ createdSince: ctx.input.createdSince,
+ updatedSince: ctx.input.updatedSince
+ },
+ 'business partner'
+ );
+
+ let client = createClient(ctx);
+ let filters = buildBusinessPartnerFilters(ctx.input);
+ let partnerIds: string[] | undefined;
+
+ if (ctx.input.role) {
+ partnerIds = await client.queryEntityIds({
+ serviceName,
+ entitySet: 'A_BusinessPartnerRole',
+ idField: 'BusinessPartner',
+ filter: `BusinessPartnerRole eq ${odataStringLiteral(ctx.input.role)}`,
+ top: topValue(ctx.input)
+ });
+ }
+
+ let idFilters = [partnerIds]
+ .filter((ids): ids is string[] => ids !== undefined)
+ .map(ids => uniqueStrings(ids));
+
+ if (idFilters.some(ids => ids.length === 0)) {
+ return {
+ output: {
+ businessPartners: [],
+ page: pageSummary(ctx.input, 0)
+ },
+ message: 'Retrieved **0** SAP S/4HANA business partner(s).'
+ };
+ }
+
+ let intersectedIds =
+ idFilters.length === 0
+ ? undefined
+ : idFilters.reduce((left, right) => left.filter(id => right.includes(id)));
+
+ if (intersectedIds && intersectedIds.length === 0) {
+ return {
+ output: {
+ businessPartners: [],
+ page: pageSummary(ctx.input, 0)
+ },
+ message: 'Retrieved **0** SAP S/4HANA business partner(s).'
+ };
+ }
+
+ let idFilter = intersectedIds
+ ? orFilters(intersectedIds.map(id => `BusinessPartner eq ${odataStringLiteral(id)}`))
+ : undefined;
+
+ let result = await client.queryEntitySet>({
+ serviceName,
+ entitySet: 'A_BusinessPartner',
+ pageToken: ctx.input.skipToken,
+ query: {
+ $top: topValue(ctx.input),
+ $filter: andFilters([filters, idFilter]) || undefined,
+ $orderby: ctx.input.orderBy,
+ $expand: expandFor(ctx.input.expand).join(',')
+ }
+ });
+
+ let businessPartners = result.items.map(mapBusinessPartner);
+
+ return {
+ output: {
+ businessPartners,
+ page: pageSummary(ctx.input, businessPartners.length, result.nextPageToken)
+ },
+ message: `Retrieved **${businessPartners.length}** SAP S/4HANA business partner(s).`
+ };
+ })
+ .build();
+
+export let getBusinessPartner = SlateTool.create(spec, {
+ name: 'Get Business Partner',
+ key: 'get_business_partner',
+ description:
+ 'Retrieve a SAP S/4HANA business partner by id with optional addresses, roles, customer/supplier, tax, and bank metadata.',
+ tags: {
+ readOnly: true
+ }
+})
+ .input(
+ z.object({
+ businessPartner: z.string().min(1).describe('SAP business partner id.'),
+ expand: z
+ .enum(['summary', 'roles', 'addresses', 'customerSupplier', 'financial', 'all'])
+ .optional()
+ .describe(
+ 'Related business partner data to expand. Defaults to all supported read-only details.'
+ )
+ })
+ )
+ .output(
+ z.object({
+ businessPartner: businessPartnerSchema
+ })
+ )
+ .handleInvocation(async ctx => {
+ let client = createClient(ctx);
+ let partner = await client.getEntity>({
+ serviceName,
+ entitySet: 'A_BusinessPartner',
+ key: ctx.input.businessPartner,
+ query: {
+ $expand: expandFor(ctx.input.expand ?? 'all', {
+ includeAddressCommunication: true
+ }).join(',')
+ }
+ });
+
+ let mapped = mapBusinessPartner(partner);
+
+ return {
+ output: {
+ businessPartner: mapped
+ },
+ message: `Retrieved SAP S/4HANA business partner **${mapped.businessPartner ?? ctx.input.businessPartner}**.`
+ };
+ })
+ .build();
diff --git a/integrations/sap-s4hana/src/tools/index.ts b/integrations/sap-s4hana/src/tools/index.ts
new file mode 100644
index 0000000000..03e53b8d59
--- /dev/null
+++ b/integrations/sap-s4hana/src/tools/index.ts
@@ -0,0 +1,6 @@
+export * from './billing-documents';
+export * from './business-partners';
+export * from './products';
+export * from './purchase-orders';
+export * from './sales-orders';
+export * from './supplier-invoices';
diff --git a/integrations/sap-s4hana/src/tools/products.test.ts b/integrations/sap-s4hana/src/tools/products.test.ts
new file mode 100644
index 0000000000..e20eebfa2e
--- /dev/null
+++ b/integrations/sap-s4hana/src/tools/products.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { buildProductFilters, mapProductSales } from './products';
+
+describe('SAP S/4HANA products tool helpers', () => {
+ it('maps documented product sales delivery fields', () => {
+ expect(
+ mapProductSales({
+ ProductSalesOrg: '1010',
+ ProductDistributionChnl: '10'
+ })
+ ).toMatchObject({
+ salesOrganization: '1010',
+ distributionChannel: '10'
+ });
+ });
+
+ it('resolves description, plant, and sales organization filters through product ids', async () => {
+ let calls: Array<{
+ serviceName: string;
+ entitySet: string;
+ idField: string;
+ filter?: string;
+ top?: number;
+ }> = [];
+
+ let client = {
+ queryEntityIds: async (params: (typeof calls)[number]) => {
+ calls.push(params);
+ if (params.entitySet === 'A_ProductDescription') return ['DESC-1'];
+ if (params.entitySet === 'A_ProductPlant') return ['PLANT-1'];
+ if (params.entitySet === 'A_ProductSalesDelivery') return ['SALES-1', 'SALES-2'];
+ return [];
+ }
+ };
+
+ let filter = await buildProductFilters(
+ {
+ description: 'pump',
+ productType: 'FERT',
+ plant: '1010',
+ salesOrg: '1710'
+ },
+ client
+ );
+
+ expect(calls).toEqual([
+ {
+ serviceName: 'API_PRODUCT_SRV',
+ entitySet: 'A_ProductDescription',
+ idField: 'Product',
+ filter: "substringof('pump', ProductDescription)",
+ top: 100
+ },
+ {
+ serviceName: 'API_PRODUCT_SRV',
+ entitySet: 'A_ProductPlant',
+ idField: 'Product',
+ filter: "Plant eq '1010'",
+ top: 100
+ },
+ {
+ serviceName: 'API_PRODUCT_SRV',
+ entitySet: 'A_ProductSalesDelivery',
+ idField: 'Product',
+ filter: "ProductSalesOrg eq '1710'",
+ top: 100
+ }
+ ]);
+ expect(filter).toBe(
+ "ProductType eq 'FERT' and (substringof('pump', Product) or substringof('pump', ProductGroup) or Product eq 'DESC-1') and Product eq 'PLANT-1' and (Product eq 'SALES-1' or Product eq 'SALES-2')"
+ );
+ });
+
+ it('returns an impossible product filter when a child entity filter has no matches', async () => {
+ let client = {
+ queryEntityIds: async () => []
+ };
+
+ await expect(buildProductFilters({ plant: '1010' }, client)).resolves.toBe(
+ "Product eq '__slates_no_product_match__'"
+ );
+ });
+});
diff --git a/integrations/sap-s4hana/src/tools/products.ts b/integrations/sap-s4hana/src/tools/products.ts
new file mode 100644
index 0000000000..624a37ead6
--- /dev/null
+++ b/integrations/sap-s4hana/src/tools/products.ts
@@ -0,0 +1,306 @@
+import { SlateTool } from 'slates';
+import { z } from 'zod';
+import {
+ andFilters,
+ odataStringLiteral,
+ orFilters,
+ type SapS4HanaClient,
+ substringFilter
+} from '../lib/client';
+import { spec } from '../spec';
+import {
+ compactOutput,
+ createClient,
+ ensureFilteredQuery,
+ navigationArray,
+ pageInputSchema,
+ pageOutputSchema,
+ pageSummary,
+ rawRecordSchema,
+ stringValue,
+ topValue
+} from './shared';
+
+const serviceName = 'API_PRODUCT_SRV';
+
+let productDescriptionSchema = z.object({
+ language: z.string().optional().describe('Language code.'),
+ description: z.string().optional().describe('Product description.'),
+ record: rawRecordSchema
+});
+
+let productPlantSchema = z.object({
+ plant: z.string().optional().describe('Plant.'),
+ procurementType: z.string().optional().describe('Procurement type.'),
+ mrpType: z.string().optional().describe('MRP type.'),
+ record: rawRecordSchema
+});
+
+let productSalesSchema = z.object({
+ salesOrganization: z.string().optional().describe('Sales organization.'),
+ distributionChannel: z.string().optional().describe('Distribution channel.'),
+ record: rawRecordSchema
+});
+
+let productSchema = z.object({
+ product: z.string().optional().describe('SAP product/material id.'),
+ productType: z.string().optional().describe('Product/material type.'),
+ baseUnit: z.string().optional().describe('Base unit of measure.'),
+ productGroup: z.string().optional().describe('Product group.'),
+ crossPlantStatus: z.string().optional().describe('Cross-plant product status.'),
+ description: z.string().optional().describe('Primary product description when expanded.'),
+ descriptions: z
+ .array(productDescriptionSchema)
+ .optional()
+ .describe('Expanded descriptions.'),
+ plants: z.array(productPlantSchema).optional().describe('Expanded plant metadata.'),
+ sales: z.array(productSalesSchema).optional().describe('Expanded sales metadata.'),
+ record: rawRecordSchema
+});
+
+let mapProductDescription = (description: Record) => ({
+ ...compactOutput({
+ language: stringValue(description, 'Language'),
+ description: stringValue(description, 'ProductDescription')
+ }),
+ record: description
+});
+
+let mapProductPlant = (plant: Record) => ({
+ ...compactOutput({
+ plant: stringValue(plant, 'Plant'),
+ procurementType: stringValue(plant, 'ProcurementType'),
+ mrpType: stringValue(plant, 'MRPType')
+ }),
+ record: plant
+});
+
+export let mapProductSales = (sales: Record) => ({
+ ...compactOutput({
+ salesOrganization:
+ stringValue(sales, 'ProductSalesOrg') ?? stringValue(sales, 'SalesOrganization'),
+ distributionChannel:
+ stringValue(sales, 'ProductDistributionChnl') ??
+ stringValue(sales, 'DistributionChannel')
+ }),
+ record: sales
+});
+
+let mapProduct = (product: Record