diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 4b3ba2f..a8e03d5 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -1,4 +1,5 @@ // === AUTOGEN-IMPORTS-BEGIN === run scripts/regenerate-catalog.mjs === +import * as billbee from './de/billbee.json'; import * as billomat from './de/billomat.json'; import * as bundesbank from './de/bundesbank.json'; import * as datev from './de/datev.json'; @@ -6,7 +7,9 @@ import * as destatisGenesis from './de/destatis-genesis.json'; import * as deutscheBahn from './de/deutsche-bahn.json'; import * as dhlTracking from './de/dhl-tracking.json'; import * as dpdGermany from './de/dpd-germany.json'; +import * as easybill from './de/easybill.json'; import * as fastbill from './de/fastbill.json'; +import * as getmyinvoices from './de/getmyinvoices.json'; import * as glsTracking from './de/gls-tracking.json'; import * as handelsregister from './de/handelsregister.json'; import * as hereGeocoding from './de/here-geocoding.json'; @@ -271,6 +274,7 @@ function withGraphqlBuiltins(adapter: AdapterDefinition): AdapterDefinition { // The imports and RAW_ADAPTERS array below are auto-generated. // === AUTOGEN-ARRAY-BEGIN === run scripts/regenerate-catalog.mjs === const RAW_ADAPTERS: AdapterDefinition[] = [ + billbee as unknown as AdapterDefinition, billomat as unknown as AdapterDefinition, bundesbank as unknown as AdapterDefinition, datev as unknown as AdapterDefinition, @@ -278,7 +282,9 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ deutscheBahn as unknown as AdapterDefinition, dhlTracking as unknown as AdapterDefinition, dpdGermany as unknown as AdapterDefinition, + easybill as unknown as AdapterDefinition, fastbill as unknown as AdapterDefinition, + getmyinvoices as unknown as AdapterDefinition, glsTracking as unknown as AdapterDefinition, handelsregister as unknown as AdapterDefinition, hereGeocoding as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/de/billbee.json b/packages/backend/src/adapters/de/billbee.json new file mode 100644 index 0000000..5b164bb --- /dev/null +++ b/packages/backend/src/adapters/de/billbee.json @@ -0,0 +1,242 @@ +{ + "slug": "billbee", + "name": "Billbee Order Management", + "description": "Manage orders, products, customers, stock and shipments in Billbee, the leading German/DACH multichannel order-management (Warenwirtschaft) platform used by 20,000+ online retailers.", + "region": "de", + "category": "ecommerce", + "icon": "billbee", + "docsUrl": "https://app.billbee.io/swagger/ui/index", + "requiredEnvVars": [ + "BILLBEE_API_KEY", + "BILLBEE_LOGIN_EMAIL", + "BILLBEE_API_PASSWORD" + ], + "connector": { + "name": "Billbee REST API", + "type": "REST", + "baseUrl": "https://api.billbee.io/api/v1", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{BILLBEE_LOGIN_EMAIL}}", + "password": "{{BILLBEE_API_PASSWORD}}" + }, + "headers": { + "X-Billbee-Api-Key": "{{BILLBEE_API_KEY}}", + "Accept": "application/json" + } + }, + "instructions": "Billbee is the leading German/DACH multichannel order-management tool (20,000+ retailers) connecting marketplaces and shops (Amazon, eBay, Otto, Kaufland, Shopify, WooCommerce, etc.). This connector wraps the REST API at https://api.billbee.io/api/v1.\n\n## Authentication (THREE credentials, all required)\nBillbee requires both an application API key AND HTTP Basic Auth at the same time:\n1. `BILLBEE_API_KEY` — sent in the `X-Billbee-Api-Key` header. Request it from Billbee (via their API request form / support@billbee.io, describing what you build).\n2. `BILLBEE_LOGIN_EMAIL` — your Billbee account login email (Basic Auth username).\n3. `BILLBEE_API_PASSWORD` — a dedicated API password (NOT your normal login password). Enable the API and set this password in the Billbee web app under Settings → Billbee API → General Settings.\nAll three are mandatory; the API key alone or Basic Auth alone will be rejected.\n\n## Pagination\nList endpoints use `page` (1-based) and `pageSize` (max 250). Responses wrap rows in a paged envelope: a `Data` array plus a `Paging` object with `Page`, `TotalPages`, `TotalRows`. Iterate `page` until `Page == TotalPages`.\n\n## Orders\n`billbee_list_orders` filters by `minOrderDate`/`maxOrderDate` and `modifiedAtMin`/`modifiedAtMax` (incremental sync). Order states are numeric; use `billbee_get_order` for full detail, or `billbee_get_order_by_extref` to look up by the marketplace's external order number.\n\n## Rate limit\nHard limit of 2 requests per second per (API key + user); exceeding it returns HTTP 429 — back off and retry. The API has usage-based pricing above a free allotment, so avoid tight polling loops; prefer `modifiedAtMin`/`updatedOrNewSince`-style incremental queries.", + "tools": [ + { + "name": "billbee_list_orders", + "description": "List orders from Billbee with filtering by order date range, modification date (incremental sync) and pagination. Returns a paged envelope of order summaries.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "pageSize": { + "type": "number", + "description": "Results per page (max 250)" + }, + "minOrderDate": { + "type": "string", + "description": "Only orders created on/after this date-time (ISO 8601, e.g. 2026-01-01)" + }, + "maxOrderDate": { + "type": "string", + "description": "Only orders created on/before this date-time (ISO 8601)" + }, + "modifiedAtMin": { + "type": "string", + "description": "Only orders modified on/after this date-time (ISO 8601) — use for incremental sync" + }, + "modifiedAtMax": { + "type": "string", + "description": "Only orders modified on/before this date-time (ISO 8601)" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/orders", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize", + "minOrderDate": "$minOrderDate", + "maxOrderDate": "$maxOrderDate", + "modifiedAtMin": "$modifiedAtMin", + "modifiedAtMax": "$modifiedAtMax" + } + } + }, + { + "name": "billbee_get_order", + "description": "Get a single Billbee order by its internal Billbee order id, returning full detail including line items, addresses, payment and shipping info.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The internal Billbee order id" + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/orders/{id}" + } + }, + { + "name": "billbee_get_order_by_extref", + "description": "Look up a Billbee order by its external reference (the marketplace/shop order number), returning the full order detail.", + "parameters": { + "type": "object", + "properties": { + "extRef": { + "type": "string", + "description": "The external order number / reference from the marketplace or shop" + } + }, + "required": [ + "extRef" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/orders/findbyextref/{extRef}" + } + }, + { + "name": "billbee_list_products", + "description": "List products/articles from Billbee with pagination. Returns article ids, SKUs, titles, prices and stock fields.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "pageSize": { + "type": "number", + "description": "Results per page (max 250)" + }, + "minCreatedAt": { + "type": "string", + "description": "Only products created on/after this date-time (ISO 8601)" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/products", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize", + "minCreatedAt": "$minCreatedAt" + } + } + }, + { + "name": "billbee_get_product", + "description": "Get a single Billbee product by id (or by SKU/EAN via lookupBy), returning full article detail including pricing and stock.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The product identifier (Billbee article id by default)" + }, + "lookupBy": { + "type": "string", + "description": "How to interpret id: 'id' (default), 'sku' or 'ean'" + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/products/{id}", + "queryParams": { + "lookupBy": "$lookupBy" + } + } + }, + { + "name": "billbee_list_customers", + "description": "List customers from Billbee with pagination. Returns customer ids, names, contact details and addresses.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "pageSize": { + "type": "number", + "description": "Results per page (max 250)" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "billbee_get_customer_orders", + "description": "List all orders belonging to a specific Billbee customer by customer id, with pagination. Useful for customer history and support lookups.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The Billbee customer id" + }, + "page": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "pageSize": { + "type": "number", + "description": "Results per page (max 250)" + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/{id}/orders", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "billbee_list_shipping_providers", + "description": "List the shipping providers configured in the Billbee account, returning provider names and their available shipping products.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/shipment/shippingproviders" + } + } + ] +} diff --git a/packages/backend/src/adapters/de/billbee.live.spec.ts b/packages/backend/src/adapters/de/billbee.live.spec.ts new file mode 100644 index 0000000..4306e48 --- /dev/null +++ b/packages/backend/src/adapters/de/billbee.live.spec.ts @@ -0,0 +1,137 @@ +import * as adapter from './billbee.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Two layers of verification for the Billbee adapter: + * + * 1. Static — always runs. Asserts the adapter targets the Billbee REST API + * (https://api.billbee.io/api/v1), uses BOTH auth layers Billbee requires + * (HTTP Basic Auth via login email + API password AND the X-Billbee-Api-Key + * header), and exposes the documented order/product/customer/shipment paths. + * + * 2. Live — skipped in CI. Billbee enforces a two-stage auth gate, verified + * live: a request with NO X-Billbee-Api-Key header is rejected with 403 + * "X-Billbee-Api-Key Header is missing"; a request WITH a (bogus) key plus + * bogus Basic Auth is rejected with 400 — either way not 404, proving the + * endpoint exists and both layers are wired. A full data test needs a valid + * BILLBEE_API_KEY (requested from Billbee), the account login email, and an + * API password. + * + * Run live with: RUN_BILLBEE_LIVE=1 npx jest src/adapters/de/billbee.live.spec.ts + */ + +describe('billbee adapter — static spec conformance', () => { + const a = adapter as unknown as { + connector: { + baseUrl: string; + authType: string; + authConfig: Record; + headers?: Record; + }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; + }; + + it('targets the Billbee REST API base URL (api.billbee.io, not app.billbee.io)', () => { + expect(a.connector.baseUrl).toBe('https://api.billbee.io/api/v1'); + }); + + it('uses Basic Auth (login email + API password) for the user credentials', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{BILLBEE_LOGIN_EMAIL}}'); + expect(a.connector.authConfig.password).toBe('{{BILLBEE_API_PASSWORD}}'); + }); + + it('also sends the application X-Billbee-Api-Key header (required alongside Basic Auth)', () => { + expect(a.connector.headers?.['X-Billbee-Api-Key']).toBe('{{BILLBEE_API_KEY}}'); + }); + + it('exposes the documented order, product, customer and shipment paths', () => { + const paths = a.tools.map((t) => t.endpointMapping.path); + expect(paths).toContain('/orders'); + expect(paths).toContain('/orders/{id}'); + expect(paths).toContain('/orders/findbyextref/{extRef}'); + expect(paths).toContain('/products'); + expect(paths).toContain('/products/{id}'); + expect(paths).toContain('/customers'); + expect(paths).toContain('/customers/{id}/orders'); + expect(paths).toContain('/shipment/shippingproviders'); + }); +}); + +const maybe = process.env.RUN_BILLBEE_LIVE ? describe : describe.skip; + +maybe('billbee adapter — live edge reachability', () => { + const oauth = {} as unknown as OAuth2TokenService; + const login = {} as unknown as LoginTokenService; + const engine = new RestEngine(oauth, login); + + const baseUrl = 'https://api.billbee.io/api/v1'; + + it('rejects a request missing the X-Billbee-Api-Key header with 403 (gate 1)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'BASIC_AUTH', + authConfig: { username: 'bogus@example.com', password: 'bogus-pass' }, + // deliberately omit connector.headers (no X-Billbee-Api-Key) + }, + { method: 'GET', path: '/orders', queryParams: { pageSize: '$pageSize' } }, + { pageSize: 1 }, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(403); + expect(JSON.stringify(err.response?.data || '')).toMatch(/X-Billbee-Api-Key/i); + }, 30000); + + it('reaches Billbee with both auth layers present (bogus → 4xx, not 404)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'BASIC_AUTH', + authConfig: { username: 'bogus@example.com', password: 'bogus-pass' }, + headers: { 'X-Billbee-Api-Key': 'bogus-invalid-key' }, + }, + { method: 'GET', path: '/orders', queryParams: { pageSize: '$pageSize' } }, + { pageSize: 1 }, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + // With the key header present but invalid Billbee returns 400 (not 404), + // proving the endpoint exists and the API-key gate was passed. + expect([400, 401, 403]).toContain(err.response?.status); + expect(err.response?.status).not.toBe(404); + }, 30000); + + it('RestEngine injects both the Basic Auth and the X-Billbee-Api-Key header', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'BASIC_AUTH', + authConfig: { username: 'sentinel@example.com', password: 'sentinel-pass' }, + headers: { 'X-Billbee-Api-Key': 'sentinel-key-12345' }, + }, + { method: 'GET', path: '/orders' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.config?.auth?.username).toBe('sentinel@example.com'); + expect(err.config?.auth?.password).toBe('sentinel-pass'); + expect((err.config?.headers || {})['X-Billbee-Api-Key']).toBe('sentinel-key-12345'); + }, 30000); +}); diff --git a/packages/backend/src/adapters/de/easybill.json b/packages/backend/src/adapters/de/easybill.json new file mode 100644 index 0000000..681fabf --- /dev/null +++ b/packages/backend/src/adapters/de/easybill.json @@ -0,0 +1,272 @@ +{ + "slug": "easybill", + "name": "easybill Invoicing", + "description": "Manage customers, invoices, offers, credit notes and products in easybill. Popular 100% Made-in-Germany invoicing and e-commerce accounting SaaS used by 20,000+ businesses.", + "region": "de", + "category": "finance", + "icon": "easybill", + "docsUrl": "https://www.easybill.de/api/", + "requiredEnvVars": [ + "EASYBILL_API_KEY" + ], + "connector": { + "name": "easybill REST API", + "type": "REST", + "baseUrl": "https://api.easybill.de/rest/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{EASYBILL_API_KEY}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "instructions": "easybill is a German cloud invoicing / accounting tool (100% Made in Germany, 20,000+ customers) with a clean REST API at https://api.easybill.de/rest/v1.\n\n## Authentication\nGenerate an API key in easybill under Settings → App-Center / API (if you can't find it, contact easybill support to enable API access). Set it as `EASYBILL_API_KEY`. The connector sends it as `Authorization: Bearer `. (easybill also accepts HTTP Basic Auth with email:apikey, but Bearer is simpler and is what this connector uses.)\n\n## Core concepts\n- **Customers** (`/customers`) — your clients. Identified by numeric `id` and a human `number`.\n- **Documents** (`/documents`) — the single resource for every document kind, discriminated by `type`: `INVOICE`, `CREDIT`, `OFFER`, `REMINDER`, `DELIVERY`, etc. Filter a list with `type=INVOICE` (comma-separated for several types). Each document carries an `items` array of line items.\n- **Positions** (`/positions`) — your reusable product/article catalog (article number, description, unit price, VAT).\n\n## Pagination\nList endpoints are page-based: `page` (default 1) and `limit` (default 100, max 1000). List responses wrap the rows in a `documents`/`page`/`pages`/`total` envelope.\n\n## Dates & filters\nDate filters accept a single `YYYY-MM-DD` or a `from,to` range (e.g. `document_date=2026-01-01,2026-03-31`). `paid_at=null` returns unpaid invoices. Most ID filters accept comma-separated lists.\n\n## Tips\n- To list only open invoices: `easybill_list_documents` with `type=INVOICE` and `paid_at=null`.\n- Fetch a finished invoice as a PDF with `easybill_get_document_pdf` (returns the PDF base64-encoded in the `pdf` field).\n- Amounts are in the document currency; VAT is per line item (`vat_percent`).", + "tools": [ + { + "name": "easybill_list_customers", + "description": "List customers (clients) from easybill with optional search and pagination. Returns id, customer number, name/company, address, emails and VAT id.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "limit": { + "type": "number", + "description": "Results per page (default 100, max 1000)" + }, + "company_name": { + "type": "string", + "description": "Filter by company name (comma-separated for several values)" + }, + "last_name": { + "type": "string", + "description": "Filter by contact last name" + }, + "emails": { + "type": "string", + "description": "Filter by email address" + }, + "number": { + "type": "string", + "description": "Filter by easybill customer number" + }, + "country": { + "type": "string", + "description": "Filter by ISO country code, e.g. DE" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "page": "$page", + "limit": "$limit", + "company_name": "$company_name", + "last_name": "$last_name", + "emails": "$emails", + "number": "$number", + "country": "$country" + } + } + }, + { + "name": "easybill_get_customer", + "description": "Get a single easybill customer by its numeric id, returning the full record (addresses, contact details, payment terms, VAT id).", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The easybill customer id" + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/{id}" + } + }, + { + "name": "easybill_create_customer", + "description": "Create a new customer (client) in easybill. Provide at least a company name or a last name plus any address and contact fields.", + "parameters": { + "type": "object", + "properties": { + "company_name": { + "type": "string", + "description": "Company name (use this or last_name)" + }, + "first_name": { + "type": "string", + "description": "Contact first name" + }, + "last_name": { + "type": "string", + "description": "Contact last name (use this or company_name)" + }, + "street": { + "type": "string", + "description": "Street and house number" + }, + "zip_code": { + "type": "string", + "description": "Postal code" + }, + "city": { + "type": "string", + "description": "City" + }, + "country": { + "type": "string", + "description": "ISO country code, e.g. DE" + }, + "emails": { + "type": "string", + "description": "Primary email address" + }, + "vat_identifier": { + "type": "string", + "description": "VAT identification number (USt-IdNr.)" + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "company_name": "$company_name", + "first_name": "$first_name", + "last_name": "$last_name", + "street": "$street", + "zip_code": "$zip_code", + "city": "$city", + "country": "$country", + "emails": "$emails", + "vat_identifier": "$vat_identifier" + } + } + }, + { + "name": "easybill_list_documents", + "description": "List documents (invoices, credits, offers, reminders, etc.) from easybill. Filter by type, customer, date range and payment status. Returns numbers, dates, amounts and status.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "limit": { + "type": "number", + "description": "Results per page (default 100, max 1000)" + }, + "type": { + "type": "string", + "description": "Document type filter, comma-separated. One or more of INVOICE, CREDIT, OFFER, REMINDER, DELIVERY" + }, + "customer_id": { + "type": "string", + "description": "Filter by customer id (comma-separated for several)" + }, + "document_date": { + "type": "string", + "description": "Document date filter: a single YYYY-MM-DD or a 'from,to' range e.g. 2026-01-01,2026-03-31" + }, + "paid_at": { + "type": "string", + "description": "Payment filter: a date/range, or the literal 'null' to return only unpaid documents" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/documents", + "queryParams": { + "page": "$page", + "limit": "$limit", + "type": "$type", + "customer_id": "$customer_id", + "document_date": "$document_date", + "paid_at": "$paid_at" + } + } + }, + { + "name": "easybill_get_document", + "description": "Get a single easybill document (invoice/offer/credit) by id with full details including line items, taxes, totals and payment status.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The easybill document id" + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/{id}" + } + }, + { + "name": "easybill_get_document_pdf", + "description": "Fetch the rendered PDF of an easybill document by id. The PDF is returned base64-encoded in the response 'pdf' field, ready to save or forward.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The easybill document id" + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/{id}/pdf" + } + }, + { + "name": "easybill_list_positions", + "description": "List products/articles (positions) from the easybill catalog with pagination. Returns article number, description, unit price and VAT percentage.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "limit": { + "type": "number", + "description": "Results per page (default 100, max 1000)" + }, + "number": { + "type": "string", + "description": "Filter by article number" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/positions", + "queryParams": { + "page": "$page", + "limit": "$limit", + "number": "$number" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/de/easybill.live.spec.ts b/packages/backend/src/adapters/de/easybill.live.spec.ts new file mode 100644 index 0000000..f7d5c19 --- /dev/null +++ b/packages/backend/src/adapters/de/easybill.live.spec.ts @@ -0,0 +1,107 @@ +import * as adapter from './easybill.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Two layers of verification for the easybill adapter: + * + * 1. Static — always runs. Asserts the adapter targets the easybill REST API + * v1 (https://api.easybill.de/rest/v1), authenticates via Bearer token, and + * exposes the documented /customers, /documents and /positions paths. + * + * 2. Live — skipped in CI. Hits api.easybill.de with a bogus Bearer token to + * prove (a) the base URL resolves to easybill, (b) the endpoint exists + * (rejects with 401 "Wrong Authorization." rather than 404), and (c) the + * RestEngine injects the token as `Authorization: Bearer `. easybill + * distinguishes "Wrong Authorization." (scheme recognised, credential bad) + * from "Wrong or missing Authorization header." (scheme not recognised), + * so a 401 with the former message proves the Bearer scheme is correct. + * A full data test needs a valid EASYBILL_API_KEY with API access enabled. + * + * Run live with: RUN_EASYBILL_LIVE=1 npx jest src/adapters/de/easybill.live.spec.ts + */ + +describe('easybill adapter — static spec conformance', () => { + const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; + }; + + it('targets the easybill REST API v1 base URL', () => { + expect(a.connector.baseUrl).toBe('https://api.easybill.de/rest/v1'); + }); + + it('authenticates via Bearer token (Authorization: Bearer )', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{EASYBILL_API_KEY}}'); + }); + + it('exposes the documented customers, documents and positions paths', () => { + const paths = a.tools.map((t) => t.endpointMapping.path); + expect(paths).toContain('/customers'); + expect(paths).toContain('/customers/{id}'); + expect(paths).toContain('/documents'); + expect(paths).toContain('/documents/{id}'); + expect(paths).toContain('/documents/{id}/pdf'); + expect(paths).toContain('/positions'); + }); + + it('prefixes every tool name with easybill_', () => { + for (const tool of a.tools) { + expect(tool.name.startsWith('easybill_')).toBe(true); + } + }); +}); + +const maybe = process.env.RUN_EASYBILL_LIVE ? describe : describe.skip; + +maybe('easybill adapter — live edge reachability', () => { + const oauth = {} as unknown as OAuth2TokenService; + const login = {} as unknown as LoginTokenService; + const engine = new RestEngine(oauth, login); + + const baseUrl = 'https://api.easybill.de/rest/v1'; + + it('reaches easybill and rejects a bogus Bearer token with 401 (endpoint exists)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'BEARER_TOKEN', + authConfig: { token: 'bogus-token-for-test' }, + }, + { method: 'GET', path: '/customers', queryParams: { limit: '$limit' } }, + { limit: 1 }, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + // 401 (not 404) proves the path exists and auth is enforced. The + // "Wrong Authorization." message proves the Bearer scheme is recognised. + expect(err.response?.status).toBe(401); + expect(JSON.stringify(err.response?.data || '')).toContain('Wrong Authorization'); + }, 30000); + + it('RestEngine injects the token as Authorization: Bearer', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'BEARER_TOKEN', + authConfig: { token: 'sentinel-token-12345' }, + }, + { method: 'GET', path: '/customers' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + const sentHeaders = err.config?.headers || {}; + expect(sentHeaders.Authorization).toBe('Bearer sentinel-token-12345'); + }, 30000); +}); diff --git a/packages/backend/src/adapters/de/getmyinvoices.json b/packages/backend/src/adapters/de/getmyinvoices.json new file mode 100644 index 0000000..6eaa33d --- /dev/null +++ b/packages/backend/src/adapters/de/getmyinvoices.json @@ -0,0 +1,225 @@ +{ + "slug": "getmyinvoices", + "name": "GetMyInvoices", + "description": "Retrieve invoices, documents, attachments and suppliers from GetMyInvoices, the German invoice & document management platform by fino data services GmbH (Kassel).", + "region": "de", + "category": "finance", + "icon": "getmyinvoices", + "docsUrl": "https://api.getmyinvoices.com/accounts/v3/doc/", + "requiredEnvVars": [ + "GETMYINVOICES_API_KEY" + ], + "connector": { + "name": "GetMyInvoices Accounts API v3", + "type": "REST", + "baseUrl": "https://api.getmyinvoices.com/accounts/v3", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-API-KEY", + "apiKey": "{{GETMYINVOICES_API_KEY}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "instructions": "GetMyInvoices (operated by fino data services GmbH, Kassel) automatically collects invoices and documents from thousands of online portals and mailboxes. This connector wraps the v3 Accounts API at https://api.getmyinvoices.com/accounts/v3.\n\n## Authentication\nGenerate an API key in the GetMyInvoices web app via the top-right menu → 'API access'. Set it as `GETMYINVOICES_API_KEY`. The connector sends it in the `X-API-KEY` header on every request. (Note: the older v1/v2 Go/PHP SDKs use a POST body `api_key` — that is NOT v3; v3 uses the header.)\n\n## Core concepts\n- **Documents** (`/documents`) — the single resource for every document, including invoices, discriminated by `documentType` (e.g. `INCOMING_INVOICE`, `SALES_INVOICE`, `RECEIPT`, `CREDIT_NOTE`, `PAYROLL`). Use `documentTypeFilter` to narrow.\n- **Companies** (`/companies`) — in GetMyInvoices terminology these are suppliers / online portals the documents come from.\n- **Attachments** — extra files linked to a document under `/documents/{documentUid}/attachments`.\n\n## Pagination (documents only)\n`GET /documents` is paginated with `perPage` (default 500) and `pageNumber` (default 1). The response envelope carries `totalCount`, `maxPages`, `offset` and the `records` array. Iterate `pageNumber` up to `maxPages`. `GET /companies` returns a plain array with no pagination.\n\n## Incremental sync & file content\n- Use `updatedOrNewSinceFilter=YYYY-MM-DD` to fetch only documents created or modified since a date — ideal for syncing.\n- To get the actual file bytes, call `getmyinvoices_get_document` (it returns `fileContent` as base64, plus `meta_data` and OCR `readableContent`). Set `includeDocument=false` when you only need metadata.\n\n## Rate limits\nThe API enforces per-minute and per-hour limits and returns `X-RateLimit-Remaining-*` headers plus `Retry-After` on HTTP 429 — respect those values and back off.", + "tools": [ + { + "name": "getmyinvoices_list_documents", + "description": "List documents (invoices, receipts, credit notes, etc.) from GetMyInvoices with filters for type, date range, payment status and incremental sync. Returns paginated metadata records.", + "parameters": { + "type": "object", + "properties": { + "pageNumber": { + "type": "number", + "description": "Page number, 1-based (default 1)" + }, + "perPage": { + "type": "number", + "description": "Results per page (default 500)" + }, + "searchQuery": { + "type": "string", + "description": "Free-text search across documents, like the web UI" + }, + "documentTypeFilter": { + "type": "string", + "description": "Document type, e.g. INCOMING_INVOICE, SALES_INVOICE, RECEIPT, CREDIT_NOTE, PAYROLL" + }, + "companyFilter": { + "type": "number", + "description": "Filter by company/supplier: 0=any, -1=unassigned, or a specific companyUid" + }, + "startDateFilter": { + "type": "string", + "description": "Earliest document date, YYYY-MM-DD" + }, + "endDateFilter": { + "type": "string", + "description": "Latest document date, YYYY-MM-DD" + }, + "updatedOrNewSinceFilter": { + "type": "string", + "description": "Return only documents created or modified since this timestamp — use for incremental sync. MUST be a full date-time in 'YYYY-MM-DD HH:MM:SS' format, e.g. '2026-01-01 00:00:00' (a plain date is rejected with HTTP 422)" + }, + "paymentStatusFilter": { + "type": "string", + "description": "Payment status filter: paid, not_paid, unknown or all" + }, + "loadLineItems": { + "type": "boolean", + "description": "Include parsed line items in each record" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/documents", + "queryParams": { + "pageNumber": "$pageNumber", + "perPage": "$perPage", + "searchQuery": "$searchQuery", + "documentTypeFilter": "$documentTypeFilter", + "companyFilter": "$companyFilter", + "startDateFilter": "$startDateFilter", + "endDateFilter": "$endDateFilter", + "updatedOrNewSinceFilter": "$updatedOrNewSinceFilter", + "paymentStatusFilter": "$paymentStatusFilter", + "loadLineItems": "$loadLineItems" + } + } + }, + { + "name": "getmyinvoices_get_document", + "description": "Get a single GetMyInvoices document by its documentUid, including metadata, parsed line items, OCR text and optionally the base64 file content.", + "parameters": { + "type": "object", + "properties": { + "documentUid": { + "type": "string", + "description": "The document unique id" + }, + "loadLineItems": { + "type": "boolean", + "description": "Include parsed line items" + }, + "includeDocument": { + "type": "boolean", + "description": "Include the file content (base64) in the response; set false for metadata only" + }, + "readableText": { + "type": "boolean", + "description": "Include the OCR/readable text content" + } + }, + "required": [ + "documentUid" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/{documentUid}", + "queryParams": { + "loadLineItems": "$loadLineItems", + "includeDocument": "$includeDocument", + "readableText": "$readableText" + } + } + }, + { + "name": "getmyinvoices_list_document_attachments", + "description": "List all attachments linked to a specific GetMyInvoices document, returning each attachment's uid, filename and metadata.", + "parameters": { + "type": "object", + "properties": { + "documentUid": { + "type": "string", + "description": "The parent document unique id" + } + }, + "required": [ + "documentUid" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/{documentUid}/attachments" + } + }, + { + "name": "getmyinvoices_get_document_attachment", + "description": "Get a single attachment of a GetMyInvoices document by document and attachment uid, including its file content and metadata.", + "parameters": { + "type": "object", + "properties": { + "documentUid": { + "type": "string", + "description": "The parent document unique id" + }, + "attachmentUid": { + "type": "string", + "description": "The attachment unique id" + } + }, + "required": [ + "documentUid", + "attachmentUid" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/{documentUid}/attachments/{attachmentUid}" + } + }, + { + "name": "getmyinvoices_list_companies", + "description": "List companies (suppliers and online portals) configured in GetMyInvoices, filterable by status and type. Returns companyUid, name, type and tags.", + "parameters": { + "type": "object", + "properties": { + "statusFilter": { + "type": "number", + "description": "Status: 0=any, 1=active, -1=inactive, -2=pending" + }, + "companyTypeFilter": { + "type": "string", + "description": "Company type: 0 for any, or ONLINE_PORTAL / CUSTOM_COMPANY" + }, + "returnPortalRecords": { + "type": "number", + "description": "0 to hide portal records, 1 to include them" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/companies", + "queryParams": { + "statusFilter": "$statusFilter", + "companyTypeFilter": "$companyTypeFilter", + "returnPortalRecords": "$returnPortalRecords" + } + } + }, + { + "name": "getmyinvoices_get_company", + "description": "Get a single company (supplier/portal) from GetMyInvoices by its companyUid, returning full details and linked portal records.", + "parameters": { + "type": "object", + "properties": { + "companyUid": { + "type": "string", + "description": "The company unique id" + } + }, + "required": [ + "companyUid" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/companies/{companyUid}" + } + } + ] +} diff --git a/packages/backend/src/adapters/de/getmyinvoices.live.spec.ts b/packages/backend/src/adapters/de/getmyinvoices.live.spec.ts new file mode 100644 index 0000000..493cc2c --- /dev/null +++ b/packages/backend/src/adapters/de/getmyinvoices.live.spec.ts @@ -0,0 +1,115 @@ +import * as adapter from './getmyinvoices.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Two layers of verification for the GetMyInvoices adapter: + * + * 1. Static — always runs. Asserts the adapter targets the v3 Accounts API + * (https://api.getmyinvoices.com/accounts/v3), authenticates via the + * X-API-KEY header, exposes the documented /documents and /companies paths, + * and keeps the date-format note on updatedOrNewSinceFilter (a regression + * guard for a real bug: the API rejects a plain date with HTTP 422 and + * requires 'Y-m-d H:i:s', confirmed live against a real account). + * + * 2. Live — skipped in CI. This API was verified end-to-end with a real key: + * GET /documents returns the {records,totalCount,maxPages,maxAmount,offset} + * envelope and GET /companies returns a bare array. The unauthenticated + * probe here uses a bogus key to prove the base URL + X-API-KEY scheme are + * correct without needing a secret in CI: a bogus key yields 403 + * "API Key does not exist" (not 404), proving the endpoint + auth wiring. + * + * Run live with: RUN_GETMYINVOICES_LIVE=1 npx jest src/adapters/de/getmyinvoices.live.spec.ts + */ + +describe('getmyinvoices adapter — static spec conformance', () => { + const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ + name: string; + endpointMapping: { method: string; path: string }; + parameters?: { properties?: Record }; + }>; + }; + + it('targets the GetMyInvoices Accounts API v3 base URL', () => { + expect(a.connector.baseUrl).toBe('https://api.getmyinvoices.com/accounts/v3'); + }); + + it('authenticates via the X-API-KEY header (not query, not Bearer)', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-API-KEY'); + expect(a.connector.authConfig.apiKey).toBe('{{GETMYINVOICES_API_KEY}}'); + }); + + it('exposes the documented documents and companies paths', () => { + const paths = a.tools.map((t) => t.endpointMapping.path); + expect(paths).toContain('/documents'); + expect(paths).toContain('/documents/{documentUid}'); + expect(paths).toContain('/documents/{documentUid}/attachments'); + expect(paths).toContain('/companies'); + expect(paths).toContain('/companies/{companyUid}'); + }); + + it('keeps the Y-m-d H:i:s note on updatedOrNewSinceFilter (HTTP 422 regression guard)', () => { + const list = a.tools.find((t) => t.name === 'getmyinvoices_list_documents'); + const desc = + list?.parameters?.properties?.updatedOrNewSinceFilter?.description || ''; + // The API rejects a plain YYYY-MM-DD with 422; the description must steer + // the agent to the full date-time format. + expect(desc).toMatch(/YYYY-MM-DD HH:MM:SS/); + }); +}); + +const maybe = process.env.RUN_GETMYINVOICES_LIVE ? describe : describe.skip; + +maybe('getmyinvoices adapter — live edge reachability', () => { + const oauth = {} as unknown as OAuth2TokenService; + const login = {} as unknown as LoginTokenService; + const engine = new RestEngine(oauth, login); + + const baseUrl = 'https://api.getmyinvoices.com/accounts/v3'; + + it('reaches GetMyInvoices and rejects a bogus key with 403 (endpoint exists)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'API_KEY', + authConfig: { headerName: 'X-API-KEY', apiKey: 'bogus-key-for-test' }, + }, + { method: 'GET', path: '/documents', queryParams: { perPage: '$perPage' } }, + { perPage: 1 }, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + // 403 "API Key does not exist" (not 404) proves the path + X-API-KEY scheme. + expect(err.response?.status).toBe(403); + expect(JSON.stringify(err.response?.data || '')).toMatch(/API Key/i); + }, 30000); + + it('RestEngine injects the key into the X-API-KEY header', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl, + authType: 'API_KEY', + authConfig: { headerName: 'X-API-KEY', apiKey: 'sentinel-key-12345' }, + }, + { method: 'GET', path: '/documents' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + const sentHeaders = err.config?.headers || {}; + expect(sentHeaders['X-API-KEY']).toBe('sentinel-key-12345'); + expect(sentHeaders.Authorization).toBeUndefined(); + }, 30000); +});