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). + +
+ Built with ❤️ by Metorial +
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). + +
+ Built with ❤️ by Metorial +
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). + +
+ Built with ❤️ by Metorial +
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). + +
+ Built with ❤️ by Metorial +
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) => { + let descriptions = navigationArray(product, 'to_Description').map(mapProductDescription); + let plants = navigationArray(product, 'to_Plant').map(mapProductPlant); + let sales = + navigationArray(product, 'to_SalesDelivery').length > 0 + ? navigationArray(product, 'to_SalesDelivery').map(mapProductSales) + : navigationArray(product, 'to_Sales').map(mapProductSales); + + return { + ...compactOutput({ + product: stringValue(product, 'Product'), + productType: stringValue(product, 'ProductType'), + baseUnit: stringValue(product, 'BaseUnit'), + productGroup: stringValue(product, 'ProductGroup'), + crossPlantStatus: stringValue(product, 'CrossPlantStatus'), + description: descriptions[0]?.description, + descriptions: descriptions.length > 0 ? descriptions : undefined, + plants: plants.length > 0 ? plants : undefined, + sales: sales.length > 0 ? sales : undefined + }), + record: product + }; +}; + +type ProductFilterInput = { + product?: string; + description?: string; + productType?: string; + plant?: string; + salesOrg?: string; +}; + +type ProductFilterClient = Pick; + +let noProductMatchFilter = `Product eq ${odataStringLiteral('__slates_no_product_match__')}`; +let productFilterIdLimit = 100; + +let productIdFilter = (productIds: string[]) => { + let uniqueProductIds = [...new Set(productIds)]; + if (uniqueProductIds.length === 0) return noProductMatchFilter; + + return orFilters( + uniqueProductIds.map(productId => `Product eq ${odataStringLiteral(productId)}`) + ); +}; + +let queryProductIds = async (client: ProductFilterClient, entitySet: string, filter: string) => + client.queryEntityIds({ + serviceName, + entitySet, + idField: 'Product', + filter, + top: productFilterIdLimit + }); + +export let buildProductFilters = async ( + input: ProductFilterInput, + client: ProductFilterClient +) => { + let descriptionProductIds = input.description + ? await queryProductIds( + client, + 'A_ProductDescription', + substringFilter('ProductDescription', input.description) + ) + : []; + + let plantProductIds = input.plant + ? await queryProductIds( + client, + 'A_ProductPlant', + `Plant eq ${odataStringLiteral(input.plant)}` + ) + : []; + + let salesOrgProductIds = input.salesOrg + ? await queryProductIds( + client, + 'A_ProductSalesDelivery', + `ProductSalesOrg eq ${odataStringLiteral(input.salesOrg)}` + ) + : []; + + let descriptionFilter = input.description + ? orFilters([ + substringFilter('Product', input.description), + substringFilter('ProductGroup', input.description), + productIdFilter(descriptionProductIds) + ]) + : undefined; + + return andFilters([ + input.product ? `Product eq ${odataStringLiteral(input.product)}` : undefined, + input.productType ? `ProductType eq ${odataStringLiteral(input.productType)}` : undefined, + descriptionFilter, + input.plant ? productIdFilter(plantProductIds) : undefined, + input.salesOrg ? productIdFilter(salesOrgProductIds) : undefined + ]); +}; + +let productExpand = 'to_Description,to_Plant,to_SalesDelivery'; + +export let listProducts = SlateTool.create(spec, { + name: 'List Products', + key: 'list_products', + description: + 'List SAP S/4HANA product/material master records for product lookup, sales order preparation, purchasing, and reporting.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + product: z.string().optional().describe('Exact SAP product/material id.'), + description: z + .string() + .optional() + .describe( + 'Search product id or product group. Description text may require expand support in the tenant.' + ), + productType: z.string().optional().describe('SAP product/material type.'), + plant: z + .string() + .optional() + .describe('Plant filter where supported by the SAP service.'), + salesOrg: z + .string() + .optional() + .describe('Sales organization filter where supported by the SAP service.'), + expandDetails: z + .boolean() + .optional() + .describe('Expand descriptions, plant metadata, and sales metadata.'), + ...pageInputSchema + }) + ) + .output( + z.object({ + products: z.array(productSchema).describe('Products/materials.'), + page: pageOutputSchema + }) + ) + .handleInvocation(async ctx => { + ensureFilteredQuery( + ctx.input, + { + product: ctx.input.product, + description: ctx.input.description, + productType: ctx.input.productType, + plant: ctx.input.plant, + salesOrg: ctx.input.salesOrg + }, + 'product' + ); + + let client = createClient(ctx); + let result = await client.queryEntitySet>({ + serviceName, + entitySet: 'A_Product', + pageToken: ctx.input.skipToken, + query: { + $top: topValue(ctx.input), + $filter: (await buildProductFilters(ctx.input, client)) || undefined, + $orderby: ctx.input.orderBy, + $expand: ctx.input.expandDetails ? productExpand : undefined + } + }); + let products = result.items.map(mapProduct); + + return { + output: { + products, + page: pageSummary(ctx.input, products.length, result.nextPageToken) + }, + message: `Retrieved **${products.length}** SAP S/4HANA product(s).` + }; + }) + .build(); + +export let getProduct = SlateTool.create(spec, { + name: 'Get Product', + key: 'get_product', + description: + 'Retrieve a SAP S/4HANA product/material by id, including descriptions, plant metadata, and sales metadata where authorized.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + product: z.string().min(1).describe('SAP product/material id.') + }) + ) + .output( + z.object({ + product: productSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let product = await client.getEntity>({ + serviceName, + entitySet: 'A_Product', + key: ctx.input.product, + query: { + $expand: productExpand + } + }); + let mapped = mapProduct(product); + + return { + output: { + product: mapped + }, + message: `Retrieved SAP S/4HANA product **${mapped.product ?? ctx.input.product}**.` + }; + }) + .build(); diff --git a/integrations/sap-s4hana/src/tools/purchase-orders.ts b/integrations/sap-s4hana/src/tools/purchase-orders.ts new file mode 100644 index 0000000000..06f5766852 --- /dev/null +++ b/integrations/sap-s4hana/src/tools/purchase-orders.ts @@ -0,0 +1,208 @@ +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_PURCHASEORDER_PROCESS_SRV'; + +let purchaseOrderItemSchema = z.object({ + purchaseOrder: z.string().optional().describe('Purchase order id.'), + purchaseOrderItem: z.string().optional().describe('Purchase order item number.'), + material: z.string().optional().describe('Material/product id.'), + plant: z.string().optional().describe('Plant.'), + orderQuantity: z.string().optional().describe('Order quantity.'), + purchaseOrderQuantityUnit: z.string().optional().describe('Quantity unit.'), + netPriceAmount: z.string().optional().describe('Item net price amount.'), + documentCurrency: z.string().optional().describe('Document currency.'), + record: rawRecordSchema +}); + +let purchaseOrderSchema = z.object({ + purchaseOrder: z.string().optional().describe('SAP purchase order id.'), + purchaseOrderType: z.string().optional().describe('Purchase order type.'), + supplier: z.string().optional().describe('Supplier id.'), + companyCode: z.string().optional().describe('Company code.'), + purchasingOrganization: z.string().optional().describe('Purchasing organization.'), + purchasingGroup: z.string().optional().describe('Purchasing group.'), + documentCurrency: z.string().optional().describe('Document currency.'), + purchaseOrderDate: z.string().optional().describe('Purchase order date.'), + purchasingProcessingStatus: z.string().optional().describe('Processing status.'), + items: z + .array(purchaseOrderItemSchema) + .optional() + .describe('Expanded purchase order items.'), + record: rawRecordSchema +}); + +let mapPurchaseOrderItem = (item: Record) => ({ + ...compactOutput({ + purchaseOrder: stringValue(item, 'PurchaseOrder'), + purchaseOrderItem: stringValue(item, 'PurchaseOrderItem'), + material: stringValue(item, 'Material'), + plant: stringValue(item, 'Plant'), + orderQuantity: stringValue(item, 'OrderQuantity'), + purchaseOrderQuantityUnit: stringValue(item, 'PurchaseOrderQuantityUnit'), + netPriceAmount: stringValue(item, 'NetPriceAmount'), + documentCurrency: stringValue(item, 'DocumentCurrency') + }), + record: item +}); + +let mapPurchaseOrder = (order: Record) => { + let items = navigationArray(order, 'to_PurchaseOrderItem').map(mapPurchaseOrderItem); + + return { + ...compactOutput({ + purchaseOrder: stringValue(order, 'PurchaseOrder'), + purchaseOrderType: stringValue(order, 'PurchaseOrderType'), + supplier: stringValue(order, 'Supplier'), + companyCode: stringValue(order, 'CompanyCode'), + purchasingOrganization: stringValue(order, 'PurchasingOrganization'), + purchasingGroup: stringValue(order, 'PurchasingGroup'), + documentCurrency: stringValue(order, 'DocumentCurrency'), + purchaseOrderDate: stringValue(order, 'PurchaseOrderDate'), + purchasingProcessingStatus: stringValue(order, 'PurchasingProcessingStatus'), + items: items.length > 0 ? items : undefined + }), + record: order + }; +}; + +let buildPurchaseOrderFilters = (input: { + purchaseOrder?: string; + supplier?: string; + purchasingOrganization?: string; + companyCode?: string; + createdSince?: string; +}) => + andFilters([ + input.purchaseOrder + ? `PurchaseOrder eq ${odataStringLiteral(input.purchaseOrder)}` + : undefined, + input.supplier ? `Supplier eq ${odataStringLiteral(input.supplier)}` : undefined, + input.purchasingOrganization + ? `PurchasingOrganization eq ${odataStringLiteral(input.purchasingOrganization)}` + : undefined, + input.companyCode ? `CompanyCode eq ${odataStringLiteral(input.companyCode)}` : undefined, + input.createdSince + ? `CreationDate ge ${odataDateTimeLiteral(input.createdSince, 'datetime')}` + : undefined + ]); + +export let listPurchaseOrders = SlateTool.create(spec, { + name: 'List Purchase Orders', + key: 'list_purchase_orders', + description: + 'List SAP S/4HANA purchase order headers for procure-to-pay visibility, supplier lookup, and purchasing reporting.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + purchaseOrder: z.string().optional().describe('Exact SAP purchase order id.'), + supplier: z.string().optional().describe('Supplier id.'), + purchasingOrganization: z.string().optional().describe('Purchasing organization.'), + companyCode: z.string().optional().describe('Company code.'), + createdSince: z + .string() + .optional() + .describe('Return purchase orders created on or after this date.'), + expandItems: z.boolean().optional().describe('Expand purchase order items.'), + ...pageInputSchema + }) + ) + .output( + z.object({ + purchaseOrders: z.array(purchaseOrderSchema).describe('Purchase orders.'), + page: pageOutputSchema + }) + ) + .handleInvocation(async ctx => { + ensureFilteredQuery( + ctx.input, + { + purchaseOrder: ctx.input.purchaseOrder, + supplier: ctx.input.supplier, + purchasingOrganization: ctx.input.purchasingOrganization, + companyCode: ctx.input.companyCode, + createdSince: ctx.input.createdSince + }, + 'purchase order' + ); + + let client = createClient(ctx); + let result = await client.queryEntitySet>({ + serviceName, + entitySet: 'A_PurchaseOrder', + pageToken: ctx.input.skipToken, + query: { + $top: topValue(ctx.input), + $filter: buildPurchaseOrderFilters(ctx.input) || undefined, + $orderby: ctx.input.orderBy, + $expand: ctx.input.expandItems ? 'to_PurchaseOrderItem' : undefined + } + }); + let purchaseOrders = result.items.map(mapPurchaseOrder); + + return { + output: { + purchaseOrders, + page: pageSummary(ctx.input, purchaseOrders.length, result.nextPageToken) + }, + message: `Retrieved **${purchaseOrders.length}** SAP S/4HANA purchase order(s).` + }; + }) + .build(); + +export let getPurchaseOrder = SlateTool.create(spec, { + name: 'Get Purchase Order', + key: 'get_purchase_order', + description: + 'Retrieve a SAP S/4HANA purchase order by id, including item lines where authorized.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + purchaseOrder: z.string().min(1).describe('SAP purchase order id.') + }) + ) + .output( + z.object({ + purchaseOrder: purchaseOrderSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let purchaseOrder = await client.getEntity>({ + serviceName, + entitySet: 'A_PurchaseOrder', + key: ctx.input.purchaseOrder, + query: { + $expand: 'to_PurchaseOrderItem' + } + }); + let mapped = mapPurchaseOrder(purchaseOrder); + + return { + output: { + purchaseOrder: mapped + }, + message: `Retrieved SAP S/4HANA purchase order **${mapped.purchaseOrder ?? ctx.input.purchaseOrder}**.` + }; + }) + .build(); diff --git a/integrations/sap-s4hana/src/tools/sales-orders.test.ts b/integrations/sap-s4hana/src/tools/sales-orders.test.ts new file mode 100644 index 0000000000..85def57c4f --- /dev/null +++ b/integrations/sap-s4hana/src/tools/sales-orders.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { mapSalesOrder } from './sales-orders'; + +describe('SAP S/4HANA sales orders tool helpers', () => { + it('keeps documented decimal amount fields when SAP returns numeric JSON values', () => { + expect( + mapSalesOrder({ + SalesOrder: '5000000001', + TotalNetAmount: 123.45, + to_Item: { + results: [ + { + SalesOrder: '5000000001', + SalesOrderItem: '10', + RequestedQuantity: 2.5, + NetAmount: 123.45 + } + ] + } + }) + ).toMatchObject({ + salesOrder: '5000000001', + totalNetAmount: '123.45', + items: [ + { + salesOrder: '5000000001', + salesOrderItem: '10', + requestedQuantity: '2.5', + netAmount: '123.45' + } + ] + }); + }); +}); diff --git a/integrations/sap-s4hana/src/tools/sales-orders.ts b/integrations/sap-s4hana/src/tools/sales-orders.ts new file mode 100644 index 0000000000..35448330a1 --- /dev/null +++ b/integrations/sap-s4hana/src/tools/sales-orders.ts @@ -0,0 +1,267 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { andFilters, odataDateTimeLiteral, odataStringLiteral } from '../lib/client'; +import { spec } from '../spec'; +import { + compactOutput, + createClient, + ensureFilteredQuery, + navigationArray, + numberValue, + pageInputSchema, + pageOutputSchema, + pageSummary, + rawRecordSchema, + stringValue, + topValue +} from './shared'; + +const serviceName = 'API_SALES_ORDER_SRV'; + +let salesOrderItemSchema = z.object({ + salesOrder: z.string().optional().describe('SAP sales order id.'), + salesOrderItem: z.string().optional().describe('SAP sales order item number.'), + material: z.string().optional().describe('Material/product id.'), + requestedQuantity: z.string().optional().describe('Requested quantity.'), + requestedQuantityUnit: z.string().optional().describe('Requested quantity unit.'), + netAmount: z.string().optional().describe('Item net amount.'), + transactionCurrency: z.string().optional().describe('Transaction currency.'), + record: rawRecordSchema +}); + +let salesOrderPartnerSchema = z.object({ + partnerFunction: z.string().optional().describe('SAP partner function.'), + customer: z.string().optional().describe('Customer/business partner id.'), + supplier: z.string().optional().describe('Supplier/business partner id.'), + record: rawRecordSchema +}); + +let salesOrderSchema = z.object({ + salesOrder: z.string().optional().describe('SAP sales order id.'), + salesOrderType: z.string().optional().describe('Sales order type.'), + soldToParty: z.string().optional().describe('Sold-to party id.'), + salesOrganization: z.string().optional().describe('Sales organization.'), + distributionChannel: z.string().optional().describe('Distribution channel.'), + division: z.string().optional().describe('Organization division.'), + overallSDProcessStatus: z.string().optional().describe('Overall SD process status.'), + overallTotalDeliveryStatus: z.string().optional().describe('Overall delivery status.'), + totalNetAmount: z.string().optional().describe('Total net amount.'), + transactionCurrency: z.string().optional().describe('Transaction currency.'), + purchaseOrderByCustomer: z + .string() + .optional() + .describe('Customer purchase order reference.'), + createdAt: z.string().optional().describe('Creation date/time.'), + lastChangedAt: z.string().optional().describe('Last changed timestamp.'), + items: z.array(salesOrderItemSchema).optional().describe('Expanded sales order items.'), + partners: z + .array(salesOrderPartnerSchema) + .optional() + .describe('Expanded sales order partners.'), + record: rawRecordSchema +}); + +let decimalStringValue = (record: Record, key: string) => { + let value = stringValue(record, key); + if (value !== undefined) return value; + + let numericValue = numberValue(record, key); + return numericValue === undefined ? undefined : String(numericValue); +}; + +let mapSalesOrderItem = (item: Record) => ({ + ...compactOutput({ + salesOrder: stringValue(item, 'SalesOrder'), + salesOrderItem: stringValue(item, 'SalesOrderItem'), + material: stringValue(item, 'Material'), + requestedQuantity: decimalStringValue(item, 'RequestedQuantity'), + requestedQuantityUnit: stringValue(item, 'RequestedQuantityUnit'), + netAmount: decimalStringValue(item, 'NetAmount'), + transactionCurrency: stringValue(item, 'TransactionCurrency') + }), + record: item +}); + +let mapSalesOrderPartner = (partner: Record) => ({ + ...compactOutput({ + partnerFunction: stringValue(partner, 'PartnerFunction'), + customer: stringValue(partner, 'Customer'), + supplier: stringValue(partner, 'Supplier') + }), + record: partner +}); + +export let mapSalesOrder = (order: Record) => { + let items = navigationArray(order, 'to_Item').map(mapSalesOrderItem); + let partners = navigationArray(order, 'to_Partner').map(mapSalesOrderPartner); + + return { + ...compactOutput({ + salesOrder: stringValue(order, 'SalesOrder'), + salesOrderType: stringValue(order, 'SalesOrderType'), + soldToParty: stringValue(order, 'SoldToParty'), + salesOrganization: stringValue(order, 'SalesOrganization'), + distributionChannel: stringValue(order, 'DistributionChannel'), + division: stringValue(order, 'OrganizationDivision'), + overallSDProcessStatus: stringValue(order, 'OverallSDProcessStatus'), + overallTotalDeliveryStatus: stringValue(order, 'OverallTotalDeliveryStatus'), + totalNetAmount: decimalStringValue(order, 'TotalNetAmount'), + transactionCurrency: stringValue(order, 'TransactionCurrency'), + purchaseOrderByCustomer: stringValue(order, 'PurchaseOrderByCustomer'), + createdAt: stringValue(order, 'CreationDate') ?? stringValue(order, 'CreationDateTime'), + lastChangedAt: stringValue(order, 'LastChangeDateTime'), + items: items.length > 0 ? items : undefined, + partners: partners.length > 0 ? partners : undefined + }), + record: order + }; +}; + +let buildSalesOrderFilters = (input: { + salesOrder?: string; + soldToParty?: string; + salesOrganization?: string; + distributionChannel?: string; + division?: string; + createdSince?: string; + updatedSince?: string; + status?: string; +}) => + andFilters([ + input.salesOrder ? `SalesOrder eq ${odataStringLiteral(input.salesOrder)}` : undefined, + input.soldToParty ? `SoldToParty eq ${odataStringLiteral(input.soldToParty)}` : undefined, + input.salesOrganization + ? `SalesOrganization eq ${odataStringLiteral(input.salesOrganization)}` + : undefined, + input.distributionChannel + ? `DistributionChannel eq ${odataStringLiteral(input.distributionChannel)}` + : undefined, + input.division + ? `OrganizationDivision eq ${odataStringLiteral(input.division)}` + : undefined, + input.status ? `OverallSDProcessStatus eq ${odataStringLiteral(input.status)}` : undefined, + input.createdSince + ? `CreationDate ge ${odataDateTimeLiteral(input.createdSince, 'datetime')}` + : undefined, + input.updatedSince + ? `LastChangeDateTime ge ${odataDateTimeLiteral(input.updatedSince)}` + : undefined + ]); + +export let listSalesOrders = SlateTool.create(spec, { + name: 'List Sales Orders', + key: 'list_sales_orders', + description: + 'List SAP S/4HANA sales order headers for order-to-cash discovery, status checks, customer lookup, and sales-area reporting.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + salesOrder: z.string().optional().describe('Exact SAP sales order id.'), + soldToParty: z.string().optional().describe('Sold-to party/customer id.'), + salesOrganization: z.string().optional().describe('Sales organization code.'), + distributionChannel: z.string().optional().describe('Distribution channel code.'), + division: z.string().optional().describe('Organization division code.'), + createdSince: z + .string() + .optional() + .describe('Return orders created on or after this date.'), + updatedSince: z + .string() + .optional() + .describe('Return orders changed on or after this date/datetime.'), + status: z.string().optional().describe('Overall SD process status code.'), + expandItems: z + .boolean() + .optional() + .describe('Expand order items and partners for each returned order.'), + ...pageInputSchema + }) + ) + .output( + z.object({ + salesOrders: z.array(salesOrderSchema).describe('Sales orders.'), + page: pageOutputSchema + }) + ) + .handleInvocation(async ctx => { + ensureFilteredQuery( + ctx.input, + { + salesOrder: ctx.input.salesOrder, + soldToParty: ctx.input.soldToParty, + salesOrganization: ctx.input.salesOrganization, + distributionChannel: ctx.input.distributionChannel, + division: ctx.input.division, + createdSince: ctx.input.createdSince, + updatedSince: ctx.input.updatedSince, + status: ctx.input.status + }, + 'sales order' + ); + + let client = createClient(ctx); + let result = await client.queryEntitySet>({ + serviceName, + entitySet: 'A_SalesOrder', + pageToken: ctx.input.skipToken, + query: { + $top: topValue(ctx.input), + $filter: buildSalesOrderFilters(ctx.input) || undefined, + $orderby: ctx.input.orderBy, + $expand: ctx.input.expandItems ? 'to_Item,to_Partner' : undefined + } + }); + let salesOrders = result.items.map(mapSalesOrder); + + return { + output: { + salesOrders, + page: pageSummary(ctx.input, salesOrders.length, result.nextPageToken) + }, + message: `Retrieved **${salesOrders.length}** SAP S/4HANA sales order(s).` + }; + }) + .build(); + +export let getSalesOrder = SlateTool.create(spec, { + name: 'Get Sales Order', + key: 'get_sales_order', + description: + 'Retrieve a SAP S/4HANA sales order by id, including item and partner context where authorized.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + salesOrder: z.string().min(1).describe('SAP sales order id.') + }) + ) + .output( + z.object({ + salesOrder: salesOrderSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let order = await client.getEntity>({ + serviceName, + entitySet: 'A_SalesOrder', + key: ctx.input.salesOrder, + query: { + $expand: 'to_Item,to_Partner' + } + }); + let mapped = mapSalesOrder(order); + + return { + output: { + salesOrder: mapped + }, + message: `Retrieved SAP S/4HANA sales order **${mapped.salesOrder ?? ctx.input.salesOrder}**.` + }; + }) + .build(); diff --git a/integrations/sap-s4hana/src/tools/shared.ts b/integrations/sap-s4hana/src/tools/shared.ts new file mode 100644 index 0000000000..13efabe957 --- /dev/null +++ b/integrations/sap-s4hana/src/tools/shared.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; +import { type SapAuthOutput, type SapConfig, SapS4HanaClient } from '../lib/client'; +import { sapValidationError } from '../lib/errors'; + +export let rawRecordSchema = z + .record(z.string(), z.unknown()) + .describe('Raw SAP OData record for fields not normalized by this tool.'); + +export let pageInputSchema = { + top: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + 'Maximum number of SAP records to return. Defaults to 25 and cannot exceed 100.' + ), + skipToken: z + .string() + .optional() + .describe('SAP next-page token or next link returned by a previous list call.'), + orderBy: z + .string() + .optional() + .describe('SAP OData $orderby expression, for example "LastChangeDateTime desc".'), + allowBroadQuery: z + .boolean() + .optional() + .describe('Set true to allow an unfiltered query against a large SAP resource.') +}; + +export let pageOutputSchema = z.object({ + top: z.number().describe('SAP $top value requested.'), + count: z.number().describe('Number of records returned in this page.'), + nextPageToken: z + .string() + .optional() + .describe('SAP server next link or token to pass as skipToken on the next call.') +}); + +export type PageInput = { + top?: number; + skipToken?: string; + orderBy?: string; + allowBroadQuery?: boolean; +}; + +export let createClient = (ctx: { auth: SapAuthOutput; config: SapConfig }) => + new SapS4HanaClient({ + auth: ctx.auth, + config: ctx.config + }); + +export let topValue = (input: PageInput) => input.top ?? 25; + +export let pageSummary = (input: PageInput, count: number, nextPageToken?: string) => ({ + top: topValue(input), + count, + nextPageToken +}); + +export let ensureFilteredQuery = ( + input: PageInput, + filters: Record, + resourceLabel: string +) => { + if (input.skipToken || input.allowBroadQuery) return; + + let hasFilter = Object.values(filters).some( + value => value !== undefined && value !== null && value !== '' + ); + + if (!hasFilter) { + throw sapValidationError( + `Provide at least one ${resourceLabel} filter or set allowBroadQuery=true.` + ); + } +}; + +export let compactOutput = >(input: T) => + Object.fromEntries( + Object.entries(input).filter(([, child]) => child !== undefined) + ) as Partial; + +export let stringValue = (record: Record, key: string) => { + let value = record[key]; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +}; + +export let numberValue = (record: Record, key: string) => { + let value = record[key]; + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim()) { + let parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +export let booleanValue = (record: Record, key: string) => { + let value = record[key]; + return typeof value === 'boolean' ? value : undefined; +}; + +export let recordValue = (record: Record, key: string) => { + let value = record[key]; + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +}; + +export let navigationArray = (record: Record, key: string) => { + let value = recordValue(record, key); + let results = value?.results; + return Array.isArray(results) ? (results as Record[]) : []; +}; + +export let firstNavigationRecord = (record: Record, key: string) => { + let value = recordValue(record, key); + if (!value) return undefined; + let results = value.results; + if (Array.isArray(results)) return results[0] as Record | undefined; + return value; +}; + +export let uniqueStrings = (values: Array) => [ + ...new Set(values.filter((value): value is string => Boolean(value))) +]; diff --git a/integrations/sap-s4hana/src/tools/supplier-invoices.test.ts b/integrations/sap-s4hana/src/tools/supplier-invoices.test.ts new file mode 100644 index 0000000000..b69ab88dbf --- /dev/null +++ b/integrations/sap-s4hana/src/tools/supplier-invoices.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSupplierInvoiceFilters, + mapSupplierInvoice, + supplierInvoiceItemNavigation +} from './supplier-invoices'; + +describe('SAP S/4HANA supplier invoice tool helpers', () => { + it('uses the documented purchase-order reference item navigation', () => { + expect(supplierInvoiceItemNavigation).toBe('to_SuplrInvcItemPurOrdRef'); + }); + + it('maps documented purchase-order reference item expansions', () => { + let invoice = mapSupplierInvoice({ + SupplierInvoice: '5105600041', + FiscalYear: '2026', + InvoicingParty: '10300001', + to_SuplrInvcItemPurOrdRef: { + results: [ + { + SupplierInvoice: '5105600041', + FiscalYear: '2026', + SupplierInvoiceItem: '000001', + PurchaseOrder: '4500000010', + PurchaseOrderItem: '00010', + Plant: '1010', + ProductType: '1', + SupplierInvoiceItemAmount: '125.500', + DocumentCurrency: 'USD', + AmountInDocumentCurrency: '999.000', + Material: 'not-a-documented-field-on-this-entity' + } + ] + } + }); + + expect(invoice.items).toEqual([ + expect.objectContaining({ + supplierInvoice: '5105600041', + fiscalYear: '2026', + supplierInvoiceItem: '000001', + purchaseOrder: '4500000010', + purchaseOrderItem: '00010', + plant: '1010', + productType: '1', + amountInDocumentCurrency: '125.500', + documentCurrency: 'USD' + }) + ]); + expect(invoice.items?.[0]).not.toHaveProperty('material'); + }); + + it('builds documented supplier invoice filters', () => { + expect( + buildSupplierInvoiceFilters({ + supplierInvoice: '5105600041', + fiscalYear: '2026', + supplier: '10300001', + companyCode: '1010', + postingDateFrom: '2026-01-02', + postingDateTo: '2026-01-31' + }) + ).toBe( + "SupplierInvoice eq '5105600041' and FiscalYear eq '2026' and InvoicingParty eq '10300001' and CompanyCode eq '1010' and PostingDate ge datetime'2026-01-02T00:00:00' and PostingDate le datetime'2026-01-31T00:00:00'" + ); + }); +}); diff --git a/integrations/sap-s4hana/src/tools/supplier-invoices.ts b/integrations/sap-s4hana/src/tools/supplier-invoices.ts new file mode 100644 index 0000000000..889867eb1b --- /dev/null +++ b/integrations/sap-s4hana/src/tools/supplier-invoices.ts @@ -0,0 +1,227 @@ +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_SUPPLIERINVOICE_PROCESS_SRV'; +export const supplierInvoiceItemNavigation = 'to_SuplrInvcItemPurOrdRef'; + +let supplierInvoiceItemSchema = z.object({ + supplierInvoice: z.string().optional().describe('Supplier invoice id.'), + fiscalYear: z.string().optional().describe('Fiscal year.'), + supplierInvoiceItem: z.string().optional().describe('Supplier invoice item number.'), + purchaseOrder: z.string().optional().describe('Related purchase order id.'), + purchaseOrderItem: z.string().optional().describe('Related purchase order item.'), + plant: z.string().optional().describe('Plant for the supplier invoice item.'), + productType: z.string().optional().describe('SAP product type group for the item.'), + amountInDocumentCurrency: z.string().optional().describe('Line amount.'), + documentCurrency: z.string().optional().describe('Document currency.'), + record: rawRecordSchema +}); + +export let supplierInvoiceSchema = z.object({ + supplierInvoice: z.string().optional().describe('SAP supplier invoice id.'), + fiscalYear: z.string().optional().describe('Fiscal year.'), + companyCode: z.string().optional().describe('Company code.'), + supplier: z.string().optional().describe('Supplier/invoicing party id.'), + supplierInvoiceIDByInvcgParty: z + .string() + .optional() + .describe('Supplier invoice reference from invoicing party.'), + documentDate: z.string().optional().describe('Document date.'), + postingDate: z.string().optional().describe('Posting date.'), + invoiceGrossAmount: z.string().optional().describe('Gross invoice amount.'), + documentCurrency: z.string().optional().describe('Document currency.'), + accountingDocument: z.string().optional().describe('Accounting document id.'), + supplierInvoiceStatus: z.string().optional().describe('Supplier invoice status.'), + paymentBlockingReason: z.string().optional().describe('Payment block reason.'), + items: z.array(supplierInvoiceItemSchema).optional().describe('Expanded invoice items.'), + record: rawRecordSchema +}); + +export let mapSupplierInvoiceItem = (item: Record) => ({ + ...compactOutput({ + supplierInvoice: stringValue(item, 'SupplierInvoice'), + fiscalYear: stringValue(item, 'FiscalYear'), + supplierInvoiceItem: stringValue(item, 'SupplierInvoiceItem'), + purchaseOrder: stringValue(item, 'PurchaseOrder'), + purchaseOrderItem: stringValue(item, 'PurchaseOrderItem'), + plant: stringValue(item, 'Plant'), + productType: stringValue(item, 'ProductType'), + amountInDocumentCurrency: stringValue(item, 'SupplierInvoiceItemAmount'), + documentCurrency: stringValue(item, 'DocumentCurrency') + }), + record: item +}); + +export let mapSupplierInvoice = (invoice: Record) => { + let items = navigationArray(invoice, supplierInvoiceItemNavigation).map( + mapSupplierInvoiceItem + ); + + return { + ...compactOutput({ + supplierInvoice: stringValue(invoice, 'SupplierInvoice'), + fiscalYear: stringValue(invoice, 'FiscalYear'), + companyCode: stringValue(invoice, 'CompanyCode'), + supplier: + stringValue(invoice, 'InvoicingParty') ?? + stringValue(invoice, 'Supplier') ?? + stringValue(invoice, 'Payee'), + supplierInvoiceIDByInvcgParty: stringValue(invoice, 'SupplierInvoiceIDByInvcgParty'), + documentDate: stringValue(invoice, 'DocumentDate'), + postingDate: stringValue(invoice, 'PostingDate'), + invoiceGrossAmount: stringValue(invoice, 'InvoiceGrossAmount'), + documentCurrency: stringValue(invoice, 'DocumentCurrency'), + accountingDocument: stringValue(invoice, 'AccountingDocument'), + supplierInvoiceStatus: stringValue(invoice, 'SupplierInvoiceStatus'), + paymentBlockingReason: stringValue(invoice, 'PaymentBlockingReason'), + items: items.length > 0 ? items : undefined + }), + record: invoice + }; +}; + +export let buildSupplierInvoiceFilters = (input: { + supplierInvoice?: string; + fiscalYear?: string; + supplier?: string; + companyCode?: string; + postingDateFrom?: string; + postingDateTo?: string; +}) => + andFilters([ + input.supplierInvoice + ? `SupplierInvoice eq ${odataStringLiteral(input.supplierInvoice)}` + : undefined, + input.fiscalYear ? `FiscalYear eq ${odataStringLiteral(input.fiscalYear)}` : undefined, + input.supplier ? `InvoicingParty eq ${odataStringLiteral(input.supplier)}` : undefined, + input.companyCode ? `CompanyCode eq ${odataStringLiteral(input.companyCode)}` : undefined, + input.postingDateFrom + ? `PostingDate ge ${odataDateTimeLiteral(input.postingDateFrom, 'datetime')}` + : undefined, + input.postingDateTo + ? `PostingDate le ${odataDateTimeLiteral(input.postingDateTo, 'datetime')}` + : undefined + ]); + +export let listSupplierInvoices = SlateTool.create(spec, { + name: 'List Supplier Invoices', + key: 'list_supplier_invoices', + description: + 'List SAP S/4HANA supplier invoices for accounts payable visibility, purchasing reconciliation, and payment/blocking status checks.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + supplierInvoice: z.string().optional().describe('Exact SAP supplier invoice id.'), + fiscalYear: z.string().optional().describe('Fiscal year.'), + supplier: z.string().optional().describe('Supplier/invoicing party id.'), + companyCode: z.string().optional().describe('Company code.'), + postingDateFrom: z.string().optional().describe('Posting date from.'), + postingDateTo: z.string().optional().describe('Posting date to.'), + expandItems: z.boolean().optional().describe('Expand supplier invoice items.'), + ...pageInputSchema + }) + ) + .output( + z.object({ + supplierInvoices: z.array(supplierInvoiceSchema).describe('Supplier invoices.'), + page: pageOutputSchema + }) + ) + .handleInvocation(async ctx => { + ensureFilteredQuery( + ctx.input, + { + supplierInvoice: ctx.input.supplierInvoice, + fiscalYear: ctx.input.fiscalYear, + supplier: ctx.input.supplier, + companyCode: ctx.input.companyCode, + postingDateFrom: ctx.input.postingDateFrom, + postingDateTo: ctx.input.postingDateTo + }, + 'supplier invoice' + ); + + let client = createClient(ctx); + let result = await client.queryEntitySet>({ + serviceName, + entitySet: 'A_SupplierInvoice', + pageToken: ctx.input.skipToken, + query: { + $top: topValue(ctx.input), + $filter: buildSupplierInvoiceFilters(ctx.input) || undefined, + $orderby: ctx.input.orderBy, + $expand: ctx.input.expandItems ? supplierInvoiceItemNavigation : undefined + } + }); + let supplierInvoices = result.items.map(mapSupplierInvoice); + + return { + output: { + supplierInvoices, + page: pageSummary(ctx.input, supplierInvoices.length, result.nextPageToken) + }, + message: `Retrieved **${supplierInvoices.length}** SAP S/4HANA supplier invoice(s).` + }; + }) + .build(); + +export let getSupplierInvoice = SlateTool.create(spec, { + name: 'Get Supplier Invoice', + key: 'get_supplier_invoice', + description: + 'Retrieve a SAP S/4HANA supplier invoice by invoice id and fiscal year, including item lines where authorized.', + tags: { + readOnly: true + } +}) + .input( + z.object({ + supplierInvoice: z.string().min(1).describe('SAP supplier invoice id.'), + fiscalYear: z.string().min(1).describe('Fiscal year for the supplier invoice key.') + }) + ) + .output( + z.object({ + supplierInvoice: supplierInvoiceSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let invoice = await client.getEntity>({ + serviceName, + entitySet: 'A_SupplierInvoice', + key: { + SupplierInvoice: ctx.input.supplierInvoice, + FiscalYear: ctx.input.fiscalYear + }, + query: { + $expand: supplierInvoiceItemNavigation + } + }); + let mapped = mapSupplierInvoice(invoice); + + return { + output: { + supplierInvoice: mapped + }, + message: `Retrieved SAP S/4HANA supplier invoice **${mapped.supplierInvoice ?? ctx.input.supplierInvoice}**.` + }; + }) + .build(); diff --git a/integrations/sap-s4hana/tsconfig.json b/integrations/sap-s4hana/tsconfig.json new file mode 100644 index 0000000000..2abe727831 --- /dev/null +++ b/integrations/sap-s4hana/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/sap-s4hana/vitest.config.ts b/integrations/sap-s4hana/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/sap-s4hana/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/sparebank-1-regnskap/README.md b/integrations/sparebank-1-regnskap/README.md new file mode 100644 index 0000000000..2f587f71a7 --- /dev/null +++ b/integrations/sparebank-1-regnskap/README.md @@ -0,0 +1,67 @@ +# SpareBank 1 Regnskap + +Query SpareBank 1 Regnskap accounting data through the Unimicro Platform API. This integration treats SpareBank 1 Regnskap as a SpareBank-branded Unimicro environment and starts with read-only ERP workflows. + +## Authentication + +SpareBank 1 Regnskap uses Unimicro Platform OAuth 2.0 / OpenID Connect. Register a web application through Unimicro partner onboarding, enable authorization-code login with refresh tokens, and add the Slates OAuth callback URL. + +Choose the SpareBank 1 Regnskap environment during authentication. The integration discovers each environment's related endpoints from `/api/endpoints` and stores the AppFramework, Identity, and Files URLs for future calls. + +## Configuration + +Set `companyKey` when most company-scoped tool calls should target the same company. You can also provide `companyKey` per tool call. Use **List Companies** first when an authenticated user has access to multiple companies. + +## Tools + +### List Companies + +List companies available to the authenticated SpareBank 1 Regnskap user. + +### List Customers / Get Customer + +List customers with Unimicro query filters or fetch a customer by ID. Requires `companyKey` in config or tool input. + +### List Suppliers / Get Supplier + +List suppliers with Unimicro query filters or fetch a supplier by ID. Requires `companyKey` in config or tool input. + +### List Customer Invoices / Get Customer Invoice + +List customer invoices and fetch invoice details, including expanded items or customer data when requested. Requires `companyKey` in config or tool input. + +### List Supplier Invoices / Get Supplier Invoice + +List incoming supplier invoices and fetch one supplier invoice's details. Requires `companyKey` in config or tool input. + +### List Products + +List product register records with optional filters. Requires `companyKey` in config or tool input. + +### List Accounts + +List chart-of-account records with account filters. Requires `companyKey` in config or tool input. + +### List Projects + +List project records with optional filters. Requires `companyKey` in config or tool input. + +### Get Trial Balance / Get Profit and Loss / Get Balance Sheet + +Retrieve read-only accounting reports from the Unimicro accounts report actions. Requires `companyKey` in config or tool input. + +### Download File + +Download a file from the environment's file service by storage reference and return it as a Slate attachment. + +## Notes + +Public research did not show a separate SpareBank 1 accounting API beyond the Unimicro Platform. Payment execution, payment batch sending, invoice posting, and supplier invoice payment actions are intentionally excluded until provider/customer approval and live test coverage confirm the exact operational semantics. + +## License + +This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE). + +
+ Built with ❤️ by Metorial +
diff --git a/integrations/sparebank-1-regnskap/docs/SPEC.md b/integrations/sparebank-1-regnskap/docs/SPEC.md new file mode 100644 index 0000000000..0b2a794c54 --- /dev/null +++ b/integrations/sparebank-1-regnskap/docs/SPEC.md @@ -0,0 +1,50 @@ +# SpareBank 1 Regnskap Integration Spec + +## Source + +SpareBank 1 Regnskap is implemented as a branded Unimicro Platform environment. The implementation uses the public Unimicro Platform OAuth and REST API documentation plus environment discovery through `/api/endpoints`. + +## Auth + +- OAuth 2.0 authorization code with refresh-token support. +- Environment is selected during OAuth setup. +- The integration discovers and stores AppFramework, Identity, and Files endpoints. +- Token refresh preserves an existing refresh token when the provider omits a rotated token. + +## Config + +- `companyKey` is optional global configuration for company-scoped tools. +- Tool-level `companyKey` overrides config. +- Company-scoped business and report tools require `companyKey` through either config or tool input. +- `download_file` also requires `companyKey` because the file service requires the company key in the download request. + +## Tool Surface + +Initial release is read-only: + +- `list_companies` +- `list_customers` +- `get_customer` +- `list_suppliers` +- `get_supplier` +- `list_customer_invoices` +- `get_customer_invoice` +- `list_supplier_invoices` +- `get_supplier_invoice` +- `list_products` +- `list_accounts` +- `list_projects` +- `get_trial_balance` +- `get_profit_and_loss` +- `get_balance_sheet` +- `download_file` + +The tools support Unimicro query options `filter`, `select`, `expand`, `top`, and `skip` where they apply. Outputs expose stable IDs and summary fields while preserving the raw provider record for provider-specific detail. + +## Exclusions + +Payment execution, payment batches, invoice posting, and supplier invoice payment workflows are excluded from the first release because the research plan identifies those as higher-risk banking-connected actions. Customer/supplier creation and invoice draft creation are also deferred until a SpareBank 1 Regnskap test company is available to validate exact write semantics. + +## Live E2E + +Private live E2E coverage is wired in `tests/integrations/sparebank-1-regnskap/tools.e2e.ts`. It requires a SpareBank 1 Regnskap profile, `companyKey`, and fixture IDs for representative customer/supplier/invoice/product/account/project/file records. diff --git a/integrations/sparebank-1-regnskap/package.json b/integrations/sparebank-1-regnskap/package.json new file mode 100644 index 0000000000..9c636df94d --- /dev/null +++ b/integrations/sparebank-1-regnskap/package.json @@ -0,0 +1,22 @@ +{ + "name": "@slates-integrations/sparebank-1-regnskap", + "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/sparebank-1-regnskap/slate.json b/integrations/sparebank-1-regnskap/slate.json new file mode 100644 index 0000000000..4ccd0952ee --- /dev/null +++ b/integrations/sparebank-1-regnskap/slate.json @@ -0,0 +1,14 @@ +{ + "name": "@sparebank1/regnskap", + "description": "Query SpareBank 1 Regnskap accounting data through the Unimicro Platform API. Discover companies, list and inspect customers, suppliers, invoices, supplier invoices, products, accounts, projects, accounting reports, and download files as attachments.", + "categories": ["accounting", "erp", "finance"], + "skills": [ + "discover SpareBank 1 Regnskap companies", + "search customer and supplier master data", + "review customer and supplier invoices", + "inspect chart of accounts and project records", + "retrieve trial balance, profit and loss, and balance sheet reports", + "download accounting files as attachments" + ], + "logoUrl": "https://www.sparebank1.no/content/dam/SB1/ikoner/GUI-ikoner/logo-sparebank1.svg" +} diff --git a/integrations/sparebank-1-regnskap/src/auth.ts b/integrations/sparebank-1-regnskap/src/auth.ts new file mode 100644 index 0000000000..17c1c884f3 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/auth.ts @@ -0,0 +1,340 @@ +import { + createAxios, + normalizeOAuthTokenResponse, + requestAxiosData, + SlateAuth, + type SlateAuthDocsReference +} from 'slates'; +import { z } from 'zod'; +import { + discoverEnvironmentEndpoints, + environmentFromKey, + type SpareBankEnvironmentKey, + spareBankEnvironmentKeySchema +} from './lib/environments'; +import { spareBankRegnskapApiError, spareBankRegnskapValidationError } from './lib/errors'; + +let identityScopes = ['openid', 'profile', 'email'] as const; +let requiredApiScopes = ['offline_access', 'AppFramework', 'READ_ONLY'] as const; + +let oauthScopes = [ + { + title: 'Offline Access', + description: 'Allows Slates to refresh access tokens.', + scope: 'offline_access' + }, + { + title: 'AppFramework', + description: 'Provides access to the selected Unimicro AppFramework environment.', + scope: 'AppFramework' + }, + { + title: 'Read Only', + description: 'Read-only access to Unimicro Platform resources.', + scope: 'READ_ONLY' + }, + { + title: 'Accounting Reporting', + description: 'Read accounting reports and ledger-related data.', + scope: 'Accounting.Reporting' + }, + { + title: 'Sales Reporting', + description: 'Read sales and invoicing reporting data.', + scope: 'Sales.Reporting' + }, + { + title: 'Sales Invoice', + description: 'Read customer invoice data.', + scope: 'Sales.Invoice' + }, + { + title: 'Approval Accounting', + description: 'Read accounting approval and supplier invoice context.', + scope: 'Approval.Accounting' + } +]; + +let uniqueScopes = (scopes: readonly string[]) => [...new Set(scopes.filter(Boolean))]; + +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 tokenResponseSchema = z.object({ + access_token: z.unknown().optional(), + refresh_token: z.unknown().optional(), + expires_in: z.unknown().optional(), + token_type: z.unknown().optional(), + scope: z.unknown().optional() +}); + +export let authOutputSchema = z.object({ + token: z.string(), + refreshToken: z.string().optional(), + expiresAt: z.string().optional(), + tokenType: z.string().optional(), + scopes: z.array(z.string()).optional(), + environment: spareBankEnvironmentKeySchema, + environmentName: z.string(), + baseUrl: z.string(), + appFrameworkUrl: z.string(), + identityUrl: z.string(), + filesUrl: z.string() +}); + +export type SpareBankRegnskapAuthOutput = z.infer; + +let oauthInputSchema = z.object({ + environment: spareBankEnvironmentKeySchema + .optional() + .describe('SpareBank 1 Regnskap environment. Defaults to sb1.') +}); + +let selectedEnvironment = (environment?: SpareBankEnvironmentKey) => environment ?? 'sb1'; + +let normalizeToken = ( + data: unknown, + options: { + operation: string; + requestedScopes: string[]; + previousRefreshToken?: string; + environment: SpareBankEnvironmentKey; + endpoints: { + appFrameworkUrl: string; + identityUrl: string; + filesUrl: string; + }; + } +): SpareBankRegnskapAuthOutput => { + let parsedResult = tokenResponseSchema.safeParse(data); + if (!parsedResult.success) { + throw spareBankRegnskapValidationError( + `SpareBank 1 Regnskap ${options.operation} response was not a valid OAuth token object.` + ); + } + + let parsed = parsedResult.data; + let normalized = normalizeOAuthTokenResponse(parsed, { + providerLabel: 'SpareBank 1 Regnskap', + operation: options.operation, + previousRefreshToken: options.previousRefreshToken, + refreshTokenFallbackMode: 'falsy' + }); + let environment = environmentFromKey(options.environment); + + return { + token: normalized.token, + refreshToken: normalized.refreshToken, + expiresAt: normalized.expiresAt, + tokenType: typeof parsed.token_type === 'string' ? parsed.token_type : 'Bearer', + scopes: parseGrantedScopes(parsed.scope, options.requestedScopes), + environment: options.environment, + environmentName: environment.name, + baseUrl: environment.baseUrl, + appFrameworkUrl: options.endpoints.appFrameworkUrl, + identityUrl: options.endpoints.identityUrl, + filesUrl: options.endpoints.filesUrl + }; +}; + +let requestToken = async (identityUrl: string, operation: string, params: URLSearchParams) => { + let http = createAxios({ + baseURL: identityUrl, + headers: { + Accept: 'application/json' + } + }); + + return await requestAxiosData( + operation, + () => + http.post('/connect/token', params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }), + spareBankRegnskapApiError + ); +}; + +type SpareBankUserInfo = { + sub?: unknown; + name?: unknown; + email?: unknown; + picture?: unknown; + preferred_username?: unknown; +}; + +export let auth = SlateAuth.create() + .output(authOutputSchema) + .addOauth({ + type: 'auth.oauth', + name: 'SpareBank 1 Regnskap OAuth 2.0', + key: 'oauth', + docs: [ + { + type: 'docs.auth.oauth', + name: 'Unimicro authorization-code authentication', + url: 'https://developer.unimicro.no/guide/authentication/auth-code' + }, + { + type: 'docs.auth.oauth_scopes', + name: 'SpareBank 1 Regnskap OIDC scopes', + url: 'https://login.regnskap.sparebank1.no/.well-known/openid-configuration' + } + ] satisfies SlateAuthDocsReference[], + scopes: oauthScopes, + inputSchema: oauthInputSchema, + + getAuthorizationUrl: async ctx => { + let environment = selectedEnvironment(ctx.input.environment); + let endpoints = await discoverEnvironmentEndpoints(environment); + let requestedScopes = uniqueScopes([ + ...identityScopes, + ...requiredApiScopes, + ...ctx.scopes + ]); + let params = new URLSearchParams({ + client_id: ctx.clientId, + redirect_uri: ctx.redirectUri, + response_type: 'code', + response_mode: 'query', + state: ctx.state, + scope: requestedScopes.join(' ') + }); + + let maybePkce = ctx as typeof ctx & { + codeChallenge?: string; + codeChallengeMethod?: string; + }; + + if (maybePkce.codeChallenge) { + params.set('code_challenge', maybePkce.codeChallenge); + params.set('code_challenge_method', maybePkce.codeChallengeMethod ?? 'S256'); + } + + return { + url: `${endpoints.identityUrl}connect/authorize?${params.toString()}`, + input: { + environment + } + }; + }, + + handleCallback: async ctx => { + let environment = selectedEnvironment(ctx.input.environment); + let endpoints = await discoverEnvironmentEndpoints(environment); + let requestedScopes = uniqueScopes([ + ...identityScopes, + ...requiredApiScopes, + ...ctx.scopes + ]); + let params = new URLSearchParams({ + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }); + + let maybePkce = ctx as typeof ctx & { codeVerifier?: string }; + if (maybePkce.codeVerifier) { + params.set('code_verifier', maybePkce.codeVerifier); + } + + let data = await requestToken(endpoints.identityUrl, 'OAuth token exchange', params); + + return { + output: normalizeToken(data, { + operation: 'token exchange', + requestedScopes, + environment, + endpoints + }) + }; + }, + + handleTokenRefresh: async (ctx: any) => { + if (!ctx.output.refreshToken) { + throw spareBankRegnskapValidationError( + 'No SpareBank 1 Regnskap refresh token is available.' + ); + } + + let params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }); + + let data = await requestToken(ctx.output.identityUrl, 'OAuth token refresh', params); + + return { + output: normalizeToken(data, { + operation: 'token refresh', + requestedScopes: ctx.output.scopes ?? [], + previousRefreshToken: ctx.output.refreshToken, + environment: ctx.output.environment, + endpoints: { + appFrameworkUrl: ctx.output.appFrameworkUrl, + identityUrl: ctx.output.identityUrl, + filesUrl: ctx.output.filesUrl + } + }) + }; + }, + + getProfile: async (ctx: any) => { + let http = createAxios({ + baseURL: ctx.output.identityUrl, + headers: { + Accept: 'application/json' + } + }); + + let profile = await requestAxiosData( + 'profile lookup', + () => + http.get('/connect/userinfo', { + headers: { + Authorization: `Bearer ${ctx.output.token}` + } + }), + spareBankRegnskapApiError + ); + + let id = + typeof profile.sub === 'string' + ? profile.sub + : typeof profile.preferred_username === 'string' + ? profile.preferred_username + : undefined; + + if (!id) { + throw spareBankRegnskapValidationError( + 'SpareBank 1 Regnskap profile response did not include a user id.' + ); + } + + return { + profile: { + id, + name: typeof profile.name === 'string' ? profile.name : undefined, + email: typeof profile.email === 'string' ? profile.email : undefined, + imageUrl: typeof profile.picture === 'string' ? profile.picture : undefined, + environment: ctx.output.environment, + environmentName: ctx.output.environmentName + } + }; + } + }); diff --git a/integrations/sparebank-1-regnskap/src/config.ts b/integrations/sparebank-1-regnskap/src/config.ts new file mode 100644 index 0000000000..b124316ede --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/config.ts @@ -0,0 +1,13 @@ +import { SlateConfig } from 'slates'; +import { z } from 'zod'; + +export let configSchema = z.object({ + companyKey: z + .string() + .optional() + .describe('Default Unimicro CompanyKey for company-scoped SpareBank 1 Regnskap API calls.') +}); + +export type SpareBankRegnskapConfig = z.infer; + +export let config = SlateConfig.create(configSchema); diff --git a/integrations/sparebank-1-regnskap/src/index.ts b/integrations/sparebank-1-regnskap/src/index.ts new file mode 100644 index 0000000000..7e614b96f1 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/index.ts @@ -0,0 +1,43 @@ +import { Slate } from 'slates'; +import { spec } from './spec'; +import { + downloadFile, + getBalanceSheet, + getCustomer, + getCustomerInvoice, + getProfitAndLoss, + getSupplier, + getSupplierInvoice, + getTrialBalance, + listAccounts, + listCompanies, + listCustomerInvoices, + listCustomers, + listProducts, + listProjects, + listSupplierInvoices, + listSuppliers +} from './tools'; + +export let provider = Slate.create({ + spec, + tools: [ + listCompanies, + listCustomers, + getCustomer, + listSuppliers, + getSupplier, + listCustomerInvoices, + getCustomerInvoice, + listSupplierInvoices, + getSupplierInvoice, + listProducts, + listAccounts, + listProjects, + getTrialBalance, + getProfitAndLoss, + getBalanceSheet, + downloadFile + ], + triggers: [] +}); diff --git a/integrations/sparebank-1-regnskap/src/lib/client.test.ts b/integrations/sparebank-1-regnskap/src/lib/client.test.ts new file mode 100644 index 0000000000..c38423b2ac --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/lib/client.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SpareBankRegnskapAuthOutput } from '../auth'; + +let axiosMocks = vi.hoisted(() => ({ + bizApi: { + get: vi.fn() + }, + appFrameworkApi: { + get: vi.fn() + }, + filesApi: { + get: vi.fn() + }, + createAuthenticatedAxios: vi.fn(), + createAxios: vi.fn() +})); + +vi.mock('slates', async importOriginal => { + let actual = await importOriginal(); + + return { + ...actual, + createAuthenticatedAxios: axiosMocks.createAuthenticatedAxios, + createAxios: axiosMocks.createAxios + }; +}); + +import { SpareBankRegnskapClient } from './client'; + +let auth: SpareBankRegnskapAuthOutput = { + token: 'token', + environment: 'sb1', + environmentName: 'SpareBank 1 Regnskap', + baseUrl: 'https://regnskap.sb1.no/', + appFrameworkUrl: 'https://regnskap.sb1.no/', + identityUrl: 'https://login.regnskap.sparebank1.no/', + filesUrl: 'https://files.regnskap.sb1.no/' +}; + +let createClient = () => new SpareBankRegnskapClient(auth); + +beforeEach(() => { + axiosMocks.bizApi.get.mockReset(); + axiosMocks.appFrameworkApi.get.mockReset(); + axiosMocks.filesApi.get.mockReset(); + axiosMocks.createAuthenticatedAxios.mockReset(); + axiosMocks.createAxios.mockReset(); + + axiosMocks.createAuthenticatedAxios + .mockReturnValueOnce(axiosMocks.bizApi) + .mockReturnValueOnce(axiosMocks.appFrameworkApi); + axiosMocks.createAxios.mockReturnValue(axiosMocks.filesApi); +}); + +describe('SpareBankRegnskapClient listCompanies', () => { + it('wraps the documented bare company object response as a one-item list', async () => { + let company = { + Name: 'Company Inc.', + Key: '015eb513-753a-4942-9f6a-8ba930e33dc6', + WebHookSubscriberId: null, + IsTest: false, + FileFlowEmail: null, + ID: 5, + Deleted: false, + CreatedAt: '2017-02-01T15:55:43.46Z', + UpdatedAt: null, + CreatedBy: null, + UpdatedBy: null + }; + axiosMocks.appFrameworkApi.get.mockResolvedValueOnce({ data: company }); + + let result = await createClient().listCompanies(); + + expect(result).toEqual([company]); + expect(axiosMocks.appFrameworkApi.get).toHaveBeenCalledWith('/api/init/companies'); + }); + + it('keeps array company responses unchanged', async () => { + let companies = [ + { + Name: 'Company Inc.', + Key: '015eb513-753a-4942-9f6a-8ba930e33dc6', + IsTest: false, + ID: 5 + } + ]; + axiosMocks.appFrameworkApi.get.mockResolvedValueOnce({ data: companies }); + + await expect(createClient().listCompanies()).resolves.toEqual(companies); + }); +}); diff --git a/integrations/sparebank-1-regnskap/src/lib/client.ts b/integrations/sparebank-1-regnskap/src/lib/client.ts new file mode 100644 index 0000000000..1761e6ffd5 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/lib/client.ts @@ -0,0 +1,252 @@ +import { + createAuthenticatedAxios, + createAxios, + getApiErrorStatus, + getResponseHeaderValue, + requestAxios, + requestAxiosData +} from 'slates'; +import type { SpareBankRegnskapAuthOutput } from '../auth'; +import { joinUrl } from './environments'; +import { spareBankRegnskapApiError, spareBankRegnskapValidationError } from './errors'; + +export type UnimicroQueryParams = { + filter?: string; + select?: string; + expand?: string; + top?: number; + skip?: number; + [key: string]: unknown; +}; + +export type SpareBankBinaryFile = { + contentBase64: string; + mimeType: string; + byteLength: number; + fileName?: string; +}; + +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 isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let decodeHeaderValue = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +let normalizeContentDispositionFileName = (value: string | undefined) => { + if (!value) return undefined; + + let utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(value); + if (utf8Match?.[1]) return decodeHeaderValue(utf8Match[1].replace(/^"|"$/g, '')); + + let match = /filename="?([^";]+)"?/i.exec(value); + return match?.[1] ? decodeHeaderValue(match[1]) : undefined; +}; + +let companyHeaders = (companyKey?: string) => + companyKey + ? { + CompanyKey: companyKey + } + : undefined; + +let retryDelay = (attempt: number) => + new Promise(resolve => setTimeout(resolve, attempt * 500)); + +let isRetryable = (error: unknown) => { + let status = getApiErrorStatus(error); + if (status === 408 || status === 429) return true; + if (typeof status === 'number' && status >= 500) return true; + if (isRecord(error) && typeof error.code === 'string') { + return ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(error.code); + } + return false; +}; + +let listFromResponse = (value: unknown, path: string) => { + if (Array.isArray(value)) return value; + if (isRecord(value) && Array.isArray(value.Items)) return value.Items; + if (isRecord(value) && Array.isArray(value.Value)) return value.Value; + if (value === null || value === undefined) return []; + + throw spareBankRegnskapValidationError( + `SpareBank 1 Regnskap ${path} response was not a list.` + ); +}; + +let companyListFromResponse = (value: unknown) => { + if ( + isRecord(value) && + ('Key' in value || 'CompanyKey' in value || 'ID' in value || 'Name' in value) + ) { + return [value]; + } + + return listFromResponse(value, 'companies'); +}; + +export class SpareBankRegnskapClient { + private bizHttp; + private appFrameworkHttp; + private filesHttp; + + constructor(private auth: SpareBankRegnskapAuthOutput) { + this.bizHttp = createAuthenticatedAxios({ + baseURL: joinUrl(auth.appFrameworkUrl, '/api/biz/'), + authHeader: { + value: `Bearer ${auth.token}` + }, + headers: { + Accept: 'application/json' + }, + paramsSerializer: { serialize: serializeParams }, + errorAdapter: error => spareBankRegnskapApiError(error) + }); + + this.appFrameworkHttp = createAuthenticatedAxios({ + baseURL: auth.appFrameworkUrl, + authHeader: { + value: `Bearer ${auth.token}` + }, + headers: { + Accept: 'application/json' + }, + paramsSerializer: { serialize: serializeParams }, + errorAdapter: error => spareBankRegnskapApiError(error) + }); + + this.filesHttp = createAxios({ + baseURL: auth.filesUrl, + paramsSerializer: { serialize: serializeParams } + }); + } + + private async withRetries(request: () => Promise) { + let attempt = 0; + + while (true) { + try { + return await request(); + } catch (error) { + attempt += 1; + if (attempt >= 3 || !isRetryable(error)) throw error; + await retryDelay(attempt); + } + } + } + + private requestData(operation: string, request: () => Promise) { + return requestAxiosData( + operation, + () => this.withRetries(request), + spareBankRegnskapApiError + ); + } + + private requestResponse(operation: string, request: () => Promise) { + return requestAxios( + operation, + () => this.withRetries(request), + spareBankRegnskapApiError + ); + } + + async listCompanies() { + let value = await this.requestData('list companies', () => + this.appFrameworkHttp.get('/api/init/companies') + ); + + return companyListFromResponse(value); + } + + async list(path: string, params: UnimicroQueryParams = {}, companyKey?: string) { + let value = await this.requestData(`list ${path}`, () => + this.bizHttp.get(path, { + params, + headers: companyHeaders(companyKey) + }) + ); + + return listFromResponse(value, path); + } + + async get(path: string, params: UnimicroQueryParams = {}, companyKey?: string) { + return await this.requestData(`get ${path}`, () => + this.bizHttp.get(path, { + params, + headers: companyHeaders(companyKey) + }) + ); + } + + async report(path: string, params: UnimicroQueryParams = {}, companyKey?: string) { + return await this.requestData(`get report ${path}`, () => + this.bizHttp.get(path, { + params, + headers: companyHeaders(companyKey) + }) + ); + } + + async downloadFile(input: { + storageReference: string; + companyKey: string; + fileName?: string; + mimeType?: string; + }): Promise { + let response = await this.requestResponse('download file', () => + this.filesHttp.get('/api/download', { + params: { + id: input.storageReference, + key: input.companyKey, + token: this.auth.token + }, + headers: { + Accept: '*/*', + Authorization: `Bearer ${this.auth.token}` + }, + responseType: 'arraybuffer' + }) + ); + + let buffer = Buffer.from(response.data); + return { + contentBase64: buffer.toString('base64'), + mimeType: + input.mimeType ?? + getResponseHeaderValue(response.headers, 'content-type') ?? + 'application/octet-stream', + byteLength: buffer.byteLength, + fileName: + input.fileName ?? + normalizeContentDispositionFileName( + getResponseHeaderValue(response.headers, 'content-disposition') + ) + }; + } +} diff --git a/integrations/sparebank-1-regnskap/src/lib/environments.ts b/integrations/sparebank-1-regnskap/src/lib/environments.ts new file mode 100644 index 0000000000..1339764c23 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/lib/environments.ts @@ -0,0 +1,119 @@ +import { createAxios, requestAxiosData } from 'slates'; +import { z } from 'zod'; +import { spareBankRegnskapApiError, spareBankRegnskapValidationError } from './errors'; + +export const SPAREBANK_SHARED_IDENTITY_URL = 'https://login.regnskap.sparebank1.no/'; + +export const SPAREBANK_ENVIRONMENTS = { + sb1sornorge: { + name: 'SpareBank 1 Regnskap Sør-Norge', + baseUrl: 'https://regnskap.sb1sornorge.no/' + }, + sb1ostlandet: { + name: 'SpareBank 1 Regnskap Østlandet', + baseUrl: 'https://regnskap.sb1ostlandet.no/' + }, + snn: { + name: 'SpareBank 1 Regnskap Nord-Norge', + baseUrl: 'https://regnskap.snn.no/' + }, + sb1: { + name: 'SpareBank 1 Regnskap Hallingdal Valdres', + baseUrl: 'https://regnskap.sb1.no/' + }, + bank: { + name: 'SpareBank 1 Regnskap Nordmøre', + baseUrl: 'https://regnskap.bank.no/' + }, + sparebank1oa: { + name: 'SpareBank 1 Regnskap Østfold Akershus', + baseUrl: 'https://regnskap.sparebank1oa.no/' + }, + rhbank: { + name: 'SpareBank 1 Regnskap Ringerike Hadeland', + baseUrl: 'https://regnskap.rhbank.no/' + }, + s1g: { + name: 'SpareBank 1 Regnskap Gudbrandsdal', + baseUrl: 'https://regnskap.s1g.no/' + }, + sb1ls: { + name: 'SpareBank 1 Regnskap Lom og Skjåk', + baseUrl: 'https://regnskap.sb1ls.no/' + }, + smn: { + name: 'SpareBank 1 SMN', + baseUrl: 'https://regnskap.smn.no/' + } +} as const; + +export type SpareBankEnvironmentKey = keyof typeof SPAREBANK_ENVIRONMENTS; + +export let spareBankEnvironmentKeySchema = z + .enum( + Object.keys(SPAREBANK_ENVIRONMENTS) as [ + SpareBankEnvironmentKey, + ...SpareBankEnvironmentKey[] + ] + ) + .describe('SpareBank 1 Regnskap environment key.'); + +export type SpareBankEndpoints = { + appFrameworkUrl: string; + identityUrl: string; + filesUrl: string; + raw: Record; +}; + +let normalizeBaseUrl = (value: string) => { + let trimmed = value.trim(); + if (!trimmed) { + throw spareBankRegnskapValidationError('Endpoint URL is empty.'); + } + + return trimmed.endsWith('/') ? trimmed : `${trimmed}/`; +}; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let endpointValue = (raw: Record, key: string, fallback?: string) => { + let value = raw[key]; + if (typeof value === 'string' && value.trim()) return normalizeBaseUrl(value); + if (fallback) return normalizeBaseUrl(fallback); + throw spareBankRegnskapValidationError( + `SpareBank 1 Regnskap endpoint discovery did not return ${key}.` + ); +}; + +export let environmentFromKey = (environment: SpareBankEnvironmentKey) => + SPAREBANK_ENVIRONMENTS[environment]; + +export let discoverEnvironmentEndpoints = async ( + environment: SpareBankEnvironmentKey +): Promise => { + let selected = environmentFromKey(environment); + let http = createAxios({ + baseURL: selected.baseUrl, + headers: { + Accept: 'application/json' + } + }); + + let discovered = await requestAxiosData( + 'discover SpareBank 1 Regnskap environment endpoints', + () => http.get('/api/endpoints'), + spareBankRegnskapApiError + ); + let raw = isRecord(discovered) ? discovered : {}; + + return { + appFrameworkUrl: endpointValue(raw, 'AppFramework', selected.baseUrl), + identityUrl: endpointValue(raw, 'Identity', SPAREBANK_SHARED_IDENTITY_URL), + filesUrl: endpointValue(raw, 'Files'), + raw + }; +}; + +export let joinUrl = (baseUrl: string, path: string) => + new URL(path.replace(/^\/+/, ''), normalizeBaseUrl(baseUrl)).toString(); diff --git a/integrations/sparebank-1-regnskap/src/lib/errors.ts b/integrations/sparebank-1-regnskap/src/lib/errors.ts new file mode 100644 index 0000000000..4a6881ab1a --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/lib/errors.ts @@ -0,0 +1,46 @@ +import { + buildApiServiceError, + collectApiErrorDetails, + createApiServiceError, + getApiErrorResponse, + isApiErrorRecord +} from 'slates'; + +export let spareBankRegnskapValidationError = (message: string) => + createApiServiceError(message, { reason: 'sparebank_1_regnskap_validation_error' }); + +let collectSpareBankDetails = (value: unknown, details: string[]) => { + collectApiErrorDetails(value, details, { + detailKeys: [ + 'message', + 'Message', + 'detail', + 'Detail', + 'error', + 'error_description', + 'code' + ], + nestedKeys: ['errors', 'Errors', 'validationMessages', 'ValidationMessages'], + includeNumbers: true + }); +}; + +export let spareBankRegnskapApiError = (error: unknown, operation = 'request') => + buildApiServiceError(error, { + providerLabel: 'SpareBank 1 Regnskap', + reason: 'sparebank_1_regnskap_api_error', + operation, + extractMessage: currentError => { + let response = getApiErrorResponse(currentError); + let details: string[] = []; + collectSpareBankDetails(response?.data, details); + collectSpareBankDetails(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.Code; + return code === undefined ? undefined : String(code); + } + }); diff --git a/integrations/sparebank-1-regnskap/src/spec.ts b/integrations/sparebank-1-regnskap/src/spec.ts new file mode 100644 index 0000000000..3640f5b807 --- /dev/null +++ b/integrations/sparebank-1-regnskap/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: 'sparebank-1-regnskap', + name: 'SpareBank 1 Regnskap', + description: + 'Query SpareBank 1 Regnskap accounting data through the Unimicro Platform API, including companies, customers, suppliers, invoices, products, accounts, projects, reports, and file downloads.', + metadata: {}, + config, + auth +}); diff --git a/integrations/sparebank-1-regnskap/src/tools.accounting.test.ts b/integrations/sparebank-1-regnskap/src/tools.accounting.test.ts new file mode 100644 index 0000000000..07b2c27589 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools.accounting.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let clientMock = vi.hoisted(() => ({ + get: vi.fn(), + report: vi.fn() +})); + +vi.mock('./lib/client', () => ({ + SpareBankRegnskapClient: vi.fn(() => clientMock) +})); + +import { getBalanceSheet, getProfitAndLoss, getSupplierInvoice } from './tools/accounting'; + +let invokeGetSupplierInvoice = (input: Record) => + getSupplierInvoice.handleInvocation({ + auth: { + token: 'token', + environment: 'sb1', + environmentName: 'SpareBank 1 Regnskap', + baseUrl: 'https://regnskap.sb1.no/', + appFrameworkUrl: 'https://regnskap.sb1.no/', + identityUrl: 'https://login.regnskap.sparebank1.no/', + filesUrl: 'https://files.regnskap.sb1.no/' + }, + config: { companyKey: 'company-from-config' }, + input + } as any); + +let invokeGetProfitAndLoss = (input: Record) => + getProfitAndLoss.handleInvocation({ + auth: { + token: 'token', + environment: 'sb1', + environmentName: 'SpareBank 1 Regnskap', + baseUrl: 'https://regnskap.sb1.no/', + appFrameworkUrl: 'https://regnskap.sb1.no/', + identityUrl: 'https://login.regnskap.sparebank1.no/', + filesUrl: 'https://files.regnskap.sb1.no/' + }, + config: { companyKey: 'company-from-config' }, + input + } as any); + +let invokeGetBalanceSheet = (input: Record) => + getBalanceSheet.handleInvocation({ + auth: { + token: 'token', + environment: 'sb1', + environmentName: 'SpareBank 1 Regnskap', + baseUrl: 'https://regnskap.sb1.no/', + appFrameworkUrl: 'https://regnskap.sb1.no/', + identityUrl: 'https://login.regnskap.sparebank1.no/', + filesUrl: 'https://files.regnskap.sb1.no/' + }, + config: { companyKey: 'company-from-config' }, + input + } as any); + +beforeEach(() => { + clientMock.get.mockReset(); + clientMock.report.mockReset(); +}); + +describe('SpareBank 1 Regnskap get_supplier_invoice', () => { + it('queries the documented supplier invoice endpoint and maps documented AP state fields', async () => { + clientMock.get.mockResolvedValueOnce({ + ID: 123, + InvoiceNumber: 'SI-1001', + SupplierID: 45, + SupplierOrgNumber: '999888777', + CostSupplier: { + Info: { + Name: 'Vendor AS' + } + }, + InvoiceDate: '2026-01-02', + FinancialDate: '2026-01-03', + PaymentDueDate: '2026-02-02', + StatusCode: 30103, + PaymentStatus: 2, + PrintStatus: 1, + TaxInclusiveAmount: 1250, + TaxExclusiveAmount: 1000, + RestAmount: 250, + IsSentToPayment: true, + PreventPayment: true, + Credited: true, + CreditedAmount: 100, + CreditedAmountCurrency: 100, + PayableRoundingAmount: 0.5, + PayableRoundingCurrencyAmount: 0.5, + InvoiceOriginType: 1, + JournalEntryID: 987, + Deleted: false + }); + + let result = await invokeGetSupplierInvoice({ + supplierInvoiceId: 123, + select: 'InvoiceNumber,PreventPayment', + expand: 'CostSupplier' + }); + + expect(clientMock.get).toHaveBeenCalledWith( + '/supplierinvoices/123', + { + select: 'InvoiceNumber,PreventPayment', + expand: 'CostSupplier' + }, + 'company-from-config' + ); + expect(result.output.supplierInvoice).toMatchObject({ + id: 123, + invoiceNumber: 'SI-1001', + supplierId: 45, + supplierName: 'Vendor AS', + supplierOrgNumber: '999888777', + invoiceDate: '2026-01-02', + financialDate: '2026-01-03', + paymentDueDate: '2026-02-02', + statusCode: 30103, + paymentStatus: 2, + printStatus: 1, + taxInclusiveAmount: 1250, + taxExclusiveAmount: 1000, + restAmount: 250, + isSentToPayment: true, + preventPayment: true, + credited: true, + creditedAmount: 100, + creditedAmountCurrency: 100, + payableRoundingAmount: 0.5, + payableRoundingCurrencyAmount: 0.5, + invoiceOriginType: 1, + journalEntryId: 987, + deleted: false + }); + }); +}); + +describe('SpareBank 1 Regnskap get_profit_and_loss', () => { + it('queries the documented profit-and-loss action with documented query parameters', async () => { + clientMock.report.mockResolvedValueOnce({ + Periods: [], + Totals: {} + }); + + let result = await invokeGetProfitAndLoss({ + companyKey: 'company-from-input', + financialYear: 2026, + sumAllYears: 'true' + }); + + expect(clientMock.report).toHaveBeenCalledWith( + '/accounts?action=profit-and-loss-periodical', + { + FinancialYear: 2026, + SumAllYears: 'true' + }, + 'company-from-input' + ); + expect(result.output.report).toEqual({ + Periods: [], + Totals: {} + }); + }); +}); + +describe('SpareBank 1 Regnskap get_balance_sheet', () => { + it('queries the documented balance action with only the documented query parameter', async () => { + clientMock.report.mockResolvedValueOnce({ + Rows: [] + }); + + let result = await invokeGetBalanceSheet({ + financialYear: 2026, + select: 'ID', + expand: 'AccountGroup' + }); + + expect(clientMock.report).toHaveBeenCalledWith( + '/accounts?action=balance', + { + FinancialYear: 2026 + }, + 'company-from-config' + ); + expect(result.output.report).toEqual({ + Rows: [] + }); + }); +}); diff --git a/integrations/sparebank-1-regnskap/src/tools.files.test.ts b/integrations/sparebank-1-regnskap/src/tools.files.test.ts new file mode 100644 index 0000000000..c00aa513ae --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools.files.test.ts @@ -0,0 +1,16 @@ +import { ServiceError } from '@lowerdeck/error'; +import { describe, expect, it } from 'vitest'; +import { normalizeStorageReference } from './tools/files'; + +describe('SpareBank 1 Regnskap download_file', () => { + it('trims the documented file StorageReference query id', () => { + expect(normalizeStorageReference(' file-storage-reference ')).toBe( + 'file-storage-reference' + ); + }); + + it('rejects blank StorageReference values before calling the file service', () => { + expect(() => normalizeStorageReference(' ')).toThrow(ServiceError); + expect(() => normalizeStorageReference(' ')).toThrow('storageReference is required'); + }); +}); diff --git a/integrations/sparebank-1-regnskap/src/tools.master-data.test.ts b/integrations/sparebank-1-regnskap/src/tools.master-data.test.ts new file mode 100644 index 0000000000..8c06b6ac57 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools.master-data.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let clientMock = vi.hoisted(() => ({ + list: vi.fn() +})); + +vi.mock('./lib/client', () => ({ + SpareBankRegnskapClient: vi.fn(() => clientMock) +})); + +import { listAccounts } from './tools/master-data'; + +let invokeListAccounts = (input: Record) => + listAccounts.handleInvocation({ + auth: { + token: 'token', + environment: 'sb1', + environmentName: 'SpareBank 1 Regnskap', + baseUrl: 'https://regnskap.sb1.no/', + appFrameworkUrl: 'https://regnskap.sb1.no/', + identityUrl: 'https://login.regnskap.sparebank1.no/', + filesUrl: 'https://files.regnskap.sb1.no/' + }, + config: { companyKey: 'company-from-config' }, + input + } as any); + +beforeEach(() => { + clientMock.list.mockReset(); +}); + +describe('SpareBank 1 Regnskap list_accounts', () => { + it('queries the documented accounts endpoint and maps documented Account fields', async () => { + clientMock.list.mockResolvedValueOnce([ + { + ID: 12, + AccountID: 34, + AccountNumber: 3000, + AccountName: 'Sales revenue', + Description: 'Domestic sales', + AccountGroupID: 3, + TopLevelAccountGroupID: 1, + VatTypeID: 25, + StatusCode: 100, + Active: true, + Visible: true, + Locked: false, + LockManualPosts: true, + Deleted: false, + SystemAccount: false, + UpdatedAt: '2026-01-02T03:04:05Z' + } + ]); + + let result = await invokeListAccounts({ + companyKey: 'company-from-input', + accountNumber: 3000, + search: 'Sales', + active: true, + visible: true, + deleted: false, + top: 10, + skip: 5 + }); + + expect(clientMock.list).toHaveBeenCalledWith( + '/accounts', + { + filter: + "AccountNumber eq 3000 and contains(AccountName,'Sales') and Active eq true and Visible eq true and Deleted eq false", + top: 10, + skip: 5 + }, + 'company-from-input' + ); + expect(result.output.accounts[0]).toMatchObject({ + id: 12, + accountId: 34, + accountNumber: 3000, + accountName: 'Sales revenue', + name: 'Sales revenue', + description: 'Domestic sales', + accountGroupId: 3, + topLevelAccountGroupId: 1, + vatTypeId: 25, + statusCode: 100, + active: true, + visible: true, + locked: false, + lockManualPosts: true, + deleted: false, + systemAccount: false, + updatedAt: '2026-01-02T03:04:05Z' + }); + }); +}); diff --git a/integrations/sparebank-1-regnskap/src/tools.schema.test.ts b/integrations/sparebank-1-regnskap/src/tools.schema.test.ts new file mode 100644 index 0000000000..2f9864acb6 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools.schema.test.ts @@ -0,0 +1,62 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('SpareBank 1 Regnskap tool input schemas', provider.actions); + +describe('SpareBank 1 Regnskap product tool schema', () => { + it('exposes documented Product fields as structured list filters', () => { + let action = provider.actions.find(child => child.key === 'list_products'); + let inputSchema = z.toJSONSchema(action?.inputSchema ?? z.object({})) as { + properties?: Record; + }; + + expect(inputSchema.properties?.externalProductNumber?.type).toBe('string'); + expect(inputSchema.properties?.defaultProductCategoryId?.type).toBe('integer'); + expect(inputSchema.properties?.statusCode?.type).toBe('integer'); + }); +}); + +describe('SpareBank 1 Regnskap get_trial_balance schema', () => { + it('does not expose undocumented report query parameters', () => { + let action = provider.actions.find(child => child.key === 'get_trial_balance'); + let inputSchema = z.toJSONSchema(action?.inputSchema ?? z.object({})) as { + properties?: Record; + }; + + expect(inputSchema.properties).toHaveProperty('companyKey'); + expect(inputSchema.properties).not.toHaveProperty('financialYear'); + expect(inputSchema.properties).not.toHaveProperty('select'); + expect(inputSchema.properties).not.toHaveProperty('expand'); + }); +}); + +describe('SpareBank 1 Regnskap get_profit_and_loss schema', () => { + it('matches the documented report query parameters', () => { + let action = provider.actions.find(child => child.key === 'get_profit_and_loss'); + let inputSchema = z.toJSONSchema(action?.inputSchema ?? z.object({})) as { + properties?: Record; + }; + + expect(inputSchema.properties).toHaveProperty('companyKey'); + expect(inputSchema.properties?.financialYear?.type).toBe('integer'); + expect(inputSchema.properties?.sumAllYears?.type).toBe('string'); + expect(inputSchema.properties).not.toHaveProperty('select'); + expect(inputSchema.properties).not.toHaveProperty('expand'); + }); +}); + +describe('SpareBank 1 Regnskap get_balance_sheet schema', () => { + it('matches the documented report query parameters', () => { + let action = provider.actions.find(child => child.key === 'get_balance_sheet'); + let inputSchema = z.toJSONSchema(action?.inputSchema ?? z.object({})) as { + properties?: Record; + }; + + expect(inputSchema.properties).toHaveProperty('companyKey'); + expect(inputSchema.properties?.financialYear?.type).toBe('integer'); + expect(inputSchema.properties).not.toHaveProperty('select'); + expect(inputSchema.properties).not.toHaveProperty('expand'); + }); +}); diff --git a/integrations/sparebank-1-regnskap/src/tools/accounting.ts b/integrations/sparebank-1-regnskap/src/tools/accounting.ts new file mode 100644 index 0000000000..6459d0df0a --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools/accounting.ts @@ -0,0 +1,456 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { spec } from '../spec'; +import { + asRecord, + booleanByKeys, + booleanEqualsFilter, + combineFilters, + containsFilter, + createClient, + dateFromFilter, + dateToFilter, + idFrom, + listMetadata, + listMetadataSchema, + nameFrom, + numberByKeys, + numberEqualsFilter, + queryInputShape, + queryParams, + rawRecordSchema, + requireCompanyKey, + stringByKeys, + stringEqualsFilter +} from './shared'; + +let customerInvoiceSchema = z.object({ + id: z.number().optional(), + invoiceNumber: z.string().optional(), + customerId: z.number().optional(), + customerName: z.string().optional(), + invoiceDate: z.string().optional(), + paymentDueDate: z.string().optional(), + statusCode: z.number().optional(), + printStatus: z.number().optional(), + taxInclusiveAmount: z.number().optional(), + taxExclusiveAmount: z.number().optional(), + restAmount: z.number().optional(), + deleted: z.boolean().optional(), + raw: rawRecordSchema +}); + +let supplierInvoiceSchema = z.object({ + id: z.number().optional(), + invoiceNumber: z.string().optional(), + supplierId: z.number().optional(), + supplierName: z.string().optional(), + supplierOrgNumber: z.string().optional(), + invoiceDate: z.string().optional(), + financialDate: z.string().optional(), + paymentDueDate: z.string().optional(), + statusCode: z.number().optional(), + paymentStatus: z.number().optional(), + taxInclusiveAmount: z.number().optional(), + taxExclusiveAmount: z.number().optional(), + restAmount: z.number().optional(), + isSentToPayment: z.boolean().optional(), + deleted: z.boolean().optional(), + raw: rawRecordSchema +}); + +let supplierInvoiceDetailSchema = supplierInvoiceSchema.extend({ + printStatus: z.number().optional(), + preventPayment: z.boolean().optional(), + credited: z.boolean().optional(), + creditedAmount: z.number().optional(), + creditedAmountCurrency: z.number().optional(), + payableRoundingAmount: z.number().optional(), + payableRoundingCurrencyAmount: z.number().optional(), + invoiceOriginType: z.number().optional(), + journalEntryId: z.number().optional() +}); + +let reportOutputSchema = z.object({ + report: z.any().describe('Raw Unimicro accounting report payload.') +}); + +let mapCustomerInvoice = (value: unknown): z.infer => { + let record = asRecord(value); + return { + id: idFrom(record), + invoiceNumber: stringByKeys(record, ['InvoiceNumber']), + customerId: numberByKeys(record, ['CustomerID']), + customerName: + stringByKeys(record, ['CustomerName']) ?? nameFrom(asRecord(record.Customer)), + invoiceDate: stringByKeys(record, ['InvoiceDate']), + paymentDueDate: stringByKeys(record, ['PaymentDueDate']), + statusCode: numberByKeys(record, ['StatusCode']), + printStatus: numberByKeys(record, ['PrintStatus']), + taxInclusiveAmount: numberByKeys(record, [ + 'TaxInclusiveAmount', + 'TaxInclusiveAmountCurrency' + ]), + taxExclusiveAmount: numberByKeys(record, [ + 'TaxExclusiveAmount', + 'TaxExclusiveAmountCurrency' + ]), + restAmount: numberByKeys(record, ['RestAmount', 'RestAmountCurrency']), + deleted: booleanByKeys(record, ['Deleted']), + raw: record + }; +}; + +let mapSupplierInvoice = (value: unknown): z.infer => { + let record = asRecord(value); + let supplier = asRecord(record.Supplier ?? record.CostSupplier); + return { + id: idFrom(record), + invoiceNumber: stringByKeys(record, ['InvoiceNumber']), + supplierId: numberByKeys(record, ['SupplierID', 'CostSupplierID']), + supplierName: nameFrom(supplier), + supplierOrgNumber: stringByKeys(record, ['SupplierOrgNumber']), + invoiceDate: stringByKeys(record, ['InvoiceDate']), + financialDate: stringByKeys(record, ['FinancialDate']), + paymentDueDate: stringByKeys(record, ['PaymentDueDate']), + statusCode: numberByKeys(record, ['StatusCode']), + paymentStatus: numberByKeys(record, ['PaymentStatus']), + taxInclusiveAmount: numberByKeys(record, [ + 'TaxInclusiveAmount', + 'TaxInclusiveAmountCurrency' + ]), + taxExclusiveAmount: numberByKeys(record, [ + 'TaxExclusiveAmount', + 'TaxExclusiveAmountCurrency' + ]), + restAmount: numberByKeys(record, ['RestAmount', 'RestAmountCurrency']), + isSentToPayment: booleanByKeys(record, ['IsSentToPayment']), + deleted: booleanByKeys(record, ['Deleted']), + raw: record + }; +}; + +let mapSupplierInvoiceDetail = ( + value: unknown +): z.infer => { + let record = asRecord(value); + return { + ...mapSupplierInvoice(record), + printStatus: numberByKeys(record, ['PrintStatus']), + preventPayment: booleanByKeys(record, ['PreventPayment']), + credited: booleanByKeys(record, ['Credited']), + creditedAmount: numberByKeys(record, ['CreditedAmount']), + creditedAmountCurrency: numberByKeys(record, ['CreditedAmountCurrency']), + payableRoundingAmount: numberByKeys(record, ['PayableRoundingAmount']), + payableRoundingCurrencyAmount: numberByKeys(record, ['PayableRoundingCurrencyAmount']), + invoiceOriginType: numberByKeys(record, ['InvoiceOriginType']), + journalEntryId: numberByKeys(record, ['JournalEntryID']) + }; +}; + +let invoiceListInput = { + invoiceNumber: z.string().optional(), + statusCode: z.number().int().optional(), + invoiceDateFrom: z + .string() + .optional() + .describe('Filter InvoiceDate greater than or equal to this date.'), + invoiceDateTo: z + .string() + .optional() + .describe('Filter InvoiceDate less than or equal to this date.'), + deleted: z.boolean().optional(), + ...queryInputShape +}; + +export let listCustomerInvoices = SlateTool.create(spec, { + name: 'List Customer Invoices', + key: 'list_customer_invoices', + description: + 'List SpareBank 1 Regnskap customer invoices with optional customer, invoice number, status, date, Unimicro filter/select/expand, and pagination.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + customerId: z.number().int().positive().optional(), + customerName: z.string().optional(), + ...invoiceListInput + }) + ) + .output( + z.object({ + invoices: z.array(customerInvoiceSchema), + ...listMetadataSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let generatedFilter = combineFilters([ + numberEqualsFilter('CustomerID', ctx.input.customerId), + containsFilter('CustomerName', ctx.input.customerName), + stringEqualsFilter('InvoiceNumber', ctx.input.invoiceNumber), + numberEqualsFilter('StatusCode', ctx.input.statusCode), + dateFromFilter('InvoiceDate', ctx.input.invoiceDateFrom), + dateToFilter('InvoiceDate', ctx.input.invoiceDateTo), + booleanEqualsFilter('Deleted', ctx.input.deleted) + ]); + let raw = await client.list( + '/invoices', + queryParams(ctx.input, generatedFilter), + requireCompanyKey(ctx, ctx.input.companyKey) + ); + let invoices = raw.map(mapCustomerInvoice); + + return { + output: { + invoices, + ...listMetadata(raw, ctx.input) + }, + message: `Found **${invoices.length}** SpareBank 1 Regnskap customer invoice(s).` + }; + }) + .build(); + +export let getCustomerInvoice = SlateTool.create(spec, { + name: 'Get Customer Invoice', + key: 'get_customer_invoice', + description: + 'Fetch one SpareBank 1 Regnskap customer invoice by Unimicro invoice ID, with optional select and expand such as Items,Customer.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + invoiceId: z.number().int().positive().describe('Unimicro customer invoice ID.'), + select: queryInputShape.select, + expand: queryInputShape.expand, + companyKey: queryInputShape.companyKey + }) + ) + .output(z.object({ invoice: customerInvoiceSchema })) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let invoice = mapCustomerInvoice( + await client.get( + `/invoices/${ctx.input.invoiceId}`, + queryParams(ctx.input), + requireCompanyKey(ctx, ctx.input.companyKey) + ) + ); + + return { + output: { invoice }, + message: `Fetched SpareBank 1 Regnskap customer invoice **${invoice.invoiceNumber ?? invoice.id ?? ctx.input.invoiceId}**.` + }; + }) + .build(); + +export let listSupplierInvoices = SlateTool.create(spec, { + name: 'List Supplier Invoices', + key: 'list_supplier_invoices', + description: + 'List SpareBank 1 Regnskap supplier invoices with optional supplier, invoice number, status, date, payment state, Unimicro filter/select/expand, and pagination.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + supplierId: z.number().int().positive().optional(), + supplierOrgNumber: z.string().optional(), + paymentStatus: z.number().int().optional(), + isSentToPayment: z.boolean().optional(), + ...invoiceListInput + }) + ) + .output( + z.object({ + supplierInvoices: z.array(supplierInvoiceSchema), + ...listMetadataSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let generatedFilter = combineFilters([ + numberEqualsFilter('SupplierID', ctx.input.supplierId), + stringEqualsFilter('SupplierOrgNumber', ctx.input.supplierOrgNumber), + stringEqualsFilter('InvoiceNumber', ctx.input.invoiceNumber), + numberEqualsFilter('StatusCode', ctx.input.statusCode), + numberEqualsFilter('PaymentStatus', ctx.input.paymentStatus), + booleanEqualsFilter('IsSentToPayment', ctx.input.isSentToPayment), + dateFromFilter('InvoiceDate', ctx.input.invoiceDateFrom), + dateToFilter('InvoiceDate', ctx.input.invoiceDateTo), + booleanEqualsFilter('Deleted', ctx.input.deleted) + ]); + let raw = await client.list( + '/supplierinvoices', + queryParams(ctx.input, generatedFilter), + requireCompanyKey(ctx, ctx.input.companyKey) + ); + let supplierInvoices = raw.map(mapSupplierInvoice); + + return { + output: { + supplierInvoices, + ...listMetadata(raw, ctx.input) + }, + message: `Found **${supplierInvoices.length}** SpareBank 1 Regnskap supplier invoice(s).` + }; + }) + .build(); + +export let getSupplierInvoice = SlateTool.create(spec, { + name: 'Get Supplier Invoice', + key: 'get_supplier_invoice', + description: + 'Fetch one SpareBank 1 Regnskap supplier invoice by Unimicro supplier invoice ID, with optional select and expand such as Items,Supplier.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + supplierInvoiceId: z.number().int().positive().describe('Unimicro supplier invoice ID.'), + select: queryInputShape.select, + expand: queryInputShape.expand, + companyKey: queryInputShape.companyKey + }) + ) + .output(z.object({ supplierInvoice: supplierInvoiceDetailSchema })) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let supplierInvoice = mapSupplierInvoiceDetail( + await client.get( + `/supplierinvoices/${ctx.input.supplierInvoiceId}`, + queryParams(ctx.input), + requireCompanyKey(ctx, ctx.input.companyKey) + ) + ); + + return { + output: { supplierInvoice }, + message: `Fetched SpareBank 1 Regnskap supplier invoice **${supplierInvoice.invoiceNumber ?? supplierInvoice.id ?? ctx.input.supplierInvoiceId}**.` + }; + }) + .build(); + +let profitAndLossInput = z.object({ + financialYear: z + .number() + .int() + .optional() + .describe('Unimicro FinancialYear query parameter.'), + sumAllYears: z + .string() + .optional() + .describe( + 'Unimicro SumAllYears query parameter. The official Swagger documents this parameter as a string.' + ), + companyKey: queryInputShape.companyKey +}); + +let balanceSheetInput = z.object({ + financialYear: z + .number() + .int() + .optional() + .describe('Unimicro FinancialYear query parameter.'), + companyKey: queryInputShape.companyKey +}); + +let trialBalanceInput = z.object({ + companyKey: queryInputShape.companyKey +}); + +export let getTrialBalance = SlateTool.create(spec, { + name: 'Get Trial Balance', + key: 'get_trial_balance', + description: + 'Retrieve the SpareBank 1 Regnskap trial balance report from the Unimicro accounts action. The official Swagger does not expose date, year, select, or expand parameters for this action.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(trialBalanceInput) + .output(reportOutputSchema) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let report = await client.report( + '/accounts?action=trialbalance', + {}, + requireCompanyKey(ctx, ctx.input.companyKey) + ); + + return { + output: { report }, + message: 'Fetched SpareBank 1 Regnskap trial balance.' + }; + }) + .build(); + +export let getProfitAndLoss = SlateTool.create(spec, { + name: 'Get Profit and Loss', + key: 'get_profit_and_loss', + description: + 'Retrieve the SpareBank 1 Regnskap profit and loss report from the Unimicro accounts periodical action. The official Swagger exposes FinancialYear and SumAllYears query parameters for this action.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(profitAndLossInput) + .output(reportOutputSchema) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let report = await client.report( + '/accounts?action=profit-and-loss-periodical', + { + FinancialYear: ctx.input.financialYear, + SumAllYears: ctx.input.sumAllYears + }, + requireCompanyKey(ctx, ctx.input.companyKey) + ); + + return { + output: { report }, + message: 'Fetched SpareBank 1 Regnskap profit and loss report.' + }; + }) + .build(); + +export let getBalanceSheet = SlateTool.create(spec, { + name: 'Get Balance Sheet', + key: 'get_balance_sheet', + description: + 'Retrieve the SpareBank 1 Regnskap balance sheet report from the Unimicro accounts action. The official Swagger exposes FinancialYear for this action.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(balanceSheetInput) + .output(reportOutputSchema) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let report = await client.report( + '/accounts?action=balance', + { + FinancialYear: ctx.input.financialYear + }, + requireCompanyKey(ctx, ctx.input.companyKey) + ); + + return { + output: { report }, + message: 'Fetched SpareBank 1 Regnskap balance sheet.' + }; + }) + .build(); diff --git a/integrations/sparebank-1-regnskap/src/tools/context.ts b/integrations/sparebank-1-regnskap/src/tools/context.ts new file mode 100644 index 0000000000..8a9d0e1ca7 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools/context.ts @@ -0,0 +1,64 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { spec } from '../spec'; +import { + asBoolean, + asRecord, + asString, + createClient, + idFrom, + nameFrom, + rawRecordSchema +} from './shared'; + +let companySchema = z.object({ + id: z.number().optional(), + companyKey: z.string().optional(), + name: z.string().optional(), + organizationNumber: z.string().optional(), + isTest: z.boolean().optional(), + raw: rawRecordSchema +}); + +let mapCompany = (value: unknown): z.infer => { + let record = asRecord(value); + return { + id: idFrom(record), + companyKey: asString(record.CompanyKey ?? record.Key ?? record.companyKey ?? record.key), + name: nameFrom(record), + organizationNumber: asString(record.OrgNumber ?? record.OrganizationNumber), + isTest: asBoolean(record.IsTest ?? record.isTest), + raw: record + }; +}; + +export let listCompanies = SlateTool.create(spec, { + name: 'List Companies', + key: 'list_companies', + description: + 'List SpareBank 1 Regnskap companies available to the authenticated user so a CompanyKey can be selected for company-scoped tools.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + companies: z.array(companySchema), + returnedCount: z.number() + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let companies = (await client.listCompanies()).map(mapCompany); + + return { + output: { + companies, + returnedCount: companies.length + }, + message: `Found **${companies.length}** SpareBank 1 Regnskap compan${companies.length === 1 ? 'y' : 'ies'}.` + }; + }) + .build(); diff --git a/integrations/sparebank-1-regnskap/src/tools/files.ts b/integrations/sparebank-1-regnskap/src/tools/files.ts new file mode 100644 index 0000000000..9ec6f0a5f2 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools/files.ts @@ -0,0 +1,70 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { spareBankRegnskapValidationError } from '../lib/errors'; +import { spec } from '../spec'; +import { createClient, requireCompanyKey } from './shared'; + +export let normalizeStorageReference = (value: string) => { + let storageReference = value.trim(); + if (!storageReference) { + throw spareBankRegnskapValidationError( + 'storageReference is required to download a SpareBank 1 Regnskap file.' + ); + } + + return storageReference; +}; + +export let downloadFile = SlateTool.create(spec, { + name: 'Download File', + key: 'download_file', + description: + 'Download a SpareBank 1 Regnskap file from the Unimicro file service by StorageReference and return it as a Slate attachment.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + storageReference: z.string().describe('Unimicro file StorageReference to download.'), + companyKey: z + .string() + .optional() + .describe('Unimicro CompanyKey. Required unless configured globally.'), + fileName: z.string().optional().describe('Optional attachment filename metadata.'), + mimeType: z.string().optional().describe('Optional expected MIME type.') + }) + ) + .output( + z.object({ + storageReference: z.string(), + fileName: z.string().optional(), + mimeType: z.string(), + byteLength: z.number(), + attachmentCount: z.number() + }) + ) + .handleInvocation(async ctx => { + let storageReference = normalizeStorageReference(ctx.input.storageReference); + let client = createClient(ctx); + let file = await client.downloadFile({ + storageReference, + companyKey: requireCompanyKey(ctx, ctx.input.companyKey), + fileName: ctx.input.fileName, + mimeType: ctx.input.mimeType + }); + + return { + output: { + storageReference, + fileName: file.fileName, + mimeType: file.mimeType, + byteLength: file.byteLength, + attachmentCount: 1 + }, + message: `Downloaded SpareBank 1 Regnskap file **${file.fileName ?? storageReference}**.`, + attachments: [createBase64Attachment(file.contentBase64, file.mimeType)] + }; + }) + .build(); diff --git a/integrations/sparebank-1-regnskap/src/tools/index.ts b/integrations/sparebank-1-regnskap/src/tools/index.ts new file mode 100644 index 0000000000..285ecf96d5 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools/index.ts @@ -0,0 +1,20 @@ +export { + getBalanceSheet, + getCustomerInvoice, + getProfitAndLoss, + getSupplierInvoice, + getTrialBalance, + listCustomerInvoices, + listSupplierInvoices +} from './accounting'; +export { listCompanies } from './context'; +export { downloadFile } from './files'; +export { + getCustomer, + getSupplier, + listAccounts, + listCustomers, + listProducts, + listProjects, + listSuppliers +} from './master-data'; diff --git a/integrations/sparebank-1-regnskap/src/tools/master-data.ts b/integrations/sparebank-1-regnskap/src/tools/master-data.ts new file mode 100644 index 0000000000..55db309b51 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools/master-data.ts @@ -0,0 +1,540 @@ +import { SlateTool } from 'slates'; +import { z } from 'zod'; +import { spec } from '../spec'; +import { + asRecord, + booleanByKeys, + booleanEqualsFilter, + combineFilters, + containsFilter, + createClient, + idFrom, + listMetadata, + listMetadataSchema, + nameFrom, + numberByKeys, + numberEqualsFilter, + queryInputShape, + queryParams, + rawRecordSchema, + requireCompanyKey, + stringByKeys, + stringEqualsFilter +} from './shared'; + +let customerSchema = z.object({ + id: z.number().optional(), + customerNumber: z.number().optional(), + name: z.string().optional(), + orgNumber: z.string().optional(), + deleted: z.boolean().optional(), + statusCode: z.number().optional(), + raw: rawRecordSchema +}); + +let supplierSchema = z.object({ + id: z.number().optional(), + supplierNumber: z.number().optional(), + name: z.string().optional(), + orgNumber: z.string().optional(), + deleted: z.boolean().optional(), + statusCode: z.number().optional(), + preventPayments: z.boolean().optional(), + raw: rawRecordSchema +}); + +let productSchema = z.object({ + id: z.number().optional(), + name: z.string().optional(), + partName: z.string().optional(), + externalProductNumber: z.string().optional(), + unit: z.string().optional(), + priceExVat: z.number().optional(), + priceIncVat: z.number().optional(), + accountId: z.number().optional(), + vatTypeId: z.number().optional(), + defaultProductCategoryId: z.number().optional(), + deleted: z.boolean().optional(), + statusCode: z.number().optional(), + raw: rawRecordSchema +}); + +let accountSchema = z.object({ + id: z.number().optional(), + accountId: z.number().optional(), + accountNumber: z.number().optional(), + accountName: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + accountGroupId: z.number().optional(), + topLevelAccountGroupId: z.number().optional(), + vatTypeId: z.number().optional(), + statusCode: z.number().optional(), + active: z.boolean().optional(), + visible: z.boolean().optional(), + locked: z.boolean().optional(), + lockManualPosts: z.boolean().optional(), + deleted: z.boolean().optional(), + systemAccount: z.boolean().optional(), + updatedAt: z.string().optional(), + raw: rawRecordSchema +}); + +let projectSchema = z.object({ + id: z.number().optional(), + projectNumber: z.string().optional(), + name: z.string().optional(), + projectCustomerId: z.number().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + visible: z.boolean().optional(), + deleted: z.boolean().optional(), + statusCode: z.number().optional(), + raw: rawRecordSchema +}); + +let mapCustomer = (value: unknown): z.infer => { + let record = asRecord(value); + return { + id: idFrom(record), + customerNumber: numberByKeys(record, ['CustomerNumber']), + name: nameFrom(record), + orgNumber: stringByKeys(record, ['OrgNumber', 'OrganizationNumber']), + deleted: booleanByKeys(record, ['Deleted']), + statusCode: numberByKeys(record, ['StatusCode']), + raw: record + }; +}; + +let mapSupplier = (value: unknown): z.infer => { + let record = asRecord(value); + return { + id: idFrom(record), + supplierNumber: numberByKeys(record, ['SupplierNumber']), + name: nameFrom(record), + orgNumber: stringByKeys(record, ['OrgNumber', 'OrganizationNumber']), + deleted: booleanByKeys(record, ['Deleted']), + statusCode: numberByKeys(record, ['StatusCode']), + preventPayments: booleanByKeys(record, ['PreventSupplierInvoicePayments']), + raw: record + }; +}; + +let mapProduct = (value: unknown): z.infer => { + let record = asRecord(value); + return { + id: idFrom(record), + name: nameFrom(record), + partName: stringByKeys(record, ['PartName']), + externalProductNumber: stringByKeys(record, ['ExternalProductNumber']), + unit: stringByKeys(record, ['Unit']), + priceExVat: numberByKeys(record, ['PriceExVat']), + priceIncVat: numberByKeys(record, ['PriceIncVat']), + accountId: numberByKeys(record, ['AccountID']), + vatTypeId: numberByKeys(record, ['VatTypeID']), + defaultProductCategoryId: numberByKeys(record, ['DefaultProductCategoryID']), + deleted: booleanByKeys(record, ['Deleted']), + statusCode: numberByKeys(record, ['StatusCode']), + raw: record + }; +}; + +let mapAccount = (value: unknown): z.infer => { + let record = asRecord(value); + let accountName = stringByKeys(record, ['AccountName', 'Name']); + return { + id: idFrom(record), + accountId: numberByKeys(record, ['AccountID']), + accountNumber: numberByKeys(record, ['AccountNumber']), + accountName, + name: accountName, + description: stringByKeys(record, ['Description']), + accountGroupId: numberByKeys(record, ['AccountGroupID']), + topLevelAccountGroupId: numberByKeys(record, ['TopLevelAccountGroupID']), + vatTypeId: numberByKeys(record, ['VatTypeID']), + statusCode: numberByKeys(record, ['StatusCode']), + active: booleanByKeys(record, ['Active']), + visible: booleanByKeys(record, ['Visible']), + locked: booleanByKeys(record, ['Locked']), + lockManualPosts: booleanByKeys(record, ['LockManualPosts']), + deleted: booleanByKeys(record, ['Deleted']), + systemAccount: booleanByKeys(record, ['SystemAccount']), + updatedAt: stringByKeys(record, ['UpdatedAt']), + raw: record + }; +}; + +let mapProject = (value: unknown): z.infer => { + let record = asRecord(value); + return { + id: idFrom(record), + projectNumber: stringByKeys(record, ['ProjectNumber']), + name: nameFrom(record), + projectCustomerId: numberByKeys(record, ['ProjectCustomerID']), + startDate: stringByKeys(record, ['StartDate', 'PlannedStartdate']), + endDate: stringByKeys(record, ['EndDate', 'PlannedEnddate']), + visible: booleanByKeys(record, ['Visible']), + deleted: booleanByKeys(record, ['Deleted']), + statusCode: numberByKeys(record, ['StatusCode']), + raw: record + }; +}; + +let customerFilter = (input: { + search?: string; + orgNumber?: string; + customerNumber?: number; + statusCode?: number; + deleted?: boolean; +}) => + combineFilters([ + containsFilter('Info.Name', input.search), + stringEqualsFilter('OrgNumber', input.orgNumber), + numberEqualsFilter('CustomerNumber', input.customerNumber), + numberEqualsFilter('StatusCode', input.statusCode), + booleanEqualsFilter('Deleted', input.deleted) + ]); + +let supplierFilter = (input: { + search?: string; + orgNumber?: string; + supplierNumber?: number; + statusCode?: number; + deleted?: boolean; +}) => + combineFilters([ + containsFilter('Info.Name', input.search), + stringEqualsFilter('OrgNumber', input.orgNumber), + numberEqualsFilter('SupplierNumber', input.supplierNumber), + numberEqualsFilter('StatusCode', input.statusCode), + booleanEqualsFilter('Deleted', input.deleted) + ]); + +export let listCustomers = SlateTool.create(spec, { + name: 'List Customers', + key: 'list_customers', + description: + 'List SpareBank 1 Regnskap customers with optional organization number, customer number, status, deleted flag, Unimicro filter/select/expand, and pagination.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + search: z.string().optional().describe('Search customer business relation name.'), + orgNumber: z.string().optional().describe('Customer organization number.'), + customerNumber: z.number().int().positive().optional(), + statusCode: z.number().int().optional(), + deleted: z.boolean().optional(), + ...queryInputShape + }) + ) + .output( + z.object({ + customers: z.array(customerSchema), + ...listMetadataSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let raw = await client.list( + '/customers', + queryParams(ctx.input, customerFilter(ctx.input)), + requireCompanyKey(ctx, ctx.input.companyKey) + ); + let customers = raw.map(mapCustomer); + + return { + output: { + customers, + ...listMetadata(raw, ctx.input) + }, + message: `Found **${customers.length}** SpareBank 1 Regnskap customer(s).` + }; + }) + .build(); + +export let getCustomer = SlateTool.create(spec, { + name: 'Get Customer', + key: 'get_customer', + description: + 'Fetch one SpareBank 1 Regnskap customer by Unimicro customer ID, with optional select and expand.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + customerId: z.number().int().positive().describe('Unimicro customer ID.'), + select: queryInputShape.select, + expand: queryInputShape.expand, + companyKey: queryInputShape.companyKey + }) + ) + .output(z.object({ customer: customerSchema })) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let customer = mapCustomer( + await client.get( + `/customers/${ctx.input.customerId}`, + queryParams(ctx.input), + requireCompanyKey(ctx, ctx.input.companyKey) + ) + ); + + return { + output: { customer }, + message: `Fetched SpareBank 1 Regnskap customer **${customer.name ?? customer.id ?? ctx.input.customerId}**.` + }; + }) + .build(); + +export let listSuppliers = SlateTool.create(spec, { + name: 'List Suppliers', + key: 'list_suppliers', + description: + 'List SpareBank 1 Regnskap suppliers with optional organization number, supplier number, status, deleted flag, Unimicro filter/select/expand, and pagination.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + search: z.string().optional().describe('Search supplier business relation name.'), + orgNumber: z.string().optional().describe('Supplier organization number.'), + supplierNumber: z.number().int().positive().optional(), + statusCode: z.number().int().optional(), + deleted: z.boolean().optional(), + ...queryInputShape + }) + ) + .output( + z.object({ + suppliers: z.array(supplierSchema), + ...listMetadataSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let raw = await client.list( + '/suppliers', + queryParams(ctx.input, supplierFilter(ctx.input)), + requireCompanyKey(ctx, ctx.input.companyKey) + ); + let suppliers = raw.map(mapSupplier); + + return { + output: { + suppliers, + ...listMetadata(raw, ctx.input) + }, + message: `Found **${suppliers.length}** SpareBank 1 Regnskap supplier(s).` + }; + }) + .build(); + +export let getSupplier = SlateTool.create(spec, { + name: 'Get Supplier', + key: 'get_supplier', + description: + 'Fetch one SpareBank 1 Regnskap supplier by Unimicro supplier ID, with optional select and expand.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + supplierId: z.number().int().positive().describe('Unimicro supplier ID.'), + select: queryInputShape.select, + expand: queryInputShape.expand, + companyKey: queryInputShape.companyKey + }) + ) + .output(z.object({ supplier: supplierSchema })) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let supplier = mapSupplier( + await client.get( + `/suppliers/${ctx.input.supplierId}`, + queryParams(ctx.input), + requireCompanyKey(ctx, ctx.input.companyKey) + ) + ); + + return { + output: { supplier }, + message: `Fetched SpareBank 1 Regnskap supplier **${supplier.name ?? supplier.id ?? ctx.input.supplierId}**.` + }; + }) + .build(); + +export let listProducts = SlateTool.create(spec, { + name: 'List Products', + key: 'list_products', + description: + 'List SpareBank 1 Regnskap products with optional name, part number, account, VAT type, Unimicro filter/select/expand, and pagination.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + search: z.string().optional().describe('Search product name.'), + partName: z.string().optional().describe('Product part number.'), + externalProductNumber: z.string().optional().describe('External product number.'), + accountId: z.number().int().positive().optional(), + vatTypeId: z.number().int().positive().optional(), + defaultProductCategoryId: z + .number() + .int() + .positive() + .optional() + .describe('Default product category ID.'), + statusCode: z.number().int().optional().describe('Product status code.'), + deleted: z.boolean().optional(), + ...queryInputShape + }) + ) + .output( + z.object({ + products: z.array(productSchema), + ...listMetadataSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let generatedFilter = combineFilters([ + containsFilter('Name', ctx.input.search), + stringEqualsFilter('PartName', ctx.input.partName), + stringEqualsFilter('ExternalProductNumber', ctx.input.externalProductNumber), + numberEqualsFilter('AccountID', ctx.input.accountId), + numberEqualsFilter('VatTypeID', ctx.input.vatTypeId), + numberEqualsFilter('DefaultProductCategoryID', ctx.input.defaultProductCategoryId), + numberEqualsFilter('StatusCode', ctx.input.statusCode), + booleanEqualsFilter('Deleted', ctx.input.deleted) + ]); + let raw = await client.list( + '/products', + queryParams(ctx.input, generatedFilter), + requireCompanyKey(ctx, ctx.input.companyKey) + ); + let products = raw.map(mapProduct); + + return { + output: { + products, + ...listMetadata(raw, ctx.input) + }, + message: `Found **${products.length}** SpareBank 1 Regnskap product(s).` + }; + }) + .build(); + +export let listAccounts = SlateTool.create(spec, { + name: 'List Accounts', + key: 'list_accounts', + description: + 'List SpareBank 1 Regnskap chart-of-account records with optional account number, name, active/deleted filters, Unimicro filter/select/expand, and pagination.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + accountNumber: z.number().int().positive().optional(), + search: z.string().optional().describe('Search account name.'), + active: z.boolean().optional(), + visible: z.boolean().optional(), + deleted: z.boolean().optional(), + ...queryInputShape + }) + ) + .output( + z.object({ + accounts: z.array(accountSchema), + ...listMetadataSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let generatedFilter = combineFilters([ + numberEqualsFilter('AccountNumber', ctx.input.accountNumber), + containsFilter('AccountName', ctx.input.search), + booleanEqualsFilter('Active', ctx.input.active), + booleanEqualsFilter('Visible', ctx.input.visible), + booleanEqualsFilter('Deleted', ctx.input.deleted) + ]); + let raw = await client.list( + '/accounts', + queryParams(ctx.input, generatedFilter), + requireCompanyKey(ctx, ctx.input.companyKey) + ); + let accounts = raw.map(mapAccount); + + return { + output: { + accounts, + ...listMetadata(raw, ctx.input) + }, + message: `Found **${accounts.length}** SpareBank 1 Regnskap account(s).` + }; + }) + .build(); + +export let listProjects = SlateTool.create(spec, { + name: 'List Projects', + key: 'list_projects', + description: + 'List SpareBank 1 Regnskap projects with optional project number, name, customer, deleted/visible filters, Unimicro filter/select/expand, and pagination.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + search: z.string().optional().describe('Search project name.'), + projectNumber: z.string().optional(), + projectCustomerId: z.number().int().positive().optional(), + visible: z.boolean().optional(), + deleted: z.boolean().optional(), + ...queryInputShape + }) + ) + .output( + z.object({ + projects: z.array(projectSchema), + ...listMetadataSchema + }) + ) + .handleInvocation(async ctx => { + let client = createClient(ctx); + let generatedFilter = combineFilters([ + containsFilter('Name', ctx.input.search), + stringEqualsFilter('ProjectNumber', ctx.input.projectNumber), + numberEqualsFilter('ProjectCustomerID', ctx.input.projectCustomerId), + booleanEqualsFilter('Visible', ctx.input.visible), + booleanEqualsFilter('Deleted', ctx.input.deleted) + ]); + let raw = await client.list( + '/projects', + queryParams(ctx.input, generatedFilter), + requireCompanyKey(ctx, ctx.input.companyKey) + ); + let projects = raw.map(mapProject); + + return { + output: { + projects, + ...listMetadata(raw, ctx.input) + }, + message: `Found **${projects.length}** SpareBank 1 Regnskap project(s).` + }; + }) + .build(); diff --git a/integrations/sparebank-1-regnskap/src/tools/shared.ts b/integrations/sparebank-1-regnskap/src/tools/shared.ts new file mode 100644 index 0000000000..3467f03ec5 --- /dev/null +++ b/integrations/sparebank-1-regnskap/src/tools/shared.ts @@ -0,0 +1,153 @@ +import { pickDefined } from 'slates'; +import { z } from 'zod'; +import type { SpareBankRegnskapAuthOutput } from '../auth'; +import type { SpareBankRegnskapConfig } from '../config'; +import { SpareBankRegnskapClient, type UnimicroQueryParams } from '../lib/client'; +import { spareBankRegnskapValidationError } from '../lib/errors'; + +export type ToolContext = { + auth: SpareBankRegnskapAuthOutput; + config: SpareBankRegnskapConfig; +}; + +export let rawRecordSchema = z + .record(z.string(), z.any()) + .describe('Raw SpareBank 1 Regnskap / Unimicro record'); + +export let queryInputShape = { + filter: z + .string() + .optional() + .describe("Advanced Unimicro filter expression, such as contains(Name,'Acme')."), + select: z + .string() + .optional() + .describe('Comma-separated Unimicro select expression for fields to return.'), + expand: z + .string() + .optional() + .describe('Comma-separated Unimicro expand expression for related entities.'), + top: z.number().int().min(1).max(1000).optional().describe('Maximum records to return.'), + skip: z.number().int().min(0).optional().describe('Records to skip for pagination.'), + companyKey: z.string().optional().describe('Override Unimicro CompanyKey for this call.') +}; + +export let listMetadataSchema = { + returnedCount: z.number().describe('Number of records returned by this request.'), + top: z.number().optional().describe('Requested top value.'), + skip: z.number().optional().describe('Requested skip value.') +}; + +export let createClient = (ctx: ToolContext) => new SpareBankRegnskapClient(ctx.auth); + +export let companyKeyFor = (ctx: ToolContext, inputCompanyKey?: string) => + inputCompanyKey?.trim() || ctx.config.companyKey?.trim() || undefined; + +export let requireCompanyKey = (ctx: ToolContext, inputCompanyKey?: string) => { + let companyKey = companyKeyFor(ctx, inputCompanyKey); + if (!companyKey) { + throw spareBankRegnskapValidationError( + 'companyKey is required. Provide it in tool input or integration config.' + ); + } + + return companyKey; +}; + +export let asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; + +export let asString = (value: unknown) => + typeof value === 'string' ? value : typeof value === 'number' ? String(value) : undefined; + +export let asNumber = (value: unknown) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim()) { + let parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +export let asBoolean = (value: unknown) => (typeof value === 'boolean' ? value : undefined); + +export let valueByKeys = (record: Record, keys: string[]) => { + for (let key of keys) { + if (record[key] !== undefined && record[key] !== null) return record[key]; + } + + return undefined; +}; + +export let nestedRecord = (record: Record, key: string) => + asRecord(record[key]); + +export let stringByKeys = (record: Record, keys: string[]) => + asString(valueByKeys(record, keys)); + +export let numberByKeys = (record: Record, keys: string[]) => + asNumber(valueByKeys(record, keys)); + +export let booleanByKeys = (record: Record, keys: string[]) => + asBoolean(valueByKeys(record, keys)); + +export let nameFrom = (record: Record) => + stringByKeys(record, [ + 'Name', + 'DisplayName', + 'CustomerName', + 'SupplierName', + 'InvoiceReceiverName' + ]) ?? stringByKeys(nestedRecord(record, 'Info'), ['Name', 'DisplayName']); + +export let idFrom = (record: Record) => + numberByKeys(record, ['ID', 'Id', 'id']); + +let escapeFilterString = (value: string) => value.replace(/'/g, "''"); + +export let stringEqualsFilter = (field: string, value: string | undefined) => + value?.trim() ? `${field} eq '${escapeFilterString(value.trim())}'` : undefined; + +export let numberEqualsFilter = (field: string, value: number | undefined) => + value === undefined ? undefined : `${field} eq ${value}`; + +export let booleanEqualsFilter = (field: string, value: boolean | undefined) => + value === undefined ? undefined : `${field} eq ${value}`; + +export let containsFilter = (field: string, value: string | undefined) => + value?.trim() ? `contains(${field},'${escapeFilterString(value.trim())}')` : undefined; + +export let dateFromFilter = (field: string, value: string | undefined) => + value?.trim() ? `${field} ge '${escapeFilterString(value.trim())}'` : undefined; + +export let dateToFilter = (field: string, value: string | undefined) => + value?.trim() ? `${field} le '${escapeFilterString(value.trim())}'` : undefined; + +export let combineFilters = (filters: Array) => + filters.filter(Boolean).join(' and ') || undefined; + +export let queryParams = ( + input: { + filter?: string; + select?: string; + expand?: string; + top?: number; + skip?: number; + }, + generatedFilter?: string +): UnimicroQueryParams => + pickDefined({ + filter: combineFilters([generatedFilter, input.filter]), + select: input.select, + expand: input.expand, + top: input.top, + skip: input.skip + }); + +export let listMetadata = (items: unknown[], input: { top?: number; skip?: number }) => ({ + returnedCount: items.length, + top: input.top, + skip: input.skip +}); diff --git a/integrations/sparebank-1-regnskap/tsconfig.json b/integrations/sparebank-1-regnskap/tsconfig.json new file mode 100644 index 0000000000..9584b078a4 --- /dev/null +++ b/integrations/sparebank-1-regnskap/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/sparebank-1-regnskap/vitest.config.ts b/integrations/sparebank-1-regnskap/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/sparebank-1-regnskap/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/unimicro/README.md b/integrations/unimicro/README.md new file mode 100644 index 0000000000..a809d692df --- /dev/null +++ b/integrations/unimicro/README.md @@ -0,0 +1,103 @@ +# UniMicro + +Connect to UniMicro accounting and ERP data for Norway-focused workflows. This +integration can list accessible companies, customers, suppliers, customer +invoices, supplier invoices, products, accounts, journal entries, projects, +financial reports, and download UniMicro files through Slate attachments. + +## Authentication + +UniMicro uses OAuth 2.0 / OpenID Connect. Configure the integration with a +UniMicro OAuth application and choose the `test`, `unimicro`, or `custom` +environment. The standard `test` and `unimicro` environments use UniMicro's +documented endpoint discovery URLs. Use `custom` for other UniMicro Platform +hosts such as bank or partner-branded accounting systems, and provide the +custom AppFramework, identity, and files URLs. + +Business tools require a UniMicro `CompanyKey`. Use **List Companies** to +discover accessible companies, then save the selected key in integration config +or provide it per tool call. + +## Tools + +### List Companies + +List companies available to the authenticated user and return their company key +metadata for business API calls. + +### List Customers + +List customer master records with pagination, filtering, selected fields, and +expansion. + +### Get Customer + +Retrieve one customer by UniMicro numeric customer id. + +### List Suppliers + +List supplier master records with pagination, filtering, selected fields, and +expansion. + +### List Customer Invoices + +List customer invoices for accounts receivable visibility, customer invoice +investigation, payment status review, and export workflows. + +### Get Customer Invoice + +Retrieve one customer invoice by UniMicro numeric invoice id. + +### List Supplier Invoices + +List supplier invoices for accounts payable visibility, approval state review, +payment status review, and export workflows. + +### Get Supplier Invoice + +Retrieve one supplier invoice by UniMicro numeric supplier invoice id. + +### List Products + +List products and services for invoice/order setup, product sync, pricing, VAT, +and account mapping workflows. + +### List Accounts + +List chart of accounts records for accounting, invoice coding, journal, and +reporting workflows. + +### List Journal Entries + +List journal entry headers for general ledger audit, voucher lookup, and +accounting export workflows. + +### List Projects + +List projects for dimensional reporting, invoice context, and project +accounting workflows. + +### Get Profit And Loss + +Retrieve the UniMicro `profit-and-loss-periodical` account report. + +### Get Balance Sheet + +Retrieve the UniMicro `balance` account report. + +### Get Trial Balance + +Retrieve the UniMicro `trialbalance` account report. + +### Download File + +Download a UniMicro file through the UniMicro Files endpoint. File content is +returned only as a Slate attachment; structured output contains metadata. + +## License + +This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE). + +
+ Built with ❤️ by Metorial +
diff --git a/integrations/unimicro/docs/SPEC.md b/integrations/unimicro/docs/SPEC.md new file mode 100644 index 0000000000..39cba39bc6 --- /dev/null +++ b/integrations/unimicro/docs/SPEC.md @@ -0,0 +1,75 @@ +# UniMicro Integration Spec + +## Scope + +This package implements a read-first UniMicro accounting and ERP integration. +It targets the concrete first-wave surface from +`docs/exec-plans/active/erp-integration-research/unimicro.md`: + +- company context discovery +- customers and suppliers +- customer invoices and supplier invoices +- products, accounts, journal entries, and projects +- profit and loss, balance sheet, and trial balance report actions +- file downloads as Slate attachments + +Destructive create/update/payment actions are intentionally excluded from this +initial version until live test coverage and exact action semantics are +validated with UniMicro. + +## Authentication And Configuration + +Authentication uses UniMicro OAuth 2.0 authorization code with refresh tokens. +The integration requests OpenID/profile, offline access, AppFramework, invoice, +accounting reporting, and accounting journal scopes. + +Configuration fields: + +- `environment`: `test`, `unimicro`, or `custom` +- `companyKey`: optional default CompanyKey for business API calls +- `customAppFrameworkUrl`, `customIdentityUrl`, `customFilesUrl`: required as + applicable when using `custom` +- `defaultTop`: optional default list page size, capped at 50 + +Standard environment defaults: + +- `test`: `https://test.unimicro.no/`, + `https://test-login.unimicro.no/`, `https://test-files.unimicro.no/` +- `unimicro`: `https://app.unimicro.no/`, + `https://login.unimicro.no/`, `https://files.unimicro.no/` + +The OAuth callback stores resolved AppFramework, identity, and files URLs. The +client also respects the token `AppFramework` claim when present. + +## Query And Pagination + +List tools expose UniMicro's documented query parameters: + +- `top` +- `skip` +- `filter` +- `select` +- `expand` + +Structured filters are conservative and map to documented UniMicro filter +expressions. Advanced callers can provide raw `filter` for provider-specific +queries. + +## Error Handling + +Validation failures and upstream failures are converted to `ServiceError` +through shared Slates helpers. The HTTP client retries 408, 429, 5xx, and common +transient network failures before wrapping the final error. + +## File Outputs + +`download_file` resolves `StorageReference` from a file id when needed, calls +the UniMicro Files endpoint, and returns content via `createBase64Attachment`. +Tool output includes only metadata such as file name, MIME type, byte length, +storage reference, and attachment count. + +## Verification + +Package-local tests cover MCP-compatible top-level object input schemas and +read-only/destructive metadata. Private live E2E coverage lives in +`tests/integrations/unimicro/tools.e2e.ts`. diff --git a/integrations/unimicro/package.json b/integrations/unimicro/package.json new file mode 100644 index 0000000000..452610f7f8 --- /dev/null +++ b/integrations/unimicro/package.json @@ -0,0 +1,22 @@ +{ + "name": "@slates-integrations/unimicro", + "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/unimicro/slate.json b/integrations/unimicro/slate.json new file mode 100644 index 0000000000..ece0030526 --- /dev/null +++ b/integrations/unimicro/slate.json @@ -0,0 +1,15 @@ +{ + "name": "@metorial/unimicro", + "description": "Connect to UniMicro accounting and ERP data. List accessible companies, customers, suppliers, products, customer invoices, supplier invoices, accounts, journal entries, projects, financial reports, and download UniMicro files as Slate attachments.", + "categories": ["accounting", "erp", "finance"], + "skills": [ + "select UniMicro company context", + "sync customer and supplier master data", + "retrieve customer and supplier invoices", + "inspect products and projects", + "list accounts and journal entries", + "retrieve financial reports", + "download UniMicro files as attachments" + ], + "logoUrl": "https://provider-logos.metorial-cdn.com/unimicro.svg" +} diff --git a/integrations/unimicro/src/auth.ts b/integrations/unimicro/src/auth.ts new file mode 100644 index 0000000000..862657addd --- /dev/null +++ b/integrations/unimicro/src/auth.ts @@ -0,0 +1,305 @@ +import { createAxios, normalizeOAuthTokenResponse, requestAxios, SlateAuth } from 'slates'; +import { z } from 'zod'; +import { configSchema } from './config'; +import { + discoverUnimicroEndpoints, + extractJwtStringClaim, + trimTrailingSlash, + type UnimicroAuthOutput +} from './lib/client'; +import { unimicroApiError, unimicroValidationError } from './lib/errors'; + +let authHttp = createAxios({ + headers: { + Accept: 'application/json' + } +}); + +let requestUnimicroAuth = (operation: string, request: () => Promise) => + requestAxios(operation, request as any, unimicroApiError) as Promise; + +let getGrantedScopes = (data: unknown, requestedScopes: string[]) => { + if (typeof data === 'object' && data !== null && 'scope' in data) { + let scope = (data as { scope?: unknown }).scope; + if (typeof scope === 'string' && scope.trim()) { + return scope.trim().split(/\s+/); + } + } + + return requestedScopes; +}; + +let formBody = (input: Record) => { + let body = new URLSearchParams(); + for (let [key, value] of Object.entries(input)) { + if (value !== undefined) body.set(key, value); + } + return body.toString(); +}; + +let resolveIdentityEndpoints = async ( + config: z.infer, + previousOutput?: Partial +) => { + let endpoints = await discoverUnimicroEndpoints(config); + let identityUrl = endpoints.identityUrl ?? previousOutput?.identityUrl; + + if (!identityUrl) { + throw unimicroValidationError( + 'UniMicro identity URL is required. Set environment to test/unimicro or provide customIdentityUrl.' + ); + } + + return { + ...endpoints, + identityUrl + }; +}; + +let buildTokenOutput = async (params: { + data: unknown; + requestedScopes: string[]; + config: z.infer; + previousRefreshToken?: string; + previousOutput?: Partial; + endpoints?: Awaited>; + operation: string; +}): Promise => { + let token = normalizeOAuthTokenResponse(params.data, { + providerLabel: 'UniMicro', + operation: params.operation, + previousRefreshToken: params.previousRefreshToken, + refreshTokenFallbackMode: params.previousRefreshToken ? 'falsy' : undefined, + accessTokenMessage: 'UniMicro OAuth token response did not include an access token.' + }); + let endpoints = params.endpoints ?? (await discoverUnimicroEndpoints(params.config)); + let appFrameworkClaim = extractJwtStringClaim(token.token, 'AppFramework'); + + return { + token: token.token, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt, + tokenType: 'Bearer', + grantedScopes: getGrantedScopes(params.data, params.requestedScopes), + environment: params.config.environment, + appFrameworkUrl: + appFrameworkClaim ?? endpoints.appFrameworkUrl ?? params.previousOutput?.appFrameworkUrl, + identityUrl: endpoints.identityUrl ?? params.previousOutput?.identityUrl, + filesUrl: endpoints.filesUrl ?? params.previousOutput?.filesUrl + }; +}; + +let getProfileFromUserInfo = async (output: UnimicroAuthOutput) => { + if (!output.identityUrl) { + return { + profile: { + id: output.environment, + name: 'UniMicro', + environment: output.environment + } + }; + } + + let response = await requestUnimicroAuth('profile lookup', () => + authHttp.get(`${trimTrailingSlash(output.identityUrl!)}/connect/userinfo`, { + headers: { + Authorization: `Bearer ${output.token}` + } + }) + ); + let data = response.data; + let record = + typeof data === 'object' && data !== null && !Array.isArray(data) + ? (data as Record) + : {}; + let id = + typeof record.sub === 'string' + ? record.sub + : typeof record.email === 'string' + ? record.email + : output.environment; + let name = + typeof record.name === 'string' + ? record.name + : typeof record.preferred_username === 'string' + ? record.preferred_username + : 'UniMicro'; + + return { + profile: { + id, + name, + email: typeof record.email === 'string' ? record.email : undefined, + environment: output.environment, + appFrameworkUrl: output.appFrameworkUrl, + filesUrl: output.filesUrl + } + }; +}; + +export let auth = SlateAuth.create() + .output( + z.object({ + token: z.string().describe('UniMicro OAuth access token.'), + refreshToken: z.string().optional().describe('UniMicro OAuth refresh token.'), + expiresAt: z.string().optional().describe('Access token expiration timestamp.'), + tokenType: z.string().optional().describe('OAuth token type.'), + grantedScopes: z + .array(z.string()) + .optional() + .describe('Scopes returned by UniMicro or requested during authorization.'), + environment: z + .enum(['test', 'unimicro', 'custom']) + .describe('UniMicro platform environment used for this connection.'), + appFrameworkUrl: z.string().optional().describe('Resolved UniMicro AppFramework URL.'), + identityUrl: z.string().optional().describe('Resolved UniMicro identity URL.'), + filesUrl: z.string().optional().describe('Resolved UniMicro file server URL.') + }) + ) + .addOauth({ + type: 'auth.oauth', + name: 'UniMicro OAuth', + key: 'oauth', + docs: [ + { + type: 'docs.auth.oauth', + name: 'UniMicro OAuth authentication', + url: 'https://developer.unimicro.no/guide/authentication/auth-code' + }, + { + type: 'docs.auth.oauth_scopes', + name: 'UniMicro environments and scopes', + url: 'https://developer.unimicro.no/guide/intro/environments' + } + ], + scopes: [ + { + title: 'OpenID', + description: 'Access OpenID identity claims.', + scope: 'openid' + }, + { + title: 'Profile', + description: 'Access profile claims for the authenticated user.', + scope: 'profile' + }, + { + title: 'Offline Access', + description: 'Issue refresh tokens so Slates can refresh UniMicro access tokens.', + scope: 'offline_access' + }, + { + title: 'AppFramework', + description: 'Access UniMicro AppFramework business APIs.', + scope: 'AppFramework' + }, + { + title: 'Sales Invoice', + description: 'Read customer invoice data.', + scope: 'Sales.Invoice' + }, + { + title: 'Accounting Reporting', + description: 'Read reports such as profit and loss, balance, and trial balance.', + scope: 'Accounting.Reporting' + }, + { + title: 'Accounting Journal', + description: 'Read journal entry and account data.', + scope: 'Accounting.Journal' + }, + { + title: 'Read Only', + description: 'Read-only API access where enabled by the UniMicro application.', + scope: 'READ_ONLY', + defaultChecked: false + } + ], + + getAuthorizationUrl: async ctx => { + let parsedConfig = configSchema.parse(ctx.config); + let endpoints = await resolveIdentityEndpoints(parsedConfig); + let params = new URLSearchParams({ + response_type: 'code', + client_id: ctx.clientId, + redirect_uri: ctx.redirectUri, + state: ctx.state, + scope: ctx.scopes.join(' ') + }); + + return { + url: `${trimTrailingSlash(endpoints.identityUrl)}/connect/authorize?${params.toString()}` + }; + }, + + handleCallback: async ctx => { + let parsedConfig = configSchema.parse(ctx.config); + let endpoints = await resolveIdentityEndpoints(parsedConfig); + let response = await requestUnimicroAuth('OAuth token exchange', () => + authHttp.post( + `${trimTrailingSlash(endpoints.identityUrl)}/connect/token`, + formBody({ + grant_type: 'authorization_code', + code: ctx.code, + redirect_uri: ctx.redirectUri, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + ); + + return { + output: await buildTokenOutput({ + data: response.data, + requestedScopes: ctx.scopes, + config: parsedConfig, + endpoints, + operation: 'token exchange' + }) + }; + }, + + handleTokenRefresh: async (ctx: any) => { + if (!ctx.output.refreshToken) { + throw unimicroValidationError('No UniMicro refresh token is available.'); + } + + let parsedConfig = configSchema.parse(ctx.config); + let endpoints = await resolveIdentityEndpoints(parsedConfig, ctx.output); + let response = await requestUnimicroAuth('OAuth token refresh', () => + authHttp.post( + `${trimTrailingSlash(endpoints.identityUrl)}/connect/token`, + formBody({ + grant_type: 'refresh_token', + refresh_token: ctx.output.refreshToken, + client_id: ctx.clientId, + client_secret: ctx.clientSecret + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + ); + + return { + output: await buildTokenOutput({ + data: response.data, + requestedScopes: ctx.output.grantedScopes ?? [], + config: parsedConfig, + previousRefreshToken: ctx.output.refreshToken, + previousOutput: ctx.output, + endpoints, + operation: 'token refresh' + }) + }; + }, + + getProfile: async (ctx: any) => getProfileFromUserInfo(ctx.output) + }); diff --git a/integrations/unimicro/src/config.ts b/integrations/unimicro/src/config.ts new file mode 100644 index 0000000000..479c6c0971 --- /dev/null +++ b/integrations/unimicro/src/config.ts @@ -0,0 +1,50 @@ +import { SlateConfig } from 'slates'; +import { z } from 'zod'; + +export let unimicroEnvironmentSchema = z + .enum(['test', 'unimicro', 'custom']) + .describe( + 'UniMicro platform environment. Use custom for DNB, Eika, Azets, SpareBank 1, or private platform hosts.' + ); + +export type UnimicroEnvironment = z.infer; + +export let configSchema = z.object({ + environment: unimicroEnvironmentSchema, + companyKey: z + .string() + .optional() + .describe( + 'Default UniMicro CompanyKey for business API calls. Use list_companies to discover available company keys.' + ), + customAppFrameworkUrl: z + .string() + .url() + .optional() + .describe( + 'Custom AppFramework/base URL, for example https://system.eikaregnskap.no/. Required when environment is custom unless the OAuth token contains an AppFramework claim.' + ), + customIdentityUrl: z + .string() + .url() + .optional() + .describe( + 'Custom identity/login URL for OAuth when environment is custom, for example https://login.regnskap.sparebank1.no/.' + ), + customFilesUrl: z + .string() + .url() + .optional() + .describe('Custom file server URL when environment is custom.'), + defaultTop: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe('Default page size for UniMicro list tools. Defaults to 50.') +}); + +export type UnimicroConfig = z.infer; + +export let config = SlateConfig.create(configSchema); diff --git a/integrations/unimicro/src/index.ts b/integrations/unimicro/src/index.ts new file mode 100644 index 0000000000..2bac3a87a6 --- /dev/null +++ b/integrations/unimicro/src/index.ts @@ -0,0 +1,43 @@ +import { Slate } from 'slates'; +import { spec } from './spec'; +import { + downloadFile, + getBalanceSheet, + getCustomer, + getCustomerInvoice, + getProfitAndLoss, + getSupplierInvoice, + getTrialBalance, + listAccounts, + listCompanies, + listCustomerInvoices, + listCustomers, + listJournalEntries, + listProducts, + listProjects, + listSupplierInvoices, + listSuppliers +} from './tools'; + +export let provider = Slate.create({ + spec, + tools: [ + listCompanies, + listCustomers, + getCustomer, + listSuppliers, + listCustomerInvoices, + getCustomerInvoice, + listSupplierInvoices, + getSupplierInvoice, + listProducts, + listAccounts, + listJournalEntries, + listProjects, + getProfitAndLoss, + getBalanceSheet, + getTrialBalance, + downloadFile + ], + triggers: [] +}); diff --git a/integrations/unimicro/src/lib/client.ts b/integrations/unimicro/src/lib/client.ts new file mode 100644 index 0000000000..0d39596afa --- /dev/null +++ b/integrations/unimicro/src/lib/client.ts @@ -0,0 +1,528 @@ +import { + createAuthenticatedAxios, + createAxios, + getApiErrorStatus, + getBase64ByteLength, + getResponseHeaderValue, + pickDefined, + requestAxios, + requestAxiosData +} from 'slates'; +import type { UnimicroConfig, UnimicroEnvironment } from '../config'; +import { unimicroApiError, unimicroValidationError } from './errors'; + +export type UnimicroAuthOutput = { + token: string; + refreshToken?: string; + expiresAt?: string; + tokenType?: string; + grantedScopes?: string[]; + environment: UnimicroEnvironment; + appFrameworkUrl?: string; + identityUrl?: string; + filesUrl?: string; +}; + +export type UnimicroEndpoints = { + appFrameworkUrl?: string; + identityUrl?: string; + filesUrl?: string; +}; + +export let defaultEndpoints = { + test: { + appFrameworkUrl: 'https://test.unimicro.no/', + identityUrl: 'https://test-login.unimicro.no/', + filesUrl: 'https://test-files.unimicro.no/' + }, + unimicro: { + appFrameworkUrl: 'https://app.unimicro.no/', + identityUrl: 'https://login.unimicro.no/', + filesUrl: 'https://files.unimicro.no/' + } +} satisfies Record, Required>; + +export let trimTrailingSlash = (value: string) => value.replace(/\/+$/, ''); +let ensureTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`; + +let isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +let endpointValue = (value: unknown) => + typeof value === 'string' && value.trim() ? ensureTrailingSlash(value.trim()) : undefined; + +let baseEndpointDefaults = (config: Partial): UnimicroEndpoints => { + if (config.environment === 'test' || config.environment === 'unimicro') { + return defaultEndpoints[config.environment]; + } + + return { + appFrameworkUrl: endpointValue(config.customAppFrameworkUrl), + identityUrl: endpointValue(config.customIdentityUrl), + filesUrl: endpointValue(config.customFilesUrl) + }; +}; + +let endpointHttp = createAxios({ + headers: { + Accept: 'application/json' + } +}); + +export let discoverUnimicroEndpoints = async ( + config: Partial +): Promise => { + let defaults = baseEndpointDefaults(config); + + if (!defaults.appFrameworkUrl) { + return defaults; + } + + try { + let response = await requestAxios>( + 'discover UniMicro endpoints', + () => endpointHttp.get(`${trimTrailingSlash(defaults.appFrameworkUrl!)}/api/endpoints`), + unimicroApiError + ); + let data = isRecord(response.data) ? response.data : {}; + + return { + appFrameworkUrl: endpointValue(data.AppFramework) ?? defaults.appFrameworkUrl, + identityUrl: endpointValue(data.Identity) ?? defaults.identityUrl, + filesUrl: endpointValue(data.Files) ?? defaults.filesUrl + }; + } catch (error) { + if (defaults.identityUrl || defaults.filesUrl) return defaults; + throw error; + } +}; + +export let resolveAuthEndpoints = async (config: Partial) => { + let endpoints = await discoverUnimicroEndpoints(config); + + if (!endpoints.identityUrl) { + throw unimicroValidationError( + 'UniMicro identity URL is required. Set environment to test/unimicro or provide customIdentityUrl.' + ); + } + + if (!endpoints.appFrameworkUrl) { + throw unimicroValidationError( + 'UniMicro AppFramework URL is required. Set environment to test/unimicro or provide customAppFrameworkUrl.' + ); + } + + return endpoints as Required> & + UnimicroEndpoints; +}; + +export let resolveToolEndpoints = ( + config: Partial, + auth: Partial +) => { + let defaults = baseEndpointDefaults(config); + let appFrameworkUrl = + endpointValue(config.customAppFrameworkUrl) ?? + endpointValue(auth.appFrameworkUrl) ?? + defaults.appFrameworkUrl; + let filesUrl = + endpointValue(config.customFilesUrl) ?? endpointValue(auth.filesUrl) ?? defaults.filesUrl; + + if (!appFrameworkUrl) { + throw unimicroValidationError( + 'UniMicro AppFramework URL is required for API calls. Configure a standard environment or customAppFrameworkUrl.' + ); + } + + return { + appFrameworkUrl, + filesUrl + }; +}; + +export let extractJwtStringClaim = (token: string, claim: string) => { + let [, payload] = token.split('.'); + if (!payload) return undefined; + + try { + let normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + let parsed = JSON.parse(Buffer.from(normalized, 'base64').toString('utf8')); + let value = isRecord(parsed) ? parsed[claim] : undefined; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } catch { + return undefined; + } +}; + +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(child => child !== undefined && child !== null && child !== '') + .join(','); + if (joined) search.append(key, joined); + continue; + } + + search.append(key, String(value)); + } + + return search.toString(); +}; + +let retryDelay = (attempt: number) => + new Promise(resolve => setTimeout(resolve, attempt * 500)); + +let isRetryable = (error: unknown) => { + let status = getApiErrorStatus(error); + if (status === 408 || status === 429) return true; + if (typeof status === 'number' && status >= 500) return true; + if (isRecord(error) && typeof error.code === 'string') { + return ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(error.code); + } + return false; +}; + +let toBuffer = (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, 'utf8'); + return Buffer.from(JSON.stringify(value ?? null)); +}; + +let contentDispositionFileName = (value: string | undefined) => { + let match = value?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i); + return match?.[1] ? decodeURIComponent(match[1]) : undefined; +}; + +export let compact = >(value: T) => + Object.fromEntries( + Object.entries(value).filter(([, child]) => child !== undefined) + ) as Partial; + +export let escapeFilterString = (value: string) => value.replace(/'/g, "''"); + +export let combineFilters = (...filters: (string | undefined)[]) => { + let active = filters.filter((filter): filter is string => Boolean(filter?.trim())); + if (active.length === 0) return undefined; + if (active.length === 1) return active[0]; + return active.map(filter => `(${filter})`).join(' and '); +}; + +let listFromResponse = (value: unknown, operation: string) => { + if (Array.isArray(value)) return value as T[]; + if (isRecord(value) && Array.isArray(value.Items)) return value.Items as T[]; + if (isRecord(value) && Array.isArray(value.Value)) return value.Value as T[]; + if (value === null || value === undefined) return []; + + throw unimicroValidationError(`UniMicro ${operation} did not return a list.`); +}; + +let companyListFromResponse = (value: unknown) => { + if (isRecord(value) && (value.Key ?? value.CompanyKey ?? value.ID ?? value.Name)) { + return [value]; + } + + return listFromResponse>(value, 'list companies'); +}; + +export type ListQueryInput = { + top?: number; + skip?: number; + filter?: string; + select?: string; + expand?: string; +}; + +export let listParams = ( + input: ListQueryInput, + defaults: { top?: number; filter?: string } = {} +) => + pickDefined({ + top: input.top ?? defaults.top ?? 50, + skip: input.skip, + filter: combineFilters(input.filter, defaults.filter), + select: input.select, + expand: input.expand + }); + +export let pageInfo = (input: ListQueryInput, count: number, defaultTop = 50) => { + let top = input.top ?? defaultTop; + let skip = input.skip ?? 0; + + return { + top, + skip, + count, + nextSkip: count >= top ? skip + count : undefined + }; +}; + +export class UnimicroClient { + private appFrameworkUrl: string; + private filesUrl?: string; + private token: string; + private companyKey?: string; + private bizHttp; + private platformHttp; + private filesHttp; + + constructor(params: { + auth: UnimicroAuthOutput; + config: Partial; + companyKey?: string; + }) { + let endpoints = resolveToolEndpoints(params.config, params.auth); + this.appFrameworkUrl = endpoints.appFrameworkUrl; + this.filesUrl = endpoints.filesUrl; + this.token = params.auth.token; + this.companyKey = params.companyKey ?? params.config.companyKey; + + this.bizHttp = createAuthenticatedAxios({ + baseURL: `${trimTrailingSlash(this.appFrameworkUrl)}/api/biz`, + authHeader: { value: `Bearer ${this.token}` }, + headers: this.companyKey + ? { + Accept: 'application/json', + CompanyKey: this.companyKey + } + : { + Accept: 'application/json' + }, + paramsSerializer: { serialize: serializeParams } + }); + + this.platformHttp = createAuthenticatedAxios({ + baseURL: trimTrailingSlash(this.appFrameworkUrl), + authHeader: { value: `Bearer ${this.token}` }, + headers: { + Accept: 'application/json' + }, + paramsSerializer: { serialize: serializeParams } + }); + + this.filesHttp = this.filesUrl + ? createAuthenticatedAxios({ + baseURL: trimTrailingSlash(this.filesUrl), + authHeader: { value: `Bearer ${this.token}` }, + paramsSerializer: { serialize: serializeParams } + }) + : undefined; + } + + private requireCompanyKey() { + if (!this.companyKey) { + throw unimicroValidationError( + 'UniMicro CompanyKey is required for this tool. Provide companyKey in the tool input or integration config.' + ); + } + + return this.companyKey; + } + + private async withRetries(request: () => Promise) { + let attempt = 0; + + while (true) { + try { + return await request(); + } catch (error) { + attempt += 1; + if (attempt >= 3 || !isRetryable(error)) throw error; + await retryDelay(attempt); + } + } + } + + private requestData(operation: string, request: () => Promise) { + return requestAxiosData(operation, () => this.withRetries(request), unimicroApiError); + } + + private requestResponse(operation: string, request: () => Promise) { + return requestAxios(operation, () => this.withRetries(request), unimicroApiError); + } + + private async getArray( + operation: string, + path: string, + params?: Record + ) { + this.requireCompanyKey(); + let data = await this.requestData(operation, () => + this.bizHttp.get(path, { params }) + ); + + return listFromResponse(data, operation); + } + + private getRecord>( + operation: string, + path: string, + params?: Record + ) { + this.requireCompanyKey(); + return this.requestData(operation, () => this.bizHttp.get(path, { params })); + } + + async listCompanies() { + let data = await this.requestData('list companies', () => + this.platformHttp.get('/api/init/companies') + ); + + return companyListFromResponse(data); + } + + listCustomers(params: Record) { + return this.getArray>('list customers', '/customers', params); + } + + getCustomer(id: number, params?: Record) { + return this.getRecord>('get customer', `/customers/${id}`, params); + } + + listSuppliers(params: Record) { + return this.getArray>('list suppliers', '/suppliers', params); + } + + listCustomerInvoices(params: Record) { + return this.getArray>( + 'list customer invoices', + '/invoices', + params + ); + } + + getCustomerInvoice(id: number, params?: Record) { + return this.getRecord>( + 'get customer invoice', + `/invoices/${id}`, + params + ); + } + + listSupplierInvoices(params: Record) { + return this.getArray>( + 'list supplier invoices', + '/supplierinvoices', + params + ); + } + + getSupplierInvoice(id: number, params?: Record) { + return this.getRecord>( + 'get supplier invoice', + `/supplierinvoices/${id}`, + params + ); + } + + listProducts(params: Record) { + return this.getArray>('list products', '/products', params); + } + + listAccounts(params: Record) { + return this.getArray>('list accounts', '/accounts', params); + } + + listJournalEntries(params: Record) { + return this.getArray>( + 'list journal entries', + '/journalentries', + params + ); + } + + listProjects(params: Record) { + return this.getArray>('list projects', '/projects', params); + } + + getProfitAndLoss(params: Record) { + return this.getRecord>( + 'get profit and loss', + '/accounts?action=profit-and-loss-periodical', + params + ); + } + + getBalanceSheet(params: Record) { + return this.getRecord>( + 'get balance sheet', + '/accounts?action=balance', + params + ); + } + + getTrialBalance(params: Record) { + return this.getRecord>( + 'get trial balance', + '/accounts?action=trialbalance', + params + ); + } + + getFile(id: number) { + return this.getRecord>('get file metadata', `/files/${id}`); + } + + async downloadFile(params: { + fileId?: number; + storageReference?: string; + fileName?: string; + mimeType?: string; + }) { + let companyKey = this.requireCompanyKey(); + let file = params.fileId ? await this.getFile(params.fileId) : undefined; + let storageReference = + params.storageReference ?? + (file && typeof file.StorageReference === 'string' ? file.StorageReference : undefined); + + if (!storageReference) { + throw unimicroValidationError( + 'Provide storageReference or a fileId with StorageReference.' + ); + } + + if (!this.filesHttp) { + throw unimicroValidationError( + 'UniMicro files URL is required to download files. Configure a standard environment or customFilesUrl.' + ); + } + + let response = await this.requestResponse('download file', () => + this.filesHttp!.get('/api/download', { + params: { + id: storageReference, + key: companyKey, + token: this.token + }, + responseType: 'arraybuffer' + }) + ); + let buffer = toBuffer(response.data); + let headerMimeType = getResponseHeaderValue(response.headers, 'content-type'); + let headerFileName = contentDispositionFileName( + getResponseHeaderValue(response.headers, 'content-disposition') + ); + let metadataName = file && typeof file.Name === 'string' ? file.Name : undefined; + let metadataMimeType = + file && typeof file.ContentType === 'string' ? file.ContentType : undefined; + + return { + contentBase64: buffer.toString('base64'), + byteLength: buffer.byteLength, + attachmentByteLength: getBase64ByteLength(buffer.toString('base64')), + mimeType: + params.mimeType ?? metadataMimeType ?? headerMimeType ?? 'application/octet-stream', + fileName: params.fileName ?? metadataName ?? headerFileName ?? storageReference, + storageReference, + file + }; + } +} diff --git a/integrations/unimicro/src/lib/errors.ts b/integrations/unimicro/src/lib/errors.ts new file mode 100644 index 0000000000..23faac0181 --- /dev/null +++ b/integrations/unimicro/src/lib/errors.ts @@ -0,0 +1,20 @@ +import { buildApiServiceError, createApiServiceError } from 'slates'; + +export let unimicroValidationError = (message: string) => + createApiServiceError(message, { reason: 'unimicro_validation_error' }); + +export let unimicroApiError = (error: unknown, operation = 'request') => + buildApiServiceError(error, { + providerLabel: 'UniMicro', + operation, + reason: 'unimicro_api_error', + detailKeys: [ + 'title', + 'detail', + 'message', + 'error', + 'error_description', + 'code', + 'ExceptionMessage' + ] + }); diff --git a/integrations/unimicro/src/spec.ts b/integrations/unimicro/src/spec.ts new file mode 100644 index 0000000000..809d43982f --- /dev/null +++ b/integrations/unimicro/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: 'unimicro', + name: 'UniMicro', + description: + 'Norwegian cloud accounting and ERP platform for company context, customers, suppliers, products, invoices, accounts, journal entries, projects, financial reports, and file downloads.', + metadata: {}, + config, + auth +}); diff --git a/integrations/unimicro/src/tools.schema.test.ts b/integrations/unimicro/src/tools.schema.test.ts new file mode 100644 index 0000000000..931f09ae5d --- /dev/null +++ b/integrations/unimicro/src/tools.schema.test.ts @@ -0,0 +1,47 @@ +import { describeMcpCompatibleToolSchemas } from '@slates/test'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { provider } from './index'; + +describeMcpCompatibleToolSchemas('UniMicro tool input schemas', provider.actions); + +let readOnlyToolIds = [ + 'list_companies', + 'list_customers', + 'get_customer', + 'list_suppliers', + 'list_customer_invoices', + 'get_customer_invoice', + 'list_supplier_invoices', + 'get_supplier_invoice', + 'list_products', + 'list_accounts', + 'list_journal_entries', + 'list_projects', + 'get_profit_and_loss', + 'get_balance_sheet', + 'get_trial_balance', + 'download_file' +]; + +describe('UniMicro tool metadata', () => { + it('marks first-wave tools read-only and non-destructive', () => { + 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 + ); + } + }); + + it('matches the documented SumAllYears query parameter type', () => { + let action = provider.actions.find(child => child.key === 'get_profit_and_loss'); + let inputSchema = z.toJSONSchema(action?.inputSchema ?? z.object({})) as { + properties?: Record; + }; + + expect(inputSchema.properties?.sumAllYears?.type).toBe('string'); + }); +}); diff --git a/integrations/unimicro/src/tools/index.ts b/integrations/unimicro/src/tools/index.ts new file mode 100644 index 0000000000..a01742b37c --- /dev/null +++ b/integrations/unimicro/src/tools/index.ts @@ -0,0 +1 @@ +export * from './read'; diff --git a/integrations/unimicro/src/tools/read.test.ts b/integrations/unimicro/src/tools/read.test.ts new file mode 100644 index 0000000000..3545883cf5 --- /dev/null +++ b/integrations/unimicro/src/tools/read.test.ts @@ -0,0 +1,88 @@ +import { ServiceError } from '@lowerdeck/error'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { UnimicroClient } from '../lib/client'; +import { downloadFile } from './read'; + +let baseContext = { + auth: { + token: 'access-token', + environment: 'test' + }, + config: { + environment: 'test', + defaultTop: 50 + } +}; + +describe('download_file', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns downloaded file bytes only as a Slate attachment', async () => { + vi.spyOn(UnimicroClient.prototype, 'downloadFile').mockResolvedValue({ + contentBase64: 'SGVsbG8=', + byteLength: 5, + attachmentByteLength: 5, + mimeType: 'text/plain', + fileName: 'hello.txt', + storageReference: 'storage-reference', + file: { + ID: 123, + Name: 'hello.txt', + StorageReference: 'storage-reference' + } + }); + + let result = await downloadFile.handleInvocation({ + ...baseContext, + input: { + companyKey: 'company-key', + fileId: 123 + } + } as any); + + expect(UnimicroClient.prototype.downloadFile).toHaveBeenCalledWith({ + fileId: 123, + storageReference: undefined, + fileName: undefined, + mimeType: undefined + }); + expect(result.output).toEqual({ + fileId: 123, + storageReference: 'storage-reference', + fileName: 'hello.txt', + mimeType: 'text/plain', + byteLength: 5, + attachmentCount: 1, + file: { + ID: 123, + Name: 'hello.txt', + StorageReference: 'storage-reference' + } + }); + expect(result.output).not.toHaveProperty('contentBase64'); + expect(result.output).not.toHaveProperty('fileContent'); + expect(result.attachments).toEqual([ + { + mimeType: 'text/plain', + content: { + type: 'content', + encoding: 'base64', + content: 'SGVsbG8=' + } + } + ]); + }); + + it('throws ServiceError when neither fileId nor storageReference is provided', async () => { + await expect( + downloadFile.handleInvocation({ + ...baseContext, + input: { + companyKey: 'company-key' + } + } as any) + ).rejects.toBeInstanceOf(ServiceError); + }); +}); diff --git a/integrations/unimicro/src/tools/read.ts b/integrations/unimicro/src/tools/read.ts new file mode 100644 index 0000000000..b8f428187a --- /dev/null +++ b/integrations/unimicro/src/tools/read.ts @@ -0,0 +1,1052 @@ +import { createBase64Attachment, SlateTool } from 'slates'; +import { z } from 'zod'; +import { combineFilters, escapeFilterString, listParams, pageInfo } from '../lib/client'; +import { spec } from '../spec'; +import { + arrayValue, + booleanValue, + compactOutput, + companyKeyInputSchema, + createClient, + expandInputSchema, + filterInputSchema, + idInputSchema, + includeDeletedInputSchema, + numberFromKeys, + numberValue, + pageInfoSchema, + rawRecordSchema, + recordValue, + requireAtLeastOne, + selectInputSchema, + skipInputSchema, + stringFromKeys, + stringValue, + type ToolContext, + topInputSchema, + unknownString, + updatedSinceInputSchema +} from './shared'; + +let companySchema = z.object({ + id: z.number().optional(), + companyKey: z.string().optional(), + name: z.string().optional(), + organizationNumber: z.string().optional(), + isTest: z.boolean().optional(), + record: rawRecordSchema +}); + +let customerSchema = z.object({ + id: z.number().optional(), + customerNumber: z.number().optional(), + name: z.string().optional(), + organizationNumber: z.string().optional(), + statusCode: z.number().optional(), + deleted: z.boolean().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + record: rawRecordSchema +}); + +let supplierSchema = z.object({ + id: z.number().optional(), + supplierNumber: z.number().optional(), + name: z.string().optional(), + organizationNumber: z.string().optional(), + statusCode: z.number().optional(), + deleted: z.boolean().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + record: rawRecordSchema +}); + +let invoiceSchema = z.object({ + id: z.number().optional(), + invoiceNumber: z.string().optional(), + customerId: z.number().optional(), + customerName: z.string().optional(), + supplierId: z.number().optional(), + supplierName: z.string().optional(), + invoiceDate: z.string().optional(), + paymentDueDate: z.string().optional(), + statusCode: z.number().optional(), + paymentStatus: z.number().optional(), + taxExclusiveAmount: z.number().optional(), + taxInclusiveAmount: z.number().optional(), + restAmount: z.number().optional(), + currencyCodeId: z.number().optional(), + itemCount: z.number().optional(), + deleted: z.boolean().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + record: rawRecordSchema +}); + +let productSchema = z.object({ + id: z.number().optional(), + partName: z.string().optional(), + name: z.string().optional(), + externalProductNumber: z.string().optional(), + unit: z.string().optional(), + accountId: z.number().optional(), + vatTypeId: z.number().optional(), + priceExVat: z.number().optional(), + priceIncVat: z.number().optional(), + statusCode: z.number().optional(), + deleted: z.boolean().optional(), + updatedAt: z.string().optional(), + record: rawRecordSchema +}); + +let accountSchema = z.object({ + id: z.number().optional(), + accountId: z.number().optional(), + accountNumber: z.number().optional(), + accountName: z.string().optional(), + active: z.boolean().optional(), + visible: z.boolean().optional(), + systemAccount: z.boolean().optional(), + locked: z.boolean().optional(), + deleted: z.boolean().optional(), + statusCode: z.number().optional(), + updatedAt: z.string().optional(), + record: rawRecordSchema +}); + +let journalEntrySchema = z.object({ + id: z.number().optional(), + journalEntryNumber: z.string().optional(), + journalEntryNumberNumeric: z.number().optional(), + description: z.string().optional(), + financialYearId: z.number().optional(), + statusCode: z.number().optional(), + lineCount: z.number().optional(), + deleted: z.boolean().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + record: rawRecordSchema +}); + +let projectSchema = z.object({ + id: z.number().optional(), + projectNumber: z.string().optional(), + name: z.string().optional(), + projectCustomerId: z.number().optional(), + projectLeadName: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + statusCode: z.number().optional(), + visible: z.boolean().optional(), + deleted: z.boolean().optional(), + updatedAt: z.string().optional(), + record: rawRecordSchema +}); + +let reportSchema = z.object({ + report: z.any().describe('Raw UniMicro accounting report payload.') +}); + +let commonListInput = { + companyKey: companyKeyInputSchema, + top: topInputSchema, + skip: skipInputSchema, + filter: filterInputSchema, + select: selectInputSchema, + expand: expandInputSchema, + updatedSince: updatedSinceInputSchema, + includeDeleted: includeDeletedInputSchema +}; + +let commonGetInput = { + companyKey: companyKeyInputSchema, + expand: expandInputSchema, + select: selectInputSchema +}; + +let deletedFilter = (includeDeleted: boolean | undefined) => + includeDeleted === true ? undefined : 'Deleted eq false'; + +let updatedSinceFilter = (value: string | undefined) => + value ? `UpdatedAt ge '${escapeFilterString(value)}'` : undefined; + +let containsFilter = (field: string, value: string | undefined) => + value ? `contains(${field},'${escapeFilterString(value)}')` : undefined; + +let eqNumberFilter = (field: string, value: number | undefined) => + value === undefined ? undefined : `${field} eq ${value}`; + +let eqStringFilter = (field: string, value: string | undefined) => + value ? `${field} eq '${escapeFilterString(value)}'` : undefined; + +let dateRangeFilter = (field: string, from?: string, to?: string) => + combineFilters( + from ? `${field} ge '${escapeFilterString(from)}'` : undefined, + to ? `${field} le '${escapeFilterString(to)}'` : undefined + ); + +let page = (ctx: ToolContext, count: number) => + pageInfo(ctx.input, count, ctx.config.defaultTop ?? 50); + +let listQuery = (ctx: ToolContext, filter?: string) => + listParams(ctx.input, { + top: ctx.config.defaultTop, + filter + }); + +let mapCompany = (record: Record) => ({ + ...compactOutput({ + id: numberFromKeys(record, ['ID', 'Id', 'CompanyID', 'CompanyId', 'id']), + companyKey: stringFromKeys(record, ['CompanyKey', 'Key', 'companyKey', 'key']), + name: stringFromKeys(record, ['Name', 'CompanyName', 'name']), + organizationNumber: stringFromKeys(record, [ + 'OrgNumber', + 'OrganizationNumber', + 'organizationNumber' + ]), + isTest: + booleanValue(record, 'IsTest') ?? + booleanValue(record, 'isTest') ?? + booleanValue(record, 'Test') + }), + record +}); + +let relationName = (record: Record) => + stringValue(recordValue(record, 'Info') ?? {}, 'Name') ?? + stringValue(record, 'Name') ?? + stringValue(record, 'CustomerName') ?? + stringValue(record, 'SupplierName'); + +let mapCustomer = (record: Record) => ({ + ...compactOutput({ + id: numberValue(record, 'ID'), + customerNumber: numberValue(record, 'CustomerNumber'), + name: relationName(record), + organizationNumber: stringValue(record, 'OrgNumber'), + statusCode: numberValue(record, 'StatusCode'), + deleted: booleanValue(record, 'Deleted'), + createdAt: stringValue(record, 'CreatedAt'), + updatedAt: stringValue(record, 'UpdatedAt') + }), + record +}); + +let mapSupplier = (record: Record) => ({ + ...compactOutput({ + id: numberValue(record, 'ID'), + supplierNumber: numberValue(record, 'SupplierNumber'), + name: relationName(record), + organizationNumber: stringValue(record, 'OrgNumber'), + statusCode: numberValue(record, 'StatusCode'), + deleted: booleanValue(record, 'Deleted'), + createdAt: stringValue(record, 'CreatedAt'), + updatedAt: stringValue(record, 'UpdatedAt') + }), + record +}); + +let mapSupplierName = (record: Record) => { + let supplier = recordValue(record, 'Supplier'); + return supplier ? relationName(supplier) : stringValue(record, 'SupplierName'); +}; + +let mapInvoice = (record: Record) => ({ + ...compactOutput({ + id: numberValue(record, 'ID'), + invoiceNumber: unknownString(record.InvoiceNumber), + customerId: numberValue(record, 'CustomerID'), + customerName: stringValue(record, 'CustomerName'), + supplierId: numberValue(record, 'SupplierID'), + supplierName: mapSupplierName(record), + invoiceDate: stringValue(record, 'InvoiceDate'), + paymentDueDate: stringValue(record, 'PaymentDueDate'), + statusCode: numberValue(record, 'StatusCode'), + paymentStatus: numberValue(record, 'PaymentStatus'), + taxExclusiveAmount: numberValue(record, 'TaxExclusiveAmount'), + taxInclusiveAmount: numberValue(record, 'TaxInclusiveAmount'), + restAmount: numberValue(record, 'RestAmount'), + currencyCodeId: numberValue(record, 'CurrencyCodeID'), + itemCount: arrayValue(record, 'Items')?.length, + deleted: booleanValue(record, 'Deleted'), + createdAt: stringValue(record, 'CreatedAt'), + updatedAt: stringValue(record, 'UpdatedAt') + }), + record +}); + +let mapProduct = (record: Record) => ({ + ...compactOutput({ + id: numberValue(record, 'ID'), + partName: stringValue(record, 'PartName'), + name: stringValue(record, 'Name'), + externalProductNumber: stringValue(record, 'ExternalProductNumber'), + unit: stringValue(record, 'Unit'), + accountId: numberValue(record, 'AccountID'), + vatTypeId: numberValue(record, 'VatTypeID'), + priceExVat: numberValue(record, 'PriceExVat'), + priceIncVat: numberValue(record, 'PriceIncVat'), + statusCode: numberValue(record, 'StatusCode'), + deleted: booleanValue(record, 'Deleted'), + updatedAt: stringValue(record, 'UpdatedAt') + }), + record +}); + +let mapAccount = (record: Record) => ({ + ...compactOutput({ + id: numberValue(record, 'ID'), + accountId: numberValue(record, 'AccountID'), + accountNumber: numberValue(record, 'AccountNumber'), + accountName: stringValue(record, 'AccountName'), + active: booleanValue(record, 'Active'), + visible: booleanValue(record, 'Visible'), + systemAccount: booleanValue(record, 'SystemAccount'), + locked: booleanValue(record, 'Locked'), + deleted: booleanValue(record, 'Deleted'), + statusCode: numberValue(record, 'StatusCode'), + updatedAt: stringValue(record, 'UpdatedAt') + }), + record +}); + +let mapJournalEntry = (record: Record) => ({ + ...compactOutput({ + id: numberValue(record, 'ID'), + journalEntryNumber: stringValue(record, 'JournalEntryNumber'), + journalEntryNumberNumeric: numberValue(record, 'JournalEntryNumberNumeric'), + description: stringValue(record, 'Description'), + financialYearId: numberValue(record, 'FinancialYearID'), + statusCode: numberValue(record, 'StatusCode'), + lineCount: arrayValue(record, 'Lines')?.length ?? arrayValue(record, 'DraftLines')?.length, + deleted: booleanValue(record, 'Deleted'), + createdAt: stringValue(record, 'CreatedAt'), + updatedAt: stringValue(record, 'UpdatedAt') + }), + record +}); + +let mapProject = (record: Record) => ({ + ...compactOutput({ + id: numberValue(record, 'ID'), + projectNumber: unknownString(record.ProjectNumber), + name: stringValue(record, 'Name'), + projectCustomerId: numberValue(record, 'ProjectCustomerID'), + projectLeadName: stringValue(record, 'ProjectLeadName'), + startDate: stringValue(record, 'StartDate'), + endDate: stringValue(record, 'EndDate'), + statusCode: numberValue(record, 'StatusCode'), + visible: booleanValue(record, 'Visible'), + deleted: booleanValue(record, 'Deleted'), + updatedAt: stringValue(record, 'UpdatedAt') + }), + record +}); + +let financialYearInput = z + .number() + .int() + .optional() + .describe('UniMicro FinancialYear query parameter for this report action.'); + +export let listCompanies = SlateTool.create(spec, { + name: 'List Companies', + key: 'list_companies', + description: + 'List companies available to the authenticated UniMicro user so callers can choose the CompanyKey required by business tools.', + tags: { + destructive: false, + readOnly: true + } +}) + .input(z.object({})) + .output( + z.object({ + companies: z.array(companySchema), + count: z.number() + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let companies = (await createClient(ctx).listCompanies()).map(mapCompany); + + return { + output: { + companies, + count: companies.length + }, + message: `Found **${companies.length}** UniMicro companies.` + }; + }) + .build(); + +export let listCustomers = SlateTool.create(spec, { + name: 'List Customers', + key: 'list_customers', + description: + 'List UniMicro customer master records for CRM, billing, accounting sync, and invoice lookup workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + customerNumber: z.number().int().optional().describe('Filter by CustomerNumber.'), + organizationNumber: z.string().optional().describe('Filter by OrgNumber.'), + statusCode: z.number().int().optional().describe('Filter by customer StatusCode.'), + nameContains: z + .string() + .optional() + .describe('Filter with contains(Info.Name, value). Use raw filter if unsupported.') + }) + ) + .output( + z.object({ + customers: z.array(customerSchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqNumberFilter('CustomerNumber', ctx.input.customerNumber), + eqStringFilter('OrgNumber', ctx.input.organizationNumber), + eqNumberFilter('StatusCode', ctx.input.statusCode), + containsFilter('Info.Name', ctx.input.nameContains) + ); + let customers = (await createClient(ctx).listCustomers(listQuery(ctx, filter))).map( + mapCustomer + ); + + return { + output: { customers, page: page(ctx, customers.length) }, + message: `Found **${customers.length}** UniMicro customers.` + }; + }) + .build(); + +export let getCustomer = SlateTool.create(spec, { + name: 'Get Customer', + key: 'get_customer', + description: 'Get one UniMicro customer by numeric customer id.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonGetInput, + id: idInputSchema + }) + ) + .output( + z.object({ + customer: customerSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let customer = mapCustomer( + await createClient(ctx).getCustomer(ctx.input.id, { + expand: ctx.input.expand, + select: ctx.input.select + }) + ); + + return { + output: { customer }, + message: `Retrieved UniMicro customer **${ctx.input.id}**.` + }; + }) + .build(); + +export let listSuppliers = SlateTool.create(spec, { + name: 'List Suppliers', + key: 'list_suppliers', + description: + 'List UniMicro supplier master records for procurement, accounts payable, and supplier invoice workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + supplierNumber: z.number().int().optional().describe('Filter by SupplierNumber.'), + organizationNumber: z.string().optional().describe('Filter by OrgNumber.'), + statusCode: z.number().int().optional().describe('Filter by supplier StatusCode.'), + nameContains: z + .string() + .optional() + .describe('Filter with contains(Info.Name, value). Use raw filter if unsupported.') + }) + ) + .output( + z.object({ + suppliers: z.array(supplierSchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqNumberFilter('SupplierNumber', ctx.input.supplierNumber), + eqStringFilter('OrgNumber', ctx.input.organizationNumber), + eqNumberFilter('StatusCode', ctx.input.statusCode), + containsFilter('Info.Name', ctx.input.nameContains) + ); + let suppliers = (await createClient(ctx).listSuppliers(listQuery(ctx, filter))).map( + mapSupplier + ); + + return { + output: { suppliers, page: page(ctx, suppliers.length) }, + message: `Found **${suppliers.length}** UniMicro suppliers.` + }; + }) + .build(); + +export let listCustomerInvoices = SlateTool.create(spec, { + name: 'List Customer Invoices', + key: 'list_customer_invoices', + description: + 'List UniMicro customer invoices for AR visibility, customer invoice investigation, payment status review, and export workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + customerId: z.number().int().optional().describe('Filter by CustomerID.'), + invoiceNumber: z.string().optional().describe('Filter by InvoiceNumber.'), + statusCode: z.number().int().optional().describe('Filter by invoice StatusCode.'), + invoiceDateFrom: z.string().optional().describe('Filter InvoiceDate greater/equal.'), + invoiceDateTo: z.string().optional().describe('Filter InvoiceDate less/equal.'), + paymentDueDateFrom: z + .string() + .optional() + .describe('Filter PaymentDueDate greater/equal.'), + paymentDueDateTo: z.string().optional().describe('Filter PaymentDueDate less/equal.') + }) + ) + .output( + z.object({ + invoices: z.array(invoiceSchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqNumberFilter('CustomerID', ctx.input.customerId), + eqStringFilter('InvoiceNumber', ctx.input.invoiceNumber), + eqNumberFilter('StatusCode', ctx.input.statusCode), + dateRangeFilter('InvoiceDate', ctx.input.invoiceDateFrom, ctx.input.invoiceDateTo), + dateRangeFilter( + 'PaymentDueDate', + ctx.input.paymentDueDateFrom, + ctx.input.paymentDueDateTo + ) + ); + let invoices = (await createClient(ctx).listCustomerInvoices(listQuery(ctx, filter))).map( + mapInvoice + ); + + return { + output: { invoices, page: page(ctx, invoices.length) }, + message: `Found **${invoices.length}** UniMicro customer invoices.` + }; + }) + .build(); + +export let getCustomerInvoice = SlateTool.create(spec, { + name: 'Get Customer Invoice', + key: 'get_customer_invoice', + description: 'Get one UniMicro customer invoice by numeric invoice id.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonGetInput, + id: idInputSchema + }) + ) + .output( + z.object({ + invoice: invoiceSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let invoice = mapInvoice( + await createClient(ctx).getCustomerInvoice(ctx.input.id, { + expand: ctx.input.expand, + select: ctx.input.select + }) + ); + + return { + output: { invoice }, + message: `Retrieved UniMicro customer invoice **${ctx.input.id}**.` + }; + }) + .build(); + +export let listSupplierInvoices = SlateTool.create(spec, { + name: 'List Supplier Invoices', + key: 'list_supplier_invoices', + description: + 'List UniMicro supplier invoices for AP visibility, approval state review, payment status review, and export workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + supplierId: z.number().int().optional().describe('Filter by SupplierID.'), + invoiceNumber: z.string().optional().describe('Filter by InvoiceNumber.'), + statusCode: z + .number() + .int() + .optional() + .describe('Filter by supplier invoice StatusCode.'), + paymentStatus: z.number().int().optional().describe('Filter by PaymentStatus.'), + invoiceDateFrom: z.string().optional().describe('Filter InvoiceDate greater/equal.'), + invoiceDateTo: z.string().optional().describe('Filter InvoiceDate less/equal.'), + paymentDueDateFrom: z + .string() + .optional() + .describe('Filter PaymentDueDate greater/equal.'), + paymentDueDateTo: z.string().optional().describe('Filter PaymentDueDate less/equal.') + }) + ) + .output( + z.object({ + invoices: z.array(invoiceSchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqNumberFilter('SupplierID', ctx.input.supplierId), + eqStringFilter('InvoiceNumber', ctx.input.invoiceNumber), + eqNumberFilter('StatusCode', ctx.input.statusCode), + eqNumberFilter('PaymentStatus', ctx.input.paymentStatus), + dateRangeFilter('InvoiceDate', ctx.input.invoiceDateFrom, ctx.input.invoiceDateTo), + dateRangeFilter( + 'PaymentDueDate', + ctx.input.paymentDueDateFrom, + ctx.input.paymentDueDateTo + ) + ); + let invoices = (await createClient(ctx).listSupplierInvoices(listQuery(ctx, filter))).map( + mapInvoice + ); + + return { + output: { invoices, page: page(ctx, invoices.length) }, + message: `Found **${invoices.length}** UniMicro supplier invoices.` + }; + }) + .build(); + +export let getSupplierInvoice = SlateTool.create(spec, { + name: 'Get Supplier Invoice', + key: 'get_supplier_invoice', + description: 'Get one UniMicro supplier invoice by numeric supplier invoice id.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonGetInput, + id: idInputSchema + }) + ) + .output( + z.object({ + invoice: invoiceSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let invoice = mapInvoice( + await createClient(ctx).getSupplierInvoice(ctx.input.id, { + expand: ctx.input.expand, + select: ctx.input.select + }) + ); + + return { + output: { invoice }, + message: `Retrieved UniMicro supplier invoice **${ctx.input.id}**.` + }; + }) + .build(); + +export let listProducts = SlateTool.create(spec, { + name: 'List Products', + key: 'list_products', + description: + 'List UniMicro products and services for invoice/order setup, product sync, pricing, VAT, and account mapping workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + partName: z.string().optional().describe('Filter by PartName/product number.'), + externalProductNumber: z + .string() + .optional() + .describe('Filter by ExternalProductNumber.'), + accountId: z.number().int().optional().describe('Filter by AccountID.'), + statusCode: z.number().int().optional().describe('Filter by product StatusCode.'), + nameContains: z.string().optional().describe('Filter with contains(Name, value).') + }) + ) + .output( + z.object({ + products: z.array(productSchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqStringFilter('PartName', ctx.input.partName), + eqStringFilter('ExternalProductNumber', ctx.input.externalProductNumber), + eqNumberFilter('AccountID', ctx.input.accountId), + eqNumberFilter('StatusCode', ctx.input.statusCode), + containsFilter('Name', ctx.input.nameContains) + ); + let products = (await createClient(ctx).listProducts(listQuery(ctx, filter))).map( + mapProduct + ); + + return { + output: { products, page: page(ctx, products.length) }, + message: `Found **${products.length}** UniMicro products.` + }; + }) + .build(); + +export let listAccounts = SlateTool.create(spec, { + name: 'List Accounts', + key: 'list_accounts', + description: + 'List UniMicro chart of accounts records for accounting, invoice coding, journal, and reporting workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + accountNumber: z.number().int().optional().describe('Filter by AccountNumber.'), + accountNameContains: z + .string() + .optional() + .describe('Filter with contains(AccountName, value).'), + active: z.boolean().optional().describe('Filter by Active.'), + visible: z.boolean().optional().describe('Filter by Visible.'), + systemAccount: z.boolean().optional().describe('Filter by SystemAccount.') + }) + ) + .output( + z.object({ + accounts: z.array(accountSchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqNumberFilter('AccountNumber', ctx.input.accountNumber), + containsFilter('AccountName', ctx.input.accountNameContains), + ctx.input.active === undefined ? undefined : `Active eq ${ctx.input.active}`, + ctx.input.visible === undefined ? undefined : `Visible eq ${ctx.input.visible}`, + ctx.input.systemAccount === undefined + ? undefined + : `SystemAccount eq ${ctx.input.systemAccount}` + ); + let accounts = (await createClient(ctx).listAccounts(listQuery(ctx, filter))).map( + mapAccount + ); + + return { + output: { accounts, page: page(ctx, accounts.length) }, + message: `Found **${accounts.length}** UniMicro accounts.` + }; + }) + .build(); + +export let listJournalEntries = SlateTool.create(spec, { + name: 'List Journal Entries', + key: 'list_journal_entries', + description: + 'List UniMicro journal entry headers for general ledger audit, voucher lookup, and accounting export workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + financialYearId: z.number().int().optional().describe('Filter by FinancialYearID.'), + journalEntryNumber: z.string().optional().describe('Filter by JournalEntryNumber.'), + statusCode: z.number().int().optional().describe('Filter by journal entry StatusCode.') + }) + ) + .output( + z.object({ + journalEntries: z.array(journalEntrySchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqNumberFilter('FinancialYearID', ctx.input.financialYearId), + eqStringFilter('JournalEntryNumber', ctx.input.journalEntryNumber), + eqNumberFilter('StatusCode', ctx.input.statusCode) + ); + let journalEntries = ( + await createClient(ctx).listJournalEntries(listQuery(ctx, filter)) + ).map(mapJournalEntry); + + return { + output: { journalEntries, page: page(ctx, journalEntries.length) }, + message: `Found **${journalEntries.length}** UniMicro journal entries.` + }; + }) + .build(); + +export let listProjects = SlateTool.create(spec, { + name: 'List Projects', + key: 'list_projects', + description: + 'List UniMicro projects for dimensional reporting, invoice context, and project accounting workflows.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + ...commonListInput, + projectCustomerId: z.number().int().optional().describe('Filter by ProjectCustomerID.'), + projectNumber: z.string().optional().describe('Filter by ProjectNumber.'), + statusCode: z.number().int().optional().describe('Filter by project StatusCode.'), + nameContains: z.string().optional().describe('Filter with contains(Name, value).') + }) + ) + .output( + z.object({ + projects: z.array(projectSchema), + page: pageInfoSchema + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + let filter = combineFilters( + deletedFilter(ctx.input.includeDeleted), + updatedSinceFilter(ctx.input.updatedSince), + eqNumberFilter('ProjectCustomerID', ctx.input.projectCustomerId), + eqStringFilter('ProjectNumber', ctx.input.projectNumber), + eqNumberFilter('StatusCode', ctx.input.statusCode), + containsFilter('Name', ctx.input.nameContains) + ); + let projects = (await createClient(ctx).listProjects(listQuery(ctx, filter))).map( + mapProject + ); + + return { + output: { projects, page: page(ctx, projects.length) }, + message: `Found **${projects.length}** UniMicro projects.` + }; + }) + .build(); + +export let getProfitAndLoss = SlateTool.create(spec, { + name: 'Get Profit And Loss', + key: 'get_profit_and_loss', + description: + 'Get the UniMicro profit-and-loss-periodical account report. The official Swagger exposes FinancialYear and SumAllYears query parameters for this action.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + companyKey: companyKeyInputSchema, + financialYear: financialYearInput, + sumAllYears: z + .string() + .optional() + .describe( + 'UniMicro SumAllYears query parameter for this report action. Official Swagger documents this parameter as a string.' + ) + }) + ) + .output(reportSchema) + .handleInvocation(async (ctx: ToolContext) => { + let report = await createClient(ctx).getProfitAndLoss({ + FinancialYear: ctx.input.financialYear, + SumAllYears: ctx.input.sumAllYears + }); + + return { + output: { report }, + message: 'Retrieved UniMicro profit and loss report.' + }; + }) + .build(); + +export let getBalanceSheet = SlateTool.create(spec, { + name: 'Get Balance Sheet', + key: 'get_balance_sheet', + description: + 'Get the UniMicro balance account report. The official Swagger exposes FinancialYear for this action.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + companyKey: companyKeyInputSchema, + financialYear: financialYearInput + }) + ) + .output(reportSchema) + .handleInvocation(async (ctx: ToolContext) => { + let report = await createClient(ctx).getBalanceSheet({ + FinancialYear: ctx.input.financialYear + }); + + return { + output: { report }, + message: 'Retrieved UniMicro balance sheet report.' + }; + }) + .build(); + +export let getTrialBalance = SlateTool.create(spec, { + name: 'Get Trial Balance', + key: 'get_trial_balance', + description: + 'Get the UniMicro trialbalance account report. The official Swagger does not expose date parameters for this action, so use the raw report output as returned.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + companyKey: companyKeyInputSchema + }) + ) + .output(reportSchema) + .handleInvocation(async (ctx: ToolContext) => { + let report = await createClient(ctx).getTrialBalance({}); + + return { + output: { report }, + message: 'Retrieved UniMicro trial balance report.' + }; + }) + .build(); + +export let downloadFile = SlateTool.create(spec, { + name: 'Download File', + key: 'download_file', + description: + 'Download a UniMicro file through the UniMicro Files endpoint and return the contents as a Slate attachment.', + tags: { + destructive: false, + readOnly: true + } +}) + .input( + z.object({ + companyKey: companyKeyInputSchema, + fileId: z + .number() + .int() + .min(1) + .optional() + .describe( + 'UniMicro file metadata id. The tool reads StorageReference before download.' + ), + storageReference: z + .string() + .optional() + .describe('UniMicro StorageReference to download directly from the Files endpoint.'), + fileName: z.string().optional().describe('Attachment filename override.'), + mimeType: z.string().optional().describe('Attachment MIME type override.') + }) + ) + .output( + z.object({ + fileId: z.number().optional(), + storageReference: z.string(), + fileName: z.string(), + mimeType: z.string(), + byteLength: z.number(), + attachmentCount: z.number(), + file: rawRecordSchema.optional() + }) + ) + .handleInvocation(async (ctx: ToolContext) => { + requireAtLeastOne( + { + fileId: ctx.input.fileId, + storageReference: ctx.input.storageReference + }, + 'Provide fileId or storageReference.' + ); + + let download = await createClient(ctx).downloadFile({ + fileId: ctx.input.fileId, + storageReference: ctx.input.storageReference, + fileName: ctx.input.fileName, + mimeType: ctx.input.mimeType + }); + + return { + output: { + fileId: ctx.input.fileId, + storageReference: download.storageReference, + fileName: download.fileName, + mimeType: download.mimeType, + byteLength: download.byteLength, + attachmentCount: 1, + file: download.file + }, + message: `Downloaded UniMicro file **${download.fileName}**.`, + attachments: [createBase64Attachment(download.contentBase64, download.mimeType)] + }; + }) + .build(); diff --git a/integrations/unimicro/src/tools/shared.ts b/integrations/unimicro/src/tools/shared.ts new file mode 100644 index 0000000000..773ea94679 --- /dev/null +++ b/integrations/unimicro/src/tools/shared.ts @@ -0,0 +1,142 @@ +import { z } from 'zod'; +import type { UnimicroConfig } from '../config'; +import { compact, type UnimicroAuthOutput, UnimicroClient } from '../lib/client'; +import { unimicroValidationError } from '../lib/errors'; + +export type ToolContext = { + auth: UnimicroAuthOutput; + config: UnimicroConfig; + input: Record; +}; + +export let rawRecordSchema = z + .record(z.string(), z.unknown()) + .describe('Raw UniMicro record for fields not normalized by this tool.'); + +export let pageInfoSchema = z.object({ + top: z.number().describe('Number of records requested.'), + skip: z.number().describe('Number of records skipped.'), + count: z.number().describe('Number of records returned.'), + nextSkip: z.number().optional().describe('Next skip value when another page may exist.') +}); + +export let companyKeyInputSchema = z + .string() + .optional() + .describe('Override UniMicro CompanyKey for this request. Defaults to integration config.'); + +export let topInputSchema = z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe('Maximum records to return. Defaults to config.defaultTop or 50.'); + +export let skipInputSchema = z + .number() + .int() + .min(0) + .optional() + .describe('Number of records to skip for UniMicro pagination.'); + +export let filterInputSchema = z + .string() + .optional() + .describe( + 'Advanced UniMicro filter expression, for example "StatusCode eq 41004" or "contains(CustomerName,\'Acme\')".' + ); + +export let selectInputSchema = z + .string() + .optional() + .describe('Comma-separated UniMicro select fields to return.'); + +export let expandInputSchema = z + .string() + .optional() + .describe('Comma-separated UniMicro expand expression, for example "Items.Product".'); + +export let includeDeletedInputSchema = z + .boolean() + .optional() + .describe( + 'Set true to include deleted records. By default list tools filter records with Deleted eq false when the resource has a Deleted field.' + ); + +export let updatedSinceInputSchema = z + .string() + .optional() + .describe('Filter records with UpdatedAt greater than or equal to this ISO date/time.'); + +export let idInputSchema = z.number().int().min(1).describe('UniMicro numeric record id.'); + +export let createClient = (ctx: ToolContext) => + new UnimicroClient({ + auth: ctx.auth, + config: ctx.config, + companyKey: ctx.input.companyKey + }); + +let value = (record: Record, key: string) => record[key]; + +export let recordValue = (record: Record, key: string) => { + let child = value(record, key); + return typeof child === 'object' && child !== null && !Array.isArray(child) + ? (child as Record) + : undefined; +}; + +export let stringValue = (record: Record, key: string) => { + let child = value(record, key); + return typeof child === 'string' ? child : undefined; +}; + +export let numberValue = (record: Record, key: string) => { + let child = value(record, key); + return typeof child === 'number' ? child : undefined; +}; + +export let booleanValue = (record: Record, key: string) => { + let child = value(record, key); + return typeof child === 'boolean' ? child : undefined; +}; + +export let arrayValue = (record: Record, key: string) => { + let child = value(record, key); + return Array.isArray(child) ? child : undefined; +}; + +export let unknownString = (value: unknown) => { + if (typeof value === 'string' && value.trim()) return value; + if (typeof value === 'number') return String(value); + return undefined; +}; + +export let stringFromKeys = (record: Record, keys: string[]) => { + for (let key of keys) { + let child = unknownString(record[key]); + if (child) return child; + } + return undefined; +}; + +export let numberFromKeys = (record: Record, keys: string[]) => { + for (let key of keys) { + let child = record[key]; + if (typeof child === 'number') return child; + } + return undefined; +}; + +export let requireAtLeastOne = (values: Record, message: string) => { + if ( + Object.values(values).some(child => child !== undefined && child !== null && child !== '') + ) { + return; + } + + throw unimicroValidationError(message); +}; + +export let compactOutput = compact; diff --git a/integrations/unimicro/src/triggers/index.ts b/integrations/unimicro/src/triggers/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/integrations/unimicro/src/triggers/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/integrations/unimicro/tsconfig.json b/integrations/unimicro/tsconfig.json new file mode 100644 index 0000000000..2abe727831 --- /dev/null +++ b/integrations/unimicro/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/unimicro/vitest.config.ts b/integrations/unimicro/vitest.config.ts new file mode 100644 index 0000000000..77375364da --- /dev/null +++ b/integrations/unimicro/vitest.config.ts @@ -0,0 +1,7 @@ +import { createSlatesVitestConfig } from '@slates/test/config'; + +export default createSlatesVitestConfig({ + test: { + include: ['src/**/*.test.ts'] + } +});