diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index d8bc4128..2b6bfc06 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -426,6 +426,7 @@ import * as dolibarr from './fr/dolibarr.json'; import * as manomano from './fr/manomano.json'; import * as octopia from './fr/octopia.json'; import * as pennylane from './fr/pennylane.json'; +import * as qonto from './fr/qonto.json'; import * as sellsy from './fr/sellsy.json'; import * as afasProfit from './nl/afas-profit.json'; import * as exactOnline from './nl/exact-online.json'; @@ -1070,6 +1071,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ manomano as unknown as AdapterDefinition, octopia as unknown as AdapterDefinition, pennylane as unknown as AdapterDefinition, + qonto as unknown as AdapterDefinition, sellsy as unknown as AdapterDefinition, afasProfit as unknown as AdapterDefinition, exactOnline as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/fr/qonto.json b/packages/backend/src/adapters/fr/qonto.json new file mode 100644 index 00000000..0c810116 --- /dev/null +++ b/packages/backend/src/adapters/fr/qonto.json @@ -0,0 +1,356 @@ +{ + "slug": "qonto", + "unlisted": true, + "name": "Qonto", + "description": "Qonto — French business account for SMBs and freelancers: organization and bank accounts, transactions, client and supplier invoices, clients and quotes. Official Business API v2, read-only, login:secret-key API key. 7 tools.", + "instructions": "This connector reads your **Qonto** business account through the official Business API: organization and bank accounts, transactions, client invoices, supplier invoices, clients and quotes. It is **read-only**: every tool is a GET, so it cannot send transfers, add beneficiaries, touch cards or create, edit or delete invoices.\n\n**Getting the credentials**\n1. Sign in to the Qonto web app (https://app.qonto.com) and, if you have several organizations, pick the one to connect.\n2. Open **Integrations & Partnerships → API key** and click **Generate** if no secret key exists yet.\n3. Put the **login** (sign-in, e.g. `pied-piper-7132`) in `QONTO_LOGIN` and the **secret key** in `QONTO_SECRET_KEY`.\n\n**How authentication works**: Qonto expects `Authorization: :`. This looks like HTTP Basic but is not: no Base64 and no `Bearer` prefix. A 401 almost always means a wrong login or secret.\n\n**Bank data is sensitive.** The connector sees every bank account of the organization. Assign it only to MCP servers whose users are allowed to see the company's bank accounts and transactions.\n\n**Workflow**: call `qonto_get_organization` first to get the bank account `id` (or IBAN); `qonto_list_transactions` lists one account at a time; passing `bank_account_id` or `iban` is recommended (whether Qonto rejects a call without them is pending verification).\n\n**Pagination**: list tools take `page` and `per_page` (1 to 100, default 100); responses carry `meta.next_page` and `meta.total_pages`.\n\n**Amounts**: transactions expose `amount` and `amount_cents`; prefer the cents for arithmetic.\n\n**Unverified.** This adapter has not been exercised against a live Qonto account yet. Two points still need confirming with a real account: the exact `Authorization: :` header format, and whether `/transactions` requires `bank_account_id` or `iban`. A correction from anyone running it against a real organization is welcome.\n\n**Sandbox**: create an application on the Qonto Developer Portal (https://developers.qonto.com) to get a sandbox organization and a personal staging token. After install, edit the connector: set the base URL to `https://thirdparty-sandbox.staging.qonto.co/v2` and add the header `X-Qonto-Staging-Token: ` (case-sensitive). Without it, sandbox calls return a OneLogin HTML page instead of JSON. The staging token is not accepted in production.\n\n**Cloud reachability**: thirdparty.qonto.com is public with a valid certificate.", + "region": "fr", + "category": "banking", + "icon": "qonto", + "docsUrl": "https://docs.qonto.com/", + "requiredEnvVars": [ + "QONTO_LOGIN", + "QONTO_SECRET_KEY" + ], + "probe": { + "tool": "qonto_get_organization" + }, + "connector": { + "name": "Qonto Business API v2", + "type": "REST", + "baseUrl": "https://thirdparty.qonto.com/v2", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "{{QONTO_LOGIN}}:{{QONTO_SECRET_KEY}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "qonto_get_organization", + "description": "Get the Qonto organization: legal name, slug and every bank account with its id, IBAN, BIC, currency, balance and status. Call this first: qonto_list_transactions needs a bank account id or IBAN from here.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/organization" + } + }, + { + "name": "qonto_list_transactions", + "description": "List transactions of one bank account. Pass bank_account_id or iban (recommended; call qonto_get_organization first to get them, a call without them is pending verification). Without a status filter Qonto returns only completed transactions. Filter by date range, side, status and operation type; paginated.", + "parameters": { + "type": "object", + "properties": { + "bank_account_id": { + "type": "string", + "description": "Bank account id from qonto_get_organization. Takes precedence over iban when both are set." + }, + "iban": { + "type": "string", + "description": "IBAN of the bank account, from qonto_get_organization." + }, + "settled_at_from": { + "type": "string", + "description": "ISO 8601 timestamp; only transactions settled at or after it." + }, + "settled_at_to": { + "type": "string", + "description": "ISO 8601 timestamp; only transactions settled at or before it." + }, + "emitted_at_from": { + "type": "string", + "description": "ISO 8601 timestamp; only transactions emitted at or after it." + }, + "emitted_at_to": { + "type": "string", + "description": "ISO 8601 timestamp; only transactions emitted at or before it." + }, + "side": { + "type": "string", + "description": "credit (money in) or debit (money out)." + }, + "status": { + "type": "string", + "description": "One status per call: pending, declined, completed or reversed (sent as status[]). When omitted, Qonto returns only completed transactions, so set it to see pending or declined ones." + }, + "operation_type": { + "type": "string", + "description": "Operation type, e.g. card, transfer, direct_debit, income (sent as operation_type[])." + }, + "with_attachments": { + "type": "boolean", + "description": "true to keep only transactions with attachments, false for those without." + }, + "sort_by": { + "type": "string", + "description": "Sort order, e.g. settled_at:desc." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1 (Qonto default 1)." + }, + "per_page": { + "type": "integer", + "description": "Items per page, 1 to 100 (Qonto default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/transactions", + "queryParams": { + "bank_account_id": "$bank_account_id", + "iban": "$iban", + "settled_at_from": "$settled_at_from", + "settled_at_to": "$settled_at_to", + "emitted_at_from": "$emitted_at_from", + "emitted_at_to": "$emitted_at_to", + "side": "$side", + "status[]": "$status", + "operation_type[]": "$operation_type", + "with_attachments": "$with_attachments", + "sort_by": "$sort_by", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "qonto_get_transaction", + "description": "Get one transaction by its id: amount, currency, side, counterparty, label, status, operation type, dates, category and attachment ids. Use ids returned by qonto_list_transactions.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Transaction id (UUID) from qonto_list_transactions." + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/transactions/{id}", + "encodePathParams": true + } + }, + { + "name": "qonto_list_client_invoices", + "description": "List the client invoices the organization issued from Qonto, filtered by status and by due date, creation or update date ranges. Answers questions like unpaid invoices due this week.", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Invoice status, e.g. draft, unpaid, paid, canceled (filter[status])." + }, + "due_date_from": { + "type": "string", + "description": "Date YYYY-MM-DD; due on or after (filter[due_date_from])." + }, + "due_date_to": { + "type": "string", + "description": "Date YYYY-MM-DD; due on or before (filter[due_date_to])." + }, + "created_at_from": { + "type": "string", + "description": "ISO 8601; created on or after (filter[created_at_from])." + }, + "created_at_to": { + "type": "string", + "description": "ISO 8601; created on or before (filter[created_at_to])." + }, + "sort_by": { + "type": "string", + "description": "Sort order, e.g. created_at:desc." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1 (Qonto default 1)." + }, + "per_page": { + "type": "integer", + "description": "Items per page, 1 to 100 (Qonto default 100)." + }, + "updated_at_from": { + "type": "string", + "description": "ISO 8601; updated on or after (filter[updated_at_from])." + }, + "updated_at_to": { + "type": "string", + "description": "ISO 8601; updated on or before (filter[updated_at_to])." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/client_invoices", + "queryParams": { + "filter[status]": "$status", + "filter[due_date_from]": "$due_date_from", + "filter[due_date_to]": "$due_date_to", + "filter[created_at_from]": "$created_at_from", + "filter[created_at_to]": "$created_at_to", + "filter[updated_at_from]": "$updated_at_from", + "filter[updated_at_to]": "$updated_at_to", + "sort_by": "$sort_by", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "qonto_list_supplier_invoices", + "description": "List supplier invoices received in Qonto, filtered by status, creation date and a free-text query over the fields named in query_fields. Useful to find bills to review or pay, or missing documents.", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Supplier invoice status, e.g. to_review, to_approve, awaiting_payment, pending, paid (filter[status]). to_pay is deprecated by Qonto; use to_review." + }, + "created_at_from": { + "type": "string", + "description": "ISO 8601; created on or after (filter[created_at_from])." + }, + "created_at_to": { + "type": "string", + "description": "ISO 8601; created on or before (filter[created_at_to])." + }, + "query": { + "type": "string", + "description": "Free-text search; Qonto applies it to the fields listed in query_fields." + }, + "query_fields": { + "type": "string", + "description": "Comma-separated fields searched by query, e.g. supplier_name,amount,file_name,invoice_number." + }, + "sort_by": { + "type": "string", + "description": "Sort order, e.g. created_at:desc." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1 (Qonto default 1)." + }, + "per_page": { + "type": "integer", + "description": "Items per page, 1 to 100 (Qonto default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/supplier_invoices", + "queryParams": { + "filter[status]": "$status", + "filter[created_at_from]": "$created_at_from", + "filter[created_at_to]": "$created_at_to", + "query": "$query", + "query_fields": "$query_fields", + "sort_by": "$sort_by", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "qonto_list_clients", + "description": "List the clients (customers) in the Qonto invoicing address book, optionally filtered by name, VAT number or email. Returns ids, contact data and billing addresses.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Filter by client name (filter[name])." + }, + "vat_number": { + "type": "string", + "description": "Filter by VAT number (filter[vat_number])." + }, + "email": { + "type": "string", + "description": "Filter by email (filter[email])." + }, + "sort_by": { + "type": "string", + "description": "Sort order, e.g. name:asc." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1 (Qonto default 1)." + }, + "per_page": { + "type": "integer", + "description": "Items per page, 1 to 100 (Qonto default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/clients", + "queryParams": { + "filter[name]": "$name", + "filter[vat_number]": "$vat_number", + "filter[email]": "$email", + "sort_by": "$sort_by", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "qonto_list_quotes", + "description": "List the quotes (devis) created in Qonto, filtered by status and creation date range, with client, amounts, validity and status. Read-only: it cannot create or send quotes.", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Quote status, e.g. pending_approval, approved, canceled (filter[status])." + }, + "created_at_from": { + "type": "string", + "description": "ISO 8601; created on or after (filter[created_at_from])." + }, + "created_at_to": { + "type": "string", + "description": "ISO 8601; created on or before (filter[created_at_to])." + }, + "sort_by": { + "type": "string", + "description": "Sort order, e.g. created_at:desc." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1 (Qonto default 1)." + }, + "per_page": { + "type": "integer", + "description": "Items per page, 1 to 100 (Qonto default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/quotes", + "queryParams": { + "filter[status]": "$status", + "filter[created_at_from]": "$created_at_from", + "filter[created_at_to]": "$created_at_to", + "sort_by": "$sort_by", + "page": "$page", + "per_page": "$per_page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/fr/qonto.live.spec.ts b/packages/backend/src/adapters/fr/qonto.live.spec.ts new file mode 100644 index 00000000..d762caa5 --- /dev/null +++ b/packages/backend/src/adapters/fr/qonto.live.spec.ts @@ -0,0 +1,97 @@ +import * as adapter from './qonto.json'; + +const a = adapter as unknown as { + category: string; + requiredEnvVars: string[]; + instructions: string; + probe: { tool: string }; + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; description: string; endpointMapping: { method: string; path: string } }>; +}; + +// Values from docs.qonto.com: "API key" authentication page, "Pagination", +// "Accessing the Sandbox environment" and the Business API v2 reference. +describe('qonto adapter — static spec conformance', () => { + it('calls the production Business API v2', () => + expect(a.connector.baseUrl).toBe('https://thirdparty.qonto.com/v2')); + + it('sends login:secret-key in Authorization, no Bearer, no Base64', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + expect(a.connector.authConfig.apiKey).toBe('{{QONTO_LOGIN}}:{{QONTO_SECRET_KEY}}'); + expect(a.connector.authConfig.apiKey).toContain(':'); + expect(a.connector.authConfig.apiKey).not.toMatch(/bearer|basic/i); + expect(a.requiredEnvVars).toEqual(['QONTO_LOGIN', 'QONTO_SECRET_KEY']); + }); + + it('only reads: every tool is a GET', () => { + for (const tool of a.tools) { + expect(['POST', 'PUT', 'PATCH', 'DELETE']).not.toContain(tool.endpointMapping.method); + expect(tool.endpointMapping.method).toBe('GET'); + } + }); + + it('uses the documented endpoint paths', () => { + expect(Object.fromEntries(a.tools.map((t) => [t.name, t.endpointMapping.path]))).toEqual({ + qonto_get_organization: '/organization', + qonto_list_transactions: '/transactions', + qonto_get_transaction: '/transactions/{id}', + qonto_list_client_invoices: '/client_invoices', + qonto_list_supplier_invoices: '/supplier_invoices', + qonto_list_clients: '/clients', + qonto_list_quotes: '/quotes', + }); + }); + + it('is a banking adapter probed with the organization', () => { + expect(a.category).toBe('banking'); + expect(a.probe.tool).toBe('qonto_get_organization'); + }); + + it('tells the agent to fetch a bank account before listing transactions', () => { + const tx = a.tools.find((t) => t.name === 'qonto_list_transactions')!; + expect(tx.description).toContain('qonto_get_organization'); + expect(tx.description).toContain('only completed'); + }); + + it('does not suggest the deprecated to_pay supplier invoice status as a filter', () => { + const si = a.tools.find((t) => t.name === 'qonto_list_supplier_invoices')!; + expect(JSON.stringify(si)).toContain('to_review'); + expect(JSON.stringify(si)).toContain('query_fields'); + }); + + it('documents the sandbox host and staging header', () => { + expect(a.instructions).toContain('https://thirdparty-sandbox.staging.qonto.co/v2'); + expect(a.instructions).toContain('X-Qonto-Staging-Token'); + }); +}); + +// Opt-in live check against the Qonto sandbox. Needs QONTO_LOGIN, +// QONTO_SECRET_KEY and QONTO_STAGING_TOKEN; QONTO_BASE_URL overrides the host. +const live = process.env.RUN_QONTO_LIVE === '1'; +(live ? describe : describe.skip)('qonto — live (sandbox)', () => { + const base = process.env.QONTO_BASE_URL ?? 'https://thirdparty-sandbox.staging.qonto.co/v2'; + const headers: Record = { + Authorization: `${process.env.QONTO_LOGIN}:${process.env.QONTO_SECRET_KEY}`, + Accept: 'application/json', + }; + if (process.env.QONTO_STAGING_TOKEN) headers['X-Qonto-Staging-Token'] = process.env.QONTO_STAGING_TOKEN; + + let accountId: string | undefined; + + it('reads the organization and its bank accounts', async () => { + const res = await fetch(`${base}/organization`, { headers }); + expect(res.status).toBe(200); + const body = (await res.json()) as { organization: { bank_accounts: Array<{ id: string }> } }; + accountId = body.organization.bank_accounts[0]?.id; + expect(accountId).toBeTruthy(); + }); + + it('lists transactions of the first bank account', async () => { + const res = await fetch(`${base}/transactions?bank_account_id=${accountId}&per_page=5`, { headers }); + expect(res.status).toBe(200); + const body = (await res.json()) as { transactions: unknown[]; meta: { current_page: number } }; + expect(Array.isArray(body.transactions)).toBe(true); + expect(body.meta.current_page).toBe(1); + }); +}); diff --git a/packages/frontend/public/logos/connectors/qonto.svg b/packages/frontend/public/logos/connectors/qonto.svg new file mode 100644 index 00000000..c12a668e --- /dev/null +++ b/packages/frontend/public/logos/connectors/qonto.svg @@ -0,0 +1,8 @@ + + + + + + + +