Bernt Christian Egeland пре 15 часа
родитељ
комит
730e314cac
34 измењених фајлова са 3745 додато и 15 уклоњено
  1. 4 0
      .env.example
  2. 5 1
      .github/workflows/deploy-prod.yml
  3. 5 1
      .github/workflows/deploy-staging.yml
  4. 39 0
      messages/de/integrations.json
  5. 39 0
      messages/en/integrations.json
  6. 39 0
      messages/es/integrations.json
  7. 39 0
      messages/fr/integrations.json
  8. 39 0
      messages/it/integrations.json
  9. 39 0
      messages/lt/integrations.json
  10. 39 0
      messages/nb/integrations.json
  11. 39 0
      messages/nl/integrations.json
  12. 39 0
      messages/pl/integrations.json
  13. 39 0
      messages/pt-BR/integrations.json
  14. 39 0
      messages/pt-PT/integrations.json
  15. 39 0
      messages/ru/integrations.json
  16. 39 0
      messages/tr/integrations.json
  17. 1 0
      public/images/integrations/fiken.svg
  18. 1173 0
      src/__tests__/features/integrations/fiken-server.test.ts
  19. 367 0
      src/__tests__/features/integrations/fiken.test.ts
  20. 44 0
      src/__tests__/features/integrations/http.test.ts
  21. 68 0
      src/__tests__/features/integrations/oauth.test.ts
  22. 2 1
      src/__tests__/features/integrations/registry.test.ts
  23. 7 0
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  24. 1 0
      src/app/api/integrations/[connector]/oauth/callback/route.ts
  25. 18 0
      src/features/integrations/Lib/accounting-sync.ts
  26. 10 0
      src/features/integrations/Lib/checksum.ts
  27. 2 1
      src/features/integrations/Lib/http.ts
  28. 5 1
      src/features/integrations/Lib/oauth.ts
  29. 5 0
      src/features/integrations/Lib/types.ts
  30. 147 0
      src/integrations/fiken/manifest.ts
  31. 363 0
      src/integrations/fiken/mapping.ts
  32. 1008 0
      src/integrations/fiken/server.ts
  33. 1 10
      src/integrations/quickbooks/mapping.ts
  34. 2 0
      src/integrations/registry.ts

+ 4 - 0
.env.example

@@ -47,3 +47,7 @@ ZOOM_INTEGRATION_CLIENT_SECRET=""
 # the connector works out which it was given.
 QUICKBOOKS_INTEGRATION_CLIENT_ID=""
 QUICKBOOKS_INTEGRATION_CLIENT_SECRET=""
+# The app registered in Fiken under Rediger konto, API. While it is in
+# development only the Fiken users listed on the app can connect.
+FIKEN_INTEGRATION_CLIENT_ID=""
+FIKEN_INTEGRATION_CLIENT_SECRET=""

+ 5 - 1
.github/workflows/deploy-prod.yml

@@ -70,11 +70,13 @@ jobs:
           ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_SECRET }}
           QUICKBOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_ID }}
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
+          FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_ID }}
+          FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_SECRET }}
         run: |
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -120,6 +122,8 @@ jobs:
                 ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
                 QUICKBOOKS_INTEGRATION_CLIENT_ID: ${QUICKBOOKS_INTEGRATION_CLIENT_ID}
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
+                FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
+                FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
               networks:
                 - proxy
           COMPOSE

+ 5 - 1
.github/workflows/deploy-staging.yml

@@ -54,11 +54,13 @@ jobs:
           ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_ZOOM_INTEGRATION_CLIENT_SECRET }}
           QUICKBOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_QUICKBOOKS_INTEGRATION_CLIENT_ID }}
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
+          FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_ID }}
+          FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_SECRET }}
         run: |
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -101,6 +103,8 @@ jobs:
                 ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
                 QUICKBOOKS_INTEGRATION_CLIENT_ID: ${QUICKBOOKS_INTEGRATION_CLIENT_ID}
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
+                FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
+                FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
               networks:
                 - proxy
           COMPOSE

+ 39 - 0
messages/de/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Alle 30 Minuten. Eine in QuickBooks gegen eine Rechnung aus Torqvoice erhaltene Zahlung wird hier auf der Rechnung erfasst."
       }
     },
+    "fiken": {
+      "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an Fiken und hole dort erfasste Zahlungen zurück.",
+      "tenantHelp": "Erstelle in Fiken unter Rediger konto, API eine App für deine eigenen Firmen. Trage die Redirect-URI unten in der App ein und kopiere Client-ID und Client-Secret hierher. Die Firma braucht das API-Modul von Fiken.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "companySlug": "Firma in Fiken",
+        "companySlugHelp": "Die Firma, in deren Buchhaltung die Rechnungen gehen. Dein Fiken-Benutzer kann Zugriff auf mehrere haben.",
+        "pushInvoices": "Ausgestellte Rechnungen an Fiken senden",
+        "pushOnComplete": "Rechnungen auch senden, wenn der Auftrag als abgeschlossen markiert wird",
+        "pushOnCompleteHelp": "Ohne diese Option geht eine Rechnung erst über, wenn sie gesendet, geteilt oder bezahlt wurde. Mit ihr geht ein abgeschlossener Auftrag sofort über und wird bei Änderungen aktualisiert.",
+        "startDate": "Nur Rechnungen mit Datum ab",
+        "startDateHelp": "Leer lassen, um jede ausgestellte Rechnung zu senden. Nutze das Datum, an dem deine Bücher zu Fiken gewechselt sind, damit ältere Rechnungen nicht doppelt erfasst werden.",
+        "attachPdf": "Rechnungs-PDF anhängen",
+        "attachPdfHelp": "Eine Rechnung geht als Verkauf (Annet salg) mit ihrer Torqvoice-Nummer nach Fiken. Ist dies an, wird das PDF, das der Kunde erhalten hat, als Beleg am Verkauf abgelegt.",
+        "laborAccount": "Erlöskonto für Arbeit",
+        "laborAccountHelp": "Leer lassen, um 3000 zu verwenden.",
+        "partsAccount": "Erlöskonto für Teile",
+        "partsAccountHelp": "Leer lassen, um 3000 zu verwenden.",
+        "zeroVatType": "MwSt-Typ für Rechnungen ohne MwSt",
+        "zeroVatTypeHelp": "Wie Fiken einen Verkauf ohne MwSt bucht. Eine Firma, die nicht für die MwSt registriert ist, muss hier nichts wählen. Eine registrierte muss wählen, bevor eine solche Rechnung gesendet wird; frage deinen Buchhalter, was gilt.",
+        "vatNone": "Keine (keine MwSt-Behandlung)",
+        "vatExempt": "Befreit (fritatt)",
+        "vatOutside": "Außerhalb des MwSt-Bereichs (utenfor)",
+        "vatExport": "Befreit, Export",
+        "vatReverse": "Befreit, Reverse Charge",
+        "zeroAccount": "Erlöskonto für Rechnungen ohne MwSt",
+        "zeroAccountHelp": "Leer lassen, um die Konten für Arbeit und Teile oben zu verwenden. Lehnt Fiken das Konto mit dem MwSt-Typ ab, steht der Grund im Aktivitätsprotokoll.",
+        "pushPayments": "Zahlungen in Fiken erfassen",
+        "paymentAccount": "Zahlungen einzahlen auf",
+        "paymentAccountHelp": "Leer lassen, um das erste Bankkonto der Firma zu verwenden.",
+        "manualPaidAsPayment": "Zahlung erfassen, wenn eine Rechnung von Hand als bezahlt markiert wird",
+        "manualPaidAsPaymentHelp": "Eine in Torqvoice ohne Zahlungseintrag als bezahlt markierte Rechnung erhält in Fiken eine Zahlung über den dort noch offenen Betrag. Ausgeschaltet bleibt sie dort offen, bis eine Zahlung erfasst wird.",
+        "pullPayments": "In Fiken erfasste Zahlungen zurückholen",
+        "pullPaymentsHelp": "Alle 30 Minuten. Eine in Fiken gegen eine Rechnung aus Torqvoice erhaltene Zahlung wird hier auf der Rechnung erfasst."
+      }
+    },
     "nhtsa": {
       "description": "Fülle ein Fahrzeug aus seiner FIN aus und sieh Rückrufe, Halterbeschwerden und Crashtest-Bewertungen für jedes Modelljahr, von der US-Sicherheitsbehörde. Kostenlos, ohne Schlüssel.",
       "fields": {},

+ 39 - 0
messages/en/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Every 30 minutes. A payment received in QuickBooks against an invoice from Torqvoice is recorded on the invoice here."
       }
     },
+    "fiken": {
+      "description": "Send issued invoices, their customers and their payments to Fiken, and bring back payments recorded there.",
+      "tenantHelp": "Create an app in Fiken under Rediger konto, API, for your own companies. Enter the redirect URI below on the app, then copy its client ID and client secret here. The company needs Fiken's API module.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "companySlug": "Fiken company",
+        "companySlugHelp": "The company whose books the invoices go into. Your Fiken user may have access to several.",
+        "pushInvoices": "Send issued invoices to Fiken",
+        "pushOnComplete": "Also send invoices when the job is marked completed",
+        "pushOnCompleteHelp": "Without this an invoice goes over once it is sent, shared or paid. With it, a completed job goes over straight away and is updated if it changes.",
+        "startDate": "Only invoices dated on or after",
+        "startDateHelp": "Leave empty to send every issued invoice. Use the date your books moved to Fiken so older invoices are not entered twice.",
+        "attachPdf": "Attach the invoice PDF",
+        "attachPdfHelp": "An invoice goes into Fiken as a sale (Annet salg) under its Torqvoice number. With this on, the PDF the customer received is filed on the sale as its voucher.",
+        "laborAccount": "Income account for labour",
+        "laborAccountHelp": "Leave empty to use 3000.",
+        "partsAccount": "Income account for parts",
+        "partsAccountHelp": "Leave empty to use 3000.",
+        "zeroVatType": "VAT type for invoices without VAT",
+        "zeroVatTypeHelp": "How Fiken books a sale that carries no VAT. A company that is not VAT registered needs no choice here. A registered one must choose before such an invoice is sent; ask your accountant which applies.",
+        "vatNone": "None (no VAT handling)",
+        "vatExempt": "Exempt (fritatt)",
+        "vatOutside": "Outside the VAT area (utenfor)",
+        "vatExport": "Exempt, export",
+        "vatReverse": "Exempt, reverse charge",
+        "zeroAccount": "Income account for invoices without VAT",
+        "zeroAccountHelp": "Leave empty to use the labour and parts accounts above. If Fiken refuses the account with the VAT type, the reason is shown in the activity log.",
+        "pushPayments": "Record payments in Fiken",
+        "paymentAccount": "Deposit payments to",
+        "paymentAccountHelp": "Leave empty to use the company's first bank account.",
+        "manualPaidAsPayment": "Record a payment when an invoice is marked paid by hand",
+        "manualPaidAsPaymentHelp": "An invoice marked as paid in Torqvoice without a payment entry gets one payment in Fiken for whatever it still shows as owed. Off, it stays open there until a payment is recorded.",
+        "pullPayments": "Bring back payments recorded in Fiken",
+        "pullPaymentsHelp": "Every 30 minutes. A payment received in Fiken against an invoice from Torqvoice is recorded on the invoice here."
+      }
+    },
     "nhtsa": {
       "description": "Fill in a vehicle from its VIN, and see recalls, owner complaints and crash ratings for every model year, from the US safety authority. Free, no key.",
       "fields": {},

+ 39 - 0
messages/es/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Cada 30 minutos. Un pago recibido en QuickBooks contra una factura de Torqvoice se registra aquí en la factura."
       }
     },
+    "fiken": {
+      "description": "Envía las facturas emitidas, sus clientes y sus pagos a Fiken y trae de vuelta los pagos registrados allí.",
+      "tenantHelp": "Crea una app en Fiken en Rediger konto, API, para tus propias empresas. Introduce en la app la URI de redirección de abajo y copia aquí su ID de cliente y su secreto de cliente. La empresa necesita el módulo de API de Fiken.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "companySlug": "Empresa en Fiken",
+        "companySlugHelp": "La empresa en cuya contabilidad entran las facturas. Tu usuario de Fiken puede tener acceso a varias.",
+        "pushInvoices": "Enviar facturas emitidas a Fiken",
+        "pushOnComplete": "Enviar también las facturas cuando el trabajo se marque como completado",
+        "pushOnCompleteHelp": "Sin esto, una factura pasa cuando se envía, se comparte o se paga. Con esto, un trabajo completado pasa de inmediato y se actualiza si cambia.",
+        "startDate": "Solo facturas con fecha igual o posterior a",
+        "startDateHelp": "Déjalo vacío para enviar todas las facturas emitidas. Usa la fecha en que tu contabilidad pasó a Fiken para no registrar dos veces las facturas antiguas.",
+        "attachPdf": "Adjuntar el PDF de la factura",
+        "attachPdfHelp": "Una factura entra en Fiken como una venta (Annet salg) con su número de Torqvoice. Con esto activado, el PDF que recibió el cliente se archiva en la venta como justificante.",
+        "laborAccount": "Cuenta de ingresos para mano de obra",
+        "laborAccountHelp": "Déjalo vacío para usar la 3000.",
+        "partsAccount": "Cuenta de ingresos para piezas",
+        "partsAccountHelp": "Déjalo vacío para usar la 3000.",
+        "zeroVatType": "Tipo de IVA para facturas sin IVA",
+        "zeroVatTypeHelp": "Cómo contabiliza Fiken una venta sin IVA. Una empresa no registrada a efectos de IVA no necesita elegir nada aquí. Una registrada debe elegir antes de que se envíe una factura así; pregunta a tu contable cuál corresponde.",
+        "vatNone": "Ninguno (sin tratamiento de IVA)",
+        "vatExempt": "Exento (fritatt)",
+        "vatOutside": "Fuera del ámbito del IVA (utenfor)",
+        "vatExport": "Exento, exportación",
+        "vatReverse": "Exento, inversión del sujeto pasivo",
+        "zeroAccount": "Cuenta de ingresos para facturas sin IVA",
+        "zeroAccountHelp": "Déjalo vacío para usar las cuentas de mano de obra y piezas de arriba. Si Fiken rechaza la cuenta con el tipo de IVA, el motivo aparece en el registro de actividad.",
+        "pushPayments": "Registrar pagos en Fiken",
+        "paymentAccount": "Depositar pagos en",
+        "paymentAccountHelp": "Déjalo vacío para usar la primera cuenta bancaria de la empresa.",
+        "manualPaidAsPayment": "Registrar un pago cuando una factura se marca como pagada a mano",
+        "manualPaidAsPaymentHelp": "Una factura marcada como pagada en Torqvoice sin registro de pago recibe en Fiken un pago por lo que aún figure pendiente. Desactivado, queda abierta allí hasta que se registre un pago.",
+        "pullPayments": "Traer los pagos registrados en Fiken",
+        "pullPaymentsHelp": "Cada 30 minutos. Un pago recibido en Fiken contra una factura de Torqvoice se registra aquí en la factura."
+      }
+    },
     "nhtsa": {
       "description": "Rellena un vehículo a partir de su VIN y consulta llamadas a revisión, quejas de propietarios y calificaciones de choque de cada año de modelo, de la autoridad de seguridad de EE. UU. Gratis, sin clave.",
       "fields": {},

+ 39 - 0
messages/fr/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Toutes les 30 minutes. Un paiement reçu dans QuickBooks sur une facture Torqvoice est enregistré ici sur la facture."
       }
     },
+    "fiken": {
+      "description": "Envoyez les factures émises, leurs clients et leurs paiements vers Fiken, et récupérez les paiements enregistrés là-bas.",
+      "tenantHelp": "Créez une application dans Fiken sous Rediger konto, API, pour vos propres sociétés. Saisissez l'URI de redirection ci-dessous dans l'application, puis copiez ici son identifiant client et son secret client. La société doit avoir le module API de Fiken.",
+      "fields": {
+        "clientId": "Identifiant client",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "companySlug": "Société dans Fiken",
+        "companySlugHelp": "La société dont la comptabilité reçoit les factures. Votre utilisateur Fiken peut avoir accès à plusieurs sociétés.",
+        "pushInvoices": "Envoyer les factures émises vers Fiken",
+        "pushOnComplete": "Envoyer aussi les factures quand le travail est marqué terminé",
+        "pushOnCompleteHelp": "Sans cette option, une facture part une fois envoyée, partagée ou payée. Avec, un travail terminé part tout de suite et est mis à jour s'il change.",
+        "startDate": "Uniquement les factures datées à partir du",
+        "startDateHelp": "Laissez vide pour envoyer toutes les factures émises. Utilisez la date où votre comptabilité est passée sur Fiken pour ne pas saisir deux fois les anciennes factures.",
+        "attachPdf": "Joindre le PDF de la facture",
+        "attachPdfHelp": "Une facture entre dans Fiken comme une vente (Annet salg) sous son numéro Torqvoice. Avec cette option, le PDF reçu par le client est classé sur la vente comme pièce justificative.",
+        "laborAccount": "Compte de produits pour la main-d'œuvre",
+        "laborAccountHelp": "Laissez vide pour utiliser le 3000.",
+        "partsAccount": "Compte de produits pour les pièces",
+        "partsAccountHelp": "Laissez vide pour utiliser le 3000.",
+        "zeroVatType": "Type de TVA pour les factures sans TVA",
+        "zeroVatTypeHelp": "Comment Fiken comptabilise une vente sans TVA. Une société non assujettie à la TVA n'a rien à choisir ici. Une société assujettie doit choisir avant l'envoi d'une telle facture ; demandez à votre comptable ce qui s'applique.",
+        "vatNone": "Aucun (pas de traitement de TVA)",
+        "vatExempt": "Exonéré (fritatt)",
+        "vatOutside": "Hors champ de la TVA (utenfor)",
+        "vatExport": "Exonéré, exportation",
+        "vatReverse": "Exonéré, autoliquidation",
+        "zeroAccount": "Compte de produits pour les factures sans TVA",
+        "zeroAccountHelp": "Laissez vide pour utiliser les comptes main-d'œuvre et pièces ci-dessus. Si Fiken refuse le compte avec le type de TVA, la raison figure dans le journal d'activité.",
+        "pushPayments": "Enregistrer les paiements dans Fiken",
+        "paymentAccount": "Déposer les paiements sur",
+        "paymentAccountHelp": "Laissez vide pour utiliser le premier compte bancaire de la société.",
+        "manualPaidAsPayment": "Enregistrer un paiement quand une facture est marquée payée à la main",
+        "manualPaidAsPaymentHelp": "Une facture marquée payée dans Torqvoice sans écriture de paiement reçoit dans Fiken un paiement du montant qu'elle y affiche encore comme dû. Désactivé, elle y reste ouverte jusqu'à l'enregistrement d'un paiement.",
+        "pullPayments": "Récupérer les paiements enregistrés dans Fiken",
+        "pullPaymentsHelp": "Toutes les 30 minutes. Un paiement reçu dans Fiken sur une facture Torqvoice est enregistré ici sur la facture."
+      }
+    },
     "nhtsa": {
       "description": "Remplissez un véhicule à partir de son VIN et consultez les rappels, les plaintes de propriétaires et les notes aux crash-tests pour chaque année-modèle, auprès de l'autorité de sécurité américaine. Gratuit, sans clé.",
       "fields": {},

+ 39 - 0
messages/it/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Ogni 30 minuti. Un pagamento ricevuto in QuickBooks su una fattura di Torqvoice viene registrato qui sulla fattura."
       }
     },
+    "fiken": {
+      "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a Fiken e riporta i pagamenti registrati lì.",
+      "tenantHelp": "Crea un'app in Fiken in Rediger konto, API, per le tue aziende. Inserisci nell'app l'URI di reindirizzamento qui sotto, poi copia qui l'ID client e il segreto client. L'azienda deve avere il modulo API di Fiken.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client"
+      },
+      "settings": {
+        "companySlug": "Azienda in Fiken",
+        "companySlugHelp": "L'azienda nella cui contabilità entrano le fatture. Il tuo utente Fiken può avere accesso a più aziende.",
+        "pushInvoices": "Invia le fatture emesse a Fiken",
+        "pushOnComplete": "Invia le fatture anche quando il lavoro viene segnato come completato",
+        "pushOnCompleteHelp": "Senza questa opzione una fattura passa quando viene inviata, condivisa o pagata. Con questa, un lavoro completato passa subito e viene aggiornato se cambia.",
+        "startDate": "Solo fatture con data a partire dal",
+        "startDateHelp": "Lascia vuoto per inviare ogni fattura emessa. Usa la data in cui la contabilità è passata a Fiken, così le fatture più vecchie non vengono registrate due volte.",
+        "attachPdf": "Allega il PDF della fattura",
+        "attachPdfHelp": "Una fattura entra in Fiken come vendita (Annet salg) con il suo numero Torqvoice. Con questa opzione attiva, il PDF ricevuto dal cliente viene archiviato sulla vendita come giustificativo.",
+        "laborAccount": "Conto ricavi per la manodopera",
+        "laborAccountHelp": "Lascia vuoto per usare il 3000.",
+        "partsAccount": "Conto ricavi per i ricambi",
+        "partsAccountHelp": "Lascia vuoto per usare il 3000.",
+        "zeroVatType": "Tipo di IVA per le fatture senza IVA",
+        "zeroVatTypeHelp": "Come Fiken registra una vendita senza IVA. Un'azienda non registrata ai fini IVA non deve scegliere nulla qui. Una registrata deve scegliere prima che una fattura del genere venga inviata; chiedi al tuo commercialista quale si applica.",
+        "vatNone": "Nessuno (nessun trattamento IVA)",
+        "vatExempt": "Esente (fritatt)",
+        "vatOutside": "Fuori campo IVA (utenfor)",
+        "vatExport": "Esente, esportazione",
+        "vatReverse": "Esente, inversione contabile",
+        "zeroAccount": "Conto ricavi per le fatture senza IVA",
+        "zeroAccountHelp": "Lascia vuoto per usare i conti di manodopera e ricambi qui sopra. Se Fiken rifiuta il conto con il tipo di IVA, il motivo compare nel registro attività.",
+        "pushPayments": "Registra i pagamenti in Fiken",
+        "paymentAccount": "Deposita i pagamenti su",
+        "paymentAccountHelp": "Lascia vuoto per usare il primo conto bancario dell'azienda.",
+        "manualPaidAsPayment": "Registrare un pagamento quando una fattura viene segnata come pagata a mano",
+        "manualPaidAsPaymentHelp": "Una fattura segnata come pagata in Torqvoice senza registrazione di pagamento riceve in Fiken un pagamento per quanto risulta ancora dovuto lì. Disattivato, resta aperta lì finché non viene registrato un pagamento.",
+        "pullPayments": "Riporta i pagamenti registrati in Fiken",
+        "pullPaymentsHelp": "Ogni 30 minuti. Un pagamento ricevuto in Fiken su una fattura di Torqvoice viene registrato qui sulla fattura."
+      }
+    },
     "nhtsa": {
       "description": "Compila un veicolo dal suo VIN e consulta richiami, reclami dei proprietari e valutazioni dei crash test per ogni anno modello, dall'autorità per la sicurezza statunitense. Gratuito, senza chiave.",
       "fields": {},

+ 39 - 0
messages/lt/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Kas 30 minučių. QuickBooks gautas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos."
       }
     },
+    "fiken": {
+      "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į Fiken ir parsisiųskite ten užregistruotus mokėjimus.",
+      "tenantHelp": "Sukurkite programėlę Fiken skiltyje Rediger konto, API, savo įmonėms. Programėlėje įveskite žemiau pateiktą peradresavimo URI, tada čia nukopijuokite jos kliento ID ir kliento slaptažodį. Įmonei reikalingas Fiken API modulis.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento slaptažodis"
+      },
+      "settings": {
+        "companySlug": "Įmonė Fiken sistemoje",
+        "companySlugHelp": "Įmonė, į kurios apskaitą patenka sąskaitos. Jūsų Fiken naudotojas gali turėti prieigą prie kelių.",
+        "pushInvoices": "Siųsti išrašytas sąskaitas į Fiken",
+        "pushOnComplete": "Siųsti sąskaitas ir tada, kai darbas pažymimas kaip užbaigtas",
+        "pushOnCompleteHelp": "Be šio nustatymo sąskaita perduodama, kai ji išsiunčiama, pasidalinama ar apmokama. Su juo užbaigtas darbas perduodamas iš karto ir atnaujinamas, jei pasikeičia.",
+        "startDate": "Tik sąskaitos, datuotos nuo",
+        "startDateHelp": "Palikite tuščią, kad būtų siunčiamos visos išrašytos sąskaitos. Naudokite datą, kada apskaita perkelta į Fiken, kad senesnės sąskaitos nebūtų įvestos dukart.",
+        "attachPdf": "Pridėti sąskaitos PDF",
+        "attachPdfHelp": "Sąskaita į Fiken patenka kaip pardavimas (Annet salg) su savo Torqvoice numeriu. Įjungus, kliento gautas PDF pridedamas prie pardavimo kaip pagrindžiantis dokumentas.",
+        "laborAccount": "Pajamų sąskaita darbui",
+        "laborAccountHelp": "Palikite tuščią, kad būtų naudojama 3000.",
+        "partsAccount": "Pajamų sąskaita dalims",
+        "partsAccountHelp": "Palikite tuščią, kad būtų naudojama 3000.",
+        "zeroVatType": "PVM tipas sąskaitoms be PVM",
+        "zeroVatTypeHelp": "Kaip Fiken apskaito pardavimą be PVM. Įmonei, kuri nėra PVM mokėtoja, čia nieko rinktis nereikia. PVM mokėtoja turi pasirinkti prieš siunčiant tokią sąskaitą; paklauskite savo buhalterio, kuris tipas taikomas.",
+        "vatNone": "Nėra (PVM netaikomas)",
+        "vatExempt": "Atleista (fritatt)",
+        "vatOutside": "Už PVM taikymo srities (utenfor)",
+        "vatExport": "Atleista, eksportas",
+        "vatReverse": "Atleista, atvirkštinis apmokestinimas",
+        "zeroAccount": "Pajamų sąskaita sąskaitoms be PVM",
+        "zeroAccountHelp": "Palikite tuščią, kad būtų naudojamos aukščiau nurodytos darbo ir dalių sąskaitos. Jei Fiken atmeta sąskaitą su šiuo PVM tipu, priežastis rodoma veiklos žurnale.",
+        "pushPayments": "Registruoti mokėjimus Fiken",
+        "paymentAccount": "Mokėjimus įnešti į",
+        "paymentAccountHelp": "Palikite tuščią, kad būtų naudojama pirmoji įmonės banko sąskaita.",
+        "manualPaidAsPayment": "Registruoti mokėjimą, kai sąskaita ranka pažymima kaip apmokėta",
+        "manualPaidAsPaymentHelp": "Torqvoice be mokėjimo įrašo kaip apmokėta pažymėta sąskaita Fiken gauna vieną mokėjimą likusiai ten rodomai sumai. Išjungus ji ten lieka atvira, kol užregistruojamas mokėjimas.",
+        "pullPayments": "Parsisiųsti Fiken užregistruotus mokėjimus",
+        "pullPaymentsHelp": "Kas 30 minučių. Fiken gautas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos."
+      }
+    },
     "nhtsa": {
       "description": "Užpildykite transporto priemonę pagal VIN ir matykite kiekvienų modelio metų atšaukimus, savininkų skundus ir smūgio bandymų įvertinimus iš JAV saugos institucijos. Nemokamai, be rakto.",
       "fields": {},

+ 39 - 0
messages/nb/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Hvert 30. minutt. En betaling mottatt i QuickBooks mot en faktura fra Torqvoice registreres her på fakturaen."
       }
     },
+    "fiken": {
+      "description": "Send utstedte fakturaer, kundene deres og betalingene deres til Fiken, og hent tilbake betalinger registrert der.",
+      "tenantHelp": "Opprett en app i Fiken under Rediger konto, API, for dine egne foretak. Legg inn omdirigerings-URI-en nedenfor på appen, og kopier klient-ID og klienthemmelighet hit. Foretaket må ha Fikens API-modul.",
+      "fields": {
+        "clientId": "Klient-ID",
+        "clientSecret": "Klienthemmelighet"
+      },
+      "settings": {
+        "companySlug": "Foretak i Fiken",
+        "companySlugHelp": "Foretaket som fakturaene skal bokføres i. Fiken-brukeren din kan ha tilgang til flere.",
+        "pushInvoices": "Send utstedte fakturaer til Fiken",
+        "pushOnComplete": "Send også fakturaer når jobben markeres som fullført",
+        "pushOnCompleteHelp": "Uten dette går en faktura over når den er sendt, delt eller betalt. Med det går en fullført jobb over med en gang og oppdateres om den endres.",
+        "startDate": "Bare fakturaer datert fra og med",
+        "startDateHelp": "La stå tomt for å sende alle utstedte fakturaer. Bruk datoen regnskapet flyttet til Fiken, så eldre fakturaer ikke føres to ganger.",
+        "attachPdf": "Legg ved faktura-PDF-en",
+        "attachPdfHelp": "En faktura går inn i Fiken som et salg (Annet salg) med fakturanummeret fra Torqvoice. Med dette på legges PDF-en kunden fikk ved salget som bilag.",
+        "laborAccount": "Inntektskonto for arbeid",
+        "laborAccountHelp": "La stå tom for å bruke 3000.",
+        "partsAccount": "Inntektskonto for deler",
+        "partsAccountHelp": "La stå tom for å bruke 3000.",
+        "zeroVatType": "Mva-type for fakturaer uten mva",
+        "zeroVatTypeHelp": "Hvordan Fiken bokfører et salg uten mva. Et foretak som ikke er mva-registrert trenger ikke velge noe her. Et registrert foretak må velge før en slik faktura sendes; spør regnskapsføreren hva som gjelder.",
+        "vatNone": "Ingen (ingen mva-behandling)",
+        "vatExempt": "Fritatt",
+        "vatOutside": "Utenfor avgiftsområdet",
+        "vatExport": "Fritatt, eksport",
+        "vatReverse": "Fritatt, omvendt avgiftsplikt",
+        "zeroAccount": "Inntektskonto for fakturaer uten mva",
+        "zeroAccountHelp": "La stå tom for å bruke kontoene for arbeid og deler ovenfor. Hvis Fiken avviser kontoen sammen med mva-typen, står årsaken i aktivitetsloggen.",
+        "pushPayments": "Registrer betalinger i Fiken",
+        "paymentAccount": "Sett betalinger inn på",
+        "paymentAccountHelp": "La stå tom for å bruke foretakets første bankkonto.",
+        "manualPaidAsPayment": "Registrer en betaling når en faktura markeres som betalt for hånd",
+        "manualPaidAsPaymentHelp": "En faktura markert som betalt i Torqvoice uten betalingsføring får én betaling i Fiken for det den fortsatt viser som skyldig der. Avslått blir den stående åpen der til en betaling registreres.",
+        "pullPayments": "Hent tilbake betalinger registrert i Fiken",
+        "pullPaymentsHelp": "Hvert 30. minutt. En betaling mottatt i Fiken mot en faktura fra Torqvoice registreres her på fakturaen."
+      }
+    },
     "nhtsa": {
       "description": "Fyll ut et kjøretøy fra VIN, og se tilbakekallinger, eierklager og kollisjonsvurderinger for hver årsmodell, fra den amerikanske trafikksikkerhetsmyndigheten. Gratis, uten nøkkel.",
       "fields": {},

+ 39 - 0
messages/nl/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Elke 30 minuten. Een betaling die in QuickBooks op een factuur van Torqvoice is ontvangen, wordt hier op de factuur geregistreerd."
       }
     },
+    "fiken": {
+      "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar Fiken en haal daar geregistreerde betalingen terug.",
+      "tenantHelp": "Maak in Fiken onder Rediger konto, API een app aan voor je eigen bedrijven. Vul de redirect-URI hieronder in bij de app en kopieer de client-ID en het client-secret hierheen. Het bedrijf heeft de API-module van Fiken nodig.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Clientgeheim"
+      },
+      "settings": {
+        "companySlug": "Bedrijf in Fiken",
+        "companySlugHelp": "Het bedrijf in wiens boekhouding de facturen komen. Je Fiken-gebruiker kan toegang hebben tot meerdere.",
+        "pushInvoices": "Uitgegeven facturen naar Fiken sturen",
+        "pushOnComplete": "Facturen ook sturen zodra de klus als voltooid is gemarkeerd",
+        "pushOnCompleteHelp": "Zonder deze optie gaat een factuur over zodra hij is verzonden, gedeeld of betaald. Met deze optie gaat een voltooide klus direct over en wordt hij bijgewerkt als er iets verandert.",
+        "startDate": "Alleen facturen gedateerd op of na",
+        "startDateHelp": "Laat leeg om elke uitgegeven factuur te sturen. Gebruik de datum waarop je boekhouding naar Fiken is overgegaan, zodat oudere facturen niet dubbel worden ingevoerd.",
+        "attachPdf": "Factuur-pdf bijvoegen",
+        "attachPdfHelp": "Een factuur komt in Fiken als een verkoop (Annet salg) met zijn Torqvoice-nummer. Met dit aan wordt de pdf die de klant ontving als boekstuk bij de verkoop bewaard.",
+        "laborAccount": "Omzetrekening voor arbeid",
+        "laborAccountHelp": "Laat leeg om 3000 te gebruiken.",
+        "partsAccount": "Omzetrekening voor onderdelen",
+        "partsAccountHelp": "Laat leeg om 3000 te gebruiken.",
+        "zeroVatType": "Btw-type voor facturen zonder btw",
+        "zeroVatTypeHelp": "Hoe Fiken een verkoop zonder btw boekt. Een bedrijf dat niet btw-geregistreerd is, hoeft hier niets te kiezen. Een geregistreerd bedrijf moet kiezen voordat zo'n factuur wordt verzonden; vraag je boekhouder welke van toepassing is.",
+        "vatNone": "Geen (geen btw-behandeling)",
+        "vatExempt": "Vrijgesteld (fritatt)",
+        "vatOutside": "Buiten het btw-gebied (utenfor)",
+        "vatExport": "Vrijgesteld, export",
+        "vatReverse": "Vrijgesteld, verlegd",
+        "zeroAccount": "Omzetrekening voor facturen zonder btw",
+        "zeroAccountHelp": "Laat leeg om de rekeningen voor arbeid en onderdelen hierboven te gebruiken. Weigert Fiken de rekening met het btw-type, dan staat de reden in het activiteitenlogboek.",
+        "pushPayments": "Betalingen in Fiken registreren",
+        "paymentAccount": "Betalingen storten op",
+        "paymentAccountHelp": "Laat leeg om de eerste bankrekening van het bedrijf te gebruiken.",
+        "manualPaidAsPayment": "Een betaling registreren als een factuur handmatig als betaald wordt gemarkeerd",
+        "manualPaidAsPaymentHelp": "Een factuur die in Torqvoice zonder betalingsregistratie als betaald is gemarkeerd, krijgt in Fiken één betaling voor wat daar nog openstaat. Uit blijft hij daar open tot er een betaling wordt geregistreerd.",
+        "pullPayments": "In Fiken geregistreerde betalingen terughalen",
+        "pullPaymentsHelp": "Elke 30 minuten. Een betaling die in Fiken op een factuur van Torqvoice is ontvangen, wordt hier op de factuur geregistreerd."
+      }
+    },
     "nhtsa": {
       "description": "Vul een voertuig in vanuit het VIN en bekijk terugroepacties, klachten van eigenaren en botsproefscores per modeljaar, van de Amerikaanse verkeersveiligheidsautoriteit. Gratis, zonder sleutel.",
       "fields": {},

+ 39 - 0
messages/pl/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Co 30 minut. Płatność przyjęta w QuickBooks za fakturę z Torqvoice zostaje zarejestrowana tutaj na fakturze."
       }
     },
+    "fiken": {
+      "description": "Wysyłaj wystawione faktury, ich klientów i płatności do Fiken i pobieraj płatności zarejestrowane tam.",
+      "tenantHelp": "Utwórz aplikację w Fiken w sekcji Rediger konto, API, dla własnych firm. Wpisz w aplikacji poniższy adres URI przekierowania, a następnie skopiuj tutaj jej identyfikator klienta i sekret klienta. Firma musi mieć moduł API Fiken.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "companySlug": "Firma w Fiken",
+        "companySlugHelp": "Firma, do której ksiąg trafiają faktury. Twój użytkownik Fiken może mieć dostęp do kilku.",
+        "pushInvoices": "Wysyłaj wystawione faktury do Fiken",
+        "pushOnComplete": "Wysyłaj faktury także wtedy, gdy zlecenie zostanie oznaczone jako ukończone",
+        "pushOnCompleteHelp": "Bez tej opcji faktura trafia do księgowości po wysłaniu, udostępnieniu lub opłaceniu. Z nią ukończone zlecenie trafia od razu i jest aktualizowane, gdy się zmieni.",
+        "startDate": "Tylko faktury z datą od",
+        "startDateHelp": "Zostaw puste, aby wysyłać każdą wystawioną fakturę. Użyj daty przeniesienia księgowości do Fiken, aby starsze faktury nie zostały wprowadzone dwa razy.",
+        "attachPdf": "Dołącz PDF faktury",
+        "attachPdfHelp": "Faktura trafia do Fiken jako sprzedaż (Annet salg) ze swoim numerem z Torqvoice. Po włączeniu PDF, który otrzymał klient, jest zapisywany przy sprzedaży jako dowód księgowy.",
+        "laborAccount": "Konto przychodów dla robocizny",
+        "laborAccountHelp": "Pozostaw puste, aby użyć 3000.",
+        "partsAccount": "Konto przychodów dla części",
+        "partsAccountHelp": "Pozostaw puste, aby użyć 3000.",
+        "zeroVatType": "Typ VAT dla faktur bez VAT",
+        "zeroVatTypeHelp": "Jak Fiken księguje sprzedaż bez VAT. Firma niezarejestrowana jako podatnik VAT nie musi tu niczego wybierać. Zarejestrowana musi wybrać, zanim taka faktura zostanie wysłana; zapytaj księgowego, który typ ma zastosowanie.",
+        "vatNone": "Brak (bez obsługi VAT)",
+        "vatExempt": "Zwolniona (fritatt)",
+        "vatOutside": "Poza zakresem VAT (utenfor)",
+        "vatExport": "Zwolniona, eksport",
+        "vatReverse": "Zwolniona, odwrotne obciążenie",
+        "zeroAccount": "Konto przychodów dla faktur bez VAT",
+        "zeroAccountHelp": "Pozostaw puste, aby użyć powyższych kont robocizny i części. Jeśli Fiken odrzuci konto z tym typem VAT, powód pojawi się w dzienniku aktywności.",
+        "pushPayments": "Rejestruj płatności w Fiken",
+        "paymentAccount": "Wpłacaj płatności na",
+        "paymentAccountHelp": "Pozostaw puste, aby użyć pierwszego konta bankowego firmy.",
+        "manualPaidAsPayment": "Rejestruj płatność, gdy faktura zostanie ręcznie oznaczona jako opłacona",
+        "manualPaidAsPaymentHelp": "Faktura oznaczona w Torqvoice jako opłacona bez wpisu płatności otrzymuje w Fiken jedną płatność na kwotę, która tam nadal figuruje jako należna. Wyłączone: pozostaje tam otwarta do czasu zarejestrowania płatności.",
+        "pullPayments": "Pobieraj płatności zarejestrowane w Fiken",
+        "pullPaymentsHelp": "Co 30 minut. Płatność przyjęta w Fiken za fakturę z Torqvoice zostaje zarejestrowana tutaj na fakturze."
+      }
+    },
     "nhtsa": {
       "description": "Uzupełnij pojazd na podstawie VIN i zobacz akcje serwisowe, skargi właścicieli i oceny z testów zderzeniowych dla każdego rocznika, z amerykańskiego urzędu bezpieczeństwa ruchu. Za darmo, bez klucza.",
       "fields": {},

+ 39 - 0
messages/pt-BR/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no QuickBooks para uma fatura do Torqvoice é registrado aqui na fatura."
       }
     },
+    "fiken": {
+      "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o Fiken e traga de volta os pagamentos registrados lá.",
+      "tenantHelp": "Crie um app no Fiken em Rediger konto, API, para as suas próprias empresas. Informe no app a URI de redirecionamento abaixo e copie para cá o ID do cliente e o segredo do cliente. A empresa precisa do módulo de API do Fiken.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "companySlug": "Empresa no Fiken",
+        "companySlugHelp": "A empresa em cuja contabilidade as faturas entram. Seu usuário do Fiken pode ter acesso a várias.",
+        "pushInvoices": "Enviar faturas emitidas para o Fiken",
+        "pushOnComplete": "Enviar faturas também quando o serviço for marcado como concluído",
+        "pushOnCompleteHelp": "Sem isso, uma fatura vai quando é enviada, compartilhada ou paga. Com isso, um serviço concluído vai na hora e é atualizado se mudar.",
+        "startDate": "Somente faturas com data a partir de",
+        "startDateHelp": "Deixe vazio para enviar toda fatura emitida. Use a data em que sua contabilidade passou para o Fiken para não lançar faturas antigas duas vezes.",
+        "attachPdf": "Anexar o PDF da fatura",
+        "attachPdfHelp": "Uma fatura entra no Fiken como uma venda (Annet salg) com o seu número do Torqvoice. Com isto ativado, o PDF que o cliente recebeu é arquivado na venda como comprovante.",
+        "laborAccount": "Conta de receita para mão de obra",
+        "laborAccountHelp": "Deixe vazio para usar a 3000.",
+        "partsAccount": "Conta de receita para peças",
+        "partsAccountHelp": "Deixe vazio para usar a 3000.",
+        "zeroVatType": "Tipo de IVA para faturas sem IVA",
+        "zeroVatTypeHelp": "Como o Fiken contabiliza uma venda sem IVA. Uma empresa não registrada para IVA não precisa escolher nada aqui. Uma registrada precisa escolher antes que uma fatura assim seja enviada; pergunte ao seu contador qual se aplica.",
+        "vatNone": "Nenhum (sem tratamento de IVA)",
+        "vatExempt": "Isento (fritatt)",
+        "vatOutside": "Fora do âmbito do IVA (utenfor)",
+        "vatExport": "Isento, exportação",
+        "vatReverse": "Isento, inversão do sujeito passivo",
+        "zeroAccount": "Conta de receita para faturas sem IVA",
+        "zeroAccountHelp": "Deixe vazio para usar as contas de mão de obra e peças acima. Se o Fiken recusar a conta com o tipo de IVA, o motivo aparece no registro de atividade.",
+        "pushPayments": "Registrar pagamentos no Fiken",
+        "paymentAccount": "Depositar pagamentos em",
+        "paymentAccountHelp": "Deixe vazio para usar a primeira conta bancária da empresa.",
+        "manualPaidAsPayment": "Registrar um pagamento quando uma fatura for marcada como paga manualmente",
+        "manualPaidAsPaymentHelp": "Uma fatura marcada como paga no Torqvoice sem lançamento de pagamento recebe no Fiken um pagamento pelo valor que ainda consta em aberto lá. Desligado, ela fica aberta lá até que um pagamento seja registrado.",
+        "pullPayments": "Trazer de volta pagamentos registrados no Fiken",
+        "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no Fiken para uma fatura do Torqvoice é registrado aqui na fatura."
+      }
+    },
     "nhtsa": {
       "description": "Preencha um veículo a partir do VIN e veja recalls, reclamações de proprietários e notas de testes de colisão de cada ano-modelo, da autoridade de segurança dos EUA. Grátis, sem chave.",
       "fields": {},

+ 39 - 0
messages/pt-PT/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no QuickBooks para uma fatura do Torqvoice é registado aqui na fatura."
       }
     },
+    "fiken": {
+      "description": "Envie as faturas emitidas, os respetivos clientes e pagamentos para o Fiken e receba de volta os pagamentos lá registados.",
+      "tenantHelp": "Crie uma aplicação no Fiken em Rediger konto, API, para as suas próprias empresas. Introduza na aplicação o URI de redirecionamento abaixo e copie para aqui o ID de cliente e o segredo de cliente. A empresa precisa do módulo de API do Fiken.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "companySlug": "Empresa no Fiken",
+        "companySlugHelp": "A empresa em cuja contabilidade as faturas entram. O seu utilizador do Fiken pode ter acesso a várias.",
+        "pushInvoices": "Enviar as faturas emitidas para o Fiken",
+        "pushOnComplete": "Enviar faturas também quando o serviço for marcado como concluído",
+        "pushOnCompleteHelp": "Sem isto, uma fatura é enviada quando é enviada ao cliente, partilhada ou paga. Com isto, um serviço concluído é enviado de imediato e atualizado se mudar.",
+        "startDate": "Só faturas com data a partir de",
+        "startDateHelp": "Deixe vazio para enviar todas as faturas emitidas. Utilize a data em que a sua contabilidade passou para o Fiken, para não lançar faturas antigas duas vezes.",
+        "attachPdf": "Anexar o PDF da fatura",
+        "attachPdfHelp": "Uma fatura entra no Fiken como uma venda (Annet salg) com o seu número do Torqvoice. Com isto ativo, o PDF que o cliente recebeu é arquivado na venda como documento de suporte.",
+        "laborAccount": "Conta de rendimentos para mão de obra",
+        "laborAccountHelp": "Deixe vazio para usar a 3000.",
+        "partsAccount": "Conta de rendimentos para peças",
+        "partsAccountHelp": "Deixe vazio para usar a 3000.",
+        "zeroVatType": "Tipo de IVA para faturas sem IVA",
+        "zeroVatTypeHelp": "Como o Fiken contabiliza uma venda sem IVA. Uma empresa não registada para IVA não precisa de escolher nada aqui. Uma registada tem de escolher antes de uma fatura assim ser enviada; pergunte ao seu contabilista qual se aplica.",
+        "vatNone": "Nenhum (sem tratamento de IVA)",
+        "vatExempt": "Isento (fritatt)",
+        "vatOutside": "Fora do âmbito do IVA (utenfor)",
+        "vatExport": "Isento, exportação",
+        "vatReverse": "Isento, autoliquidação",
+        "zeroAccount": "Conta de rendimentos para faturas sem IVA",
+        "zeroAccountHelp": "Deixe vazio para usar as contas de mão de obra e peças acima. Se o Fiken recusar a conta com o tipo de IVA, o motivo aparece no registo de atividade.",
+        "pushPayments": "Registar pagamentos no Fiken",
+        "paymentAccount": "Depositar pagamentos em",
+        "paymentAccountHelp": "Deixe vazio para usar a primeira conta bancária da empresa.",
+        "manualPaidAsPayment": "Registar um pagamento quando uma fatura for marcada manualmente como paga",
+        "manualPaidAsPaymentHelp": "Uma fatura marcada como paga no Torqvoice sem lançamento de pagamento recebe no Fiken um pagamento pelo valor que lá ainda consta em aberto. Se estiver desativado, fica lá em aberto até ser registado um pagamento.",
+        "pullPayments": "Receber de volta os pagamentos registados no Fiken",
+        "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no Fiken para uma fatura do Torqvoice é registado aqui na fatura."
+      }
+    },
     "nhtsa": {
       "description": "Preencha uma viatura a partir do VIN e veja recalls, reclamações de proprietários e classificações de testes de colisão de cada ano-modelo, da autoridade de segurança dos EUA. Gratuito, sem chave.",
       "fields": {},

+ 39 - 0
messages/ru/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Каждые 30 минут. Платёж, полученный в QuickBooks по счёту из Torqvoice, записывается здесь на счёт."
       }
     },
+    "fiken": {
+      "description": "Отправляйте выставленные счета, их клиентов и платежи в Fiken и получайте обратно платежи, записанные там.",
+      "tenantHelp": "Создайте приложение в Fiken в разделе Rediger konto, API для своих компаний. Укажите в приложении URI перенаправления ниже и скопируйте сюда его идентификатор клиента и секрет клиента. Компании нужен модуль API Fiken.",
+      "fields": {
+        "clientId": "Идентификатор клиента",
+        "clientSecret": "Секрет клиента"
+      },
+      "settings": {
+        "companySlug": "Компания в Fiken",
+        "companySlugHelp": "Компания, в учёт которой попадают счета. У вашего пользователя Fiken может быть доступ к нескольким.",
+        "pushInvoices": "Отправлять выставленные счета в Fiken",
+        "pushOnComplete": "Также отправлять счета, когда заказ отмечен как выполненный",
+        "pushOnCompleteHelp": "Без этого счёт уходит, когда он отправлен, передан по ссылке или оплачен. С этим выполненный заказ уходит сразу и обновляется при изменениях.",
+        "startDate": "Только счета с датой не раньше",
+        "startDateHelp": "Оставьте пустым, чтобы отправлять все выставленные счета. Укажите дату перехода бухгалтерии на Fiken, чтобы старые счета не вводились дважды.",
+        "attachPdf": "Прикреплять PDF счёта",
+        "attachPdfHelp": "Счёт попадает в Fiken как продажа (Annet salg) под своим номером из Torqvoice. Если включено, PDF, который получил клиент, сохраняется при продаже как первичный документ.",
+        "laborAccount": "Счёт доходов для работ",
+        "laborAccountHelp": "Оставьте пустым, чтобы использовать 3000.",
+        "partsAccount": "Счёт доходов для запчастей",
+        "partsAccountHelp": "Оставьте пустым, чтобы использовать 3000.",
+        "zeroVatType": "Тип НДС для счетов без НДС",
+        "zeroVatTypeHelp": "Как Fiken проводит продажу без НДС. Компании, не зарегистрированной как плательщик НДС, здесь ничего выбирать не нужно. Зарегистрированная должна выбрать до отправки такого счёта; уточните у бухгалтера, какой тип применяется.",
+        "vatNone": "Нет (без учёта НДС)",
+        "vatExempt": "Освобождено (fritatt)",
+        "vatOutside": "Вне сферы НДС (utenfor)",
+        "vatExport": "Освобождено, экспорт",
+        "vatReverse": "Освобождено, обратное начисление",
+        "zeroAccount": "Счёт доходов для счетов без НДС",
+        "zeroAccountHelp": "Оставьте пустым, чтобы использовать счета для работ и запчастей выше. Если Fiken отклонит счёт с этим типом НДС, причина будет в журнале активности.",
+        "pushPayments": "Записывать платежи в Fiken",
+        "paymentAccount": "Зачислять платежи на",
+        "paymentAccountHelp": "Оставьте пустым, чтобы использовать первый банковский счёт компании.",
+        "manualPaidAsPayment": "Записывать платёж, когда счёт отмечен оплаченным вручную",
+        "manualPaidAsPaymentHelp": "Счёт, отмеченный в Torqvoice как оплаченный без записи платежа, получает в Fiken один платёж на сумму, которая там ещё числится к оплате. Выключено: он остаётся там открытым, пока не будет записан платёж.",
+        "pullPayments": "Забирать платежи, записанные в Fiken",
+        "pullPaymentsHelp": "Каждые 30 минут. Платёж, полученный в Fiken по счёту из Torqvoice, записывается здесь на счёт."
+      }
+    },
     "nhtsa": {
       "description": "Заполните автомобиль по VIN и смотрите отзывные кампании, жалобы владельцев и рейтинги краш-тестов для каждого модельного года от американского ведомства безопасности. Бесплатно, без ключа.",
       "fields": {},

+ 39 - 0
messages/tr/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Her 30 dakikada bir. QuickBooks'ta bir Torqvoice faturası için alınan ödeme burada faturaya kaydedilir."
       }
     },
+    "fiken": {
+      "description": "Kesilen faturaları, müşterilerini ve ödemelerini Fiken'e gönderin ve orada kaydedilen ödemeleri geri alın.",
+      "tenantHelp": "Fiken'de Rediger konto, API altında kendi şirketleriniz için bir uygulama oluşturun. Aşağıdaki yönlendirme URI'sini uygulamaya girin, ardından istemci kimliğini ve istemci gizli anahtarını buraya kopyalayın. Şirketin Fiken API modülüne ihtiyacı vardır.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci gizli anahtarı"
+      },
+      "settings": {
+        "companySlug": "Fiken şirketi",
+        "companySlugHelp": "Faturaların defterlerine işlendiği şirket. Fiken kullanıcınızın birden fazla şirkete erişimi olabilir.",
+        "pushInvoices": "Kesilen faturaları Fiken'e gönder",
+        "pushOnComplete": "İş tamamlandı olarak işaretlendiğinde faturaları da gönder",
+        "pushOnCompleteHelp": "Bu olmadan fatura gönderildiğinde, paylaşıldığında veya ödendiğinde aktarılır. Bununla tamamlanan iş hemen aktarılır ve değişirse güncellenir.",
+        "startDate": "Yalnızca şu tarih ve sonrasındaki faturalar",
+        "startDateHelp": "Kesilen her faturayı göndermek için boş bırakın. Eski faturaların iki kez girilmemesi için defterlerinizin Fiken'e taşındığı tarihi kullanın.",
+        "attachPdf": "Fatura PDF'ini ekle",
+        "attachPdfHelp": "Bir fatura Fiken'e Torqvoice numarasıyla bir satış (Annet salg) olarak girer. Bu açıkken, müşterinin aldığı PDF satışa belge olarak eklenir.",
+        "laborAccount": "İşçilik için gelir hesabı",
+        "laborAccountHelp": "3000 kullanmak için boş bırakın.",
+        "partsAccount": "Parçalar için gelir hesabı",
+        "partsAccountHelp": "3000 kullanmak için boş bırakın.",
+        "zeroVatType": "KDV'siz faturalar için KDV türü",
+        "zeroVatTypeHelp": "Fiken'in KDV içermeyen bir satışı nasıl kaydettiği. KDV mükellefi olmayan bir şirketin burada bir şey seçmesi gerekmez. Mükellef olan bir şirket, böyle bir fatura gönderilmeden önce seçim yapmalıdır; hangisinin geçerli olduğunu muhasebecinize sorun.",
+        "vatNone": "Yok (KDV işlemi yok)",
+        "vatExempt": "Muaf (fritatt)",
+        "vatOutside": "KDV kapsamı dışında (utenfor)",
+        "vatExport": "Muaf, ihracat",
+        "vatReverse": "Muaf, ters yükümlülük",
+        "zeroAccount": "KDV'siz faturalar için gelir hesabı",
+        "zeroAccountHelp": "Yukarıdaki işçilik ve parça hesaplarını kullanmak için boş bırakın. Fiken hesabı bu KDV türüyle reddederse nedeni etkinlik günlüğünde görünür.",
+        "pushPayments": "Ödemeleri Fiken'e kaydet",
+        "paymentAccount": "Ödemeleri şuraya yatır",
+        "paymentAccountHelp": "Şirketin ilk banka hesabını kullanmak için boş bırakın.",
+        "manualPaidAsPayment": "Fatura elle ödendi olarak işaretlendiğinde ödeme kaydet",
+        "manualPaidAsPaymentHelp": "Torqvoice'ta ödeme kaydı olmadan ödendi olarak işaretlenen bir fatura, Fiken'de hâlâ alacak görünen tutar kadar tek bir ödeme alır. Kapalıysa bir ödeme kaydedilene kadar orada açık kalır.",
+        "pullPayments": "Fiken'de kaydedilen ödemeleri geri al",
+        "pullPaymentsHelp": "Her 30 dakikada bir. Fiken'de bir Torqvoice faturası için alınan ödeme burada faturaya kaydedilir."
+      }
+    },
     "nhtsa": {
       "description": "Bir aracı VIN'inden doldurun ve her model yılı için geri çağırmaları, sahip şikâyetlerini ve çarpışma testi puanlarını ABD güvenlik kurumundan görün. Ücretsiz, anahtarsız.",
       "fields": {},

+ 1 - 0
public/images/integrations/fiken.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Fiken"><title>Fiken</title><rect width="48" height="48" rx="11" fill="#F1EEFB"/><g transform="translate(9.9 7.5)" fill-rule="evenodd" clip-rule="evenodd"><path fill="#75ABF7" d="M12.46 15.17c-.72 0-1.4-.28-1.91-.78-.5-.5-.8-1.18-.8-1.89v-1.03C9.76 5.67 14.52.97 20.4.94c2.13 0 4.2.63 5.96 1.8a2.64 2.64 0 0 1 .7 3.68 2.74 2.74 0 0 1-3.72.75 5.3 5.3 0 0 0-2.94-.88 5.2 5.2 0 0 0-5.24 5.18v1.04c0 .7-.28 1.38-.79 1.88-.5.5-1.19.79-1.9.79"/><path fill="#5239BA" d="M21.45 17.68h-6.29v-6.2a2.7 2.7 0 0 0-1.32-2.39c-.85-.5-1.9-.5-2.76 0s-1.35 1.41-1.32 2.38v6.22H3.47a2.7 2.7 0 0 0-2.71 2.67 2.7 2.7 0 0 0 2.71 2.67h6.29v6.21c-.02.97.49 1.87 1.33 2.36s1.9.49 2.74 0a2.7 2.7 0 0 0 1.33-2.36v-6.22h6.3a2.7 2.7 0 0 0 2.7-2.67 2.7 2.7 0 0 0-2.7-2.67"/></g></svg>

+ 1173 - 0
src/__tests__/features/integrations/fiken-server.test.ts

@@ -0,0 +1,1173 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+  AccountingPayment,
+} from '@/features/integrations/Lib/accounting-sync'
+import type { ConnectorContext, LinkRecord, LogLevel } from '@/features/integrations/Lib/types'
+
+/**
+ * The connector against a scripted Fiken: every request it makes is recorded
+ * and answered from here, so the exact URLs, query parameters and bodies
+ * that would reach Fiken are asserted without a company. The loaders that
+ * read this app's database are replaced with fixtures.
+ */
+
+const loadInvoice = vi.fn<(orgId: string, id: string) => Promise<AccountingInvoice | null>>()
+const loadPayment = vi.fn<(orgId: string, id: string) => Promise<AccountingPayment | null>>()
+const loadCustomer = vi.fn<(orgId: string, id: string) => Promise<AccountingCustomer | null>>()
+const loadPdf = vi.fn<() => Promise<{ buffer: Uint8Array; filename: string } | null>>()
+const recordPulledPayment = vi.fn()
+const removePulledPayment = vi.fn()
+const workshopCurrency = vi.fn<() => Promise<string | null>>()
+
+vi.mock('@/features/integrations/Lib/accounting-sync', () => ({
+  INVOICE_ENTITY: 'ServiceRecord',
+  CUSTOMER_ENTITY: 'Customer',
+  PAYMENT_ENTITY: 'Payment',
+  loadInvoiceForAccounting: (orgId: string, id: string) => loadInvoice(orgId, id),
+  loadPaymentForAccounting: (orgId: string, id: string) => loadPayment(orgId, id),
+  loadCustomerForAccounting: (orgId: string, id: string) => loadCustomer(orgId, id),
+  loadInvoicePdfForAccounting: () => loadPdf(),
+  recordPulledPayment: (...args: unknown[]) => recordPulledPayment(...args),
+  removePulledPayment: (...args: unknown[]) => removePulledPayment(...args),
+  workshopCurrency: () => workshopCurrency(),
+}))
+
+const { connector } = await import('@/integrations/fiken/server')
+
+const SLUG = 'verksted-as'
+const BASE = `/api/v2/companies/${SLUG}`
+
+interface Call {
+  method: string
+  host: string
+  path: string
+  query: Record<string, string>
+  body: Record<string, unknown> | null
+  form: Record<string, string> | null
+  headers: Record<string, string>
+}
+
+interface Reply {
+  status?: number
+  json?: unknown
+  /** The id a create answers with, sent back the way Fiken does: in Location. */
+  created?: number
+  headers?: Record<string, string>
+}
+
+type Answer = (call: Call) => Reply
+
+function respond(call: Call, reply: Reply): Response {
+  const headers = new Headers(reply.headers)
+  if (reply.created) {
+    headers.set('Location', `https://api.fiken.no${call.path}/${reply.created}`)
+    return new Response(null, { status: 201, headers })
+  }
+  const status = reply.status ?? 200
+  if (status === 204) return new Response(null, { status, headers })
+  return new Response(reply.json === undefined ? '' : JSON.stringify(reply.json), {
+    status,
+    headers,
+  })
+}
+
+function makeCtx(input: {
+  settings?: Record<string, unknown>
+  state?: Record<string, unknown>
+  answer: Answer
+}) {
+  const calls: Call[] = []
+  const links = new Map<string, LinkRecord & { entityId: string; entityType: string }>()
+  const logs: { level: LogLevel; message: string }[] = []
+  const state: Record<string, unknown> = {
+    // Known and VAT registered, so a push does not read the company unless a test asks.
+    company: { slug: SLUG, vatRegistered: true, fetchedAt: new Date().toISOString() },
+    ...input.state,
+  }
+  const key = (t: string, e: string) => `${t}:${e}`
+  const ctx: ConnectorContext = {
+    connection: {
+      id: `conn-${Math.random()}`,
+      organizationId: 'org1',
+      connectorId: 'fiken',
+      settings: { companySlug: SLUG, paymentAccount: '1920:10001', ...input.settings },
+      state,
+      externalAccountId: 'fiken-x',
+    },
+    credentials: { accessToken: 'tok', refreshToken: 'ref' },
+    http: {
+      async fetch(url: string, init?: RequestInit): Promise<Response> {
+        const u = new URL(url)
+        const body = init?.body
+        const call: Call = {
+          method: init?.method ?? 'GET',
+          host: u.host,
+          path: u.pathname,
+          query: Object.fromEntries(u.searchParams.entries()),
+          body: typeof body === 'string' ? (JSON.parse(body) as Record<string, unknown>) : null,
+          form:
+            body instanceof FormData
+              ? Object.fromEntries(
+                  [...body.entries()].map(([k, v]) => [
+                    k,
+                    typeof v === 'string' ? v : `file:${v.name}:${v.type}:${v.size}`,
+                  ])
+                )
+              : null,
+          headers: Object.fromEntries(new Headers(init?.headers).entries()),
+        }
+        calls.push(call)
+        return respond(call, input.answer(call))
+      },
+      async json<T>(): Promise<T> {
+        throw new Error('not used')
+      },
+    },
+    links: {
+      async get(t, e) {
+        return links.get(key(t, e)) ?? null
+      },
+      async set(t, e, link) {
+        const prev = links.get(key(t, e))
+        links.set(key(t, e), {
+          entityType: t,
+          entityId: e,
+          remoteId: link.remoteId,
+          remoteUrl: link.remoteUrl ?? null,
+          metadata: link.metadata === undefined ? (prev?.metadata ?? null) : link.metadata,
+          checksum: link.checksum ?? null,
+        })
+      },
+      async remove(t, e) {
+        links.delete(key(t, e))
+      },
+      async remoteIds(t) {
+        return new Set([...links.values()].filter((l) => l.entityType === t).map((l) => l.remoteId))
+      },
+      async byRemoteId(t, remoteId) {
+        return (
+          [...links.values()].find((l) => l.entityType === t && l.remoteId === remoteId) ?? null
+        )
+      },
+    },
+    async log(level, message) {
+      logs.push({ level, message })
+    },
+    async saveState(patch) {
+      Object.assign(state, patch)
+    },
+    timezone: 'Europe/Oslo',
+    appUrl: 'https://shop.example.com',
+  }
+  const reset = () => calls.splice(0, calls.length)
+  return { ctx, calls, links, logs, state, reset }
+}
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: 'Anna Berg',
+  email: 'anna@example.com',
+  phone: '912 34 567',
+  address: 'Storgata 1\n0155 Oslo',
+  company: null,
+  taxId: null,
+  taxExempt: false,
+  customerNumber: 'C-0042',
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  invoiceNumber: 'INV-1001',
+  status: 'completed',
+  issuedAt: new Date('2026-09-04T10:00:00Z'),
+  invoiceDate: new Date('2026-09-04T10:00:00Z'),
+  serviceDate: new Date('2026-09-03T10:00:00Z'),
+  dueDate: new Date('2026-09-18T10:00:00Z'),
+  mileage: 84200,
+  notes: null,
+  subtotal: 300,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 25,
+  taxAmount: 75,
+  taxInclusive: false,
+  totalAmount: 375,
+  manuallyPaid: false,
+  customer,
+  vehicle: { year: 2018, make: 'Toyota', model: 'Corolla', licensePlate: 'AB 12345' },
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Brake service',
+      partNumber: null,
+      quantity: 2,
+      unitPrice: 100,
+      total: 200,
+    },
+    {
+      kind: 'part',
+      description: 'Brake pads',
+      partNumber: 'BP-100',
+      quantity: 1,
+      unitPrice: 100,
+      total: 100,
+    },
+  ],
+  payments: [],
+}
+
+const payment: AccountingPayment = {
+  id: 'pay1',
+  serviceRecordId: 'svc1',
+  amount: 375,
+  date: new Date('2026-09-05T10:00:00Z'),
+  method: 'card',
+  note: null,
+  provider: 'stripe',
+  externalId: 'pi_123',
+}
+
+const saleBody = {
+  saleNumber: 'INV-1001',
+  date: '2026-09-04',
+  kind: 'external_invoice',
+  lines: [
+    { description: 'Brake service', netPrice: 20000, vat: 5000, account: '3000', vatType: 'HIGH' },
+    {
+      description: 'BP-100 Brake pads',
+      netPrice: 10000,
+      vat: 2500,
+      account: '3000',
+      vatType: 'HIGH',
+    },
+  ],
+  customerId: 501,
+  currency: 'NOK',
+  dueDate: '2026-09-18',
+}
+
+const PAGE = { page: '0', pageSize: '100' }
+
+/** A Fiken company that has nothing yet and accepts everything. */
+function emptyCompany(overrides: Answer = () => ({ status: 599 })): Answer {
+  let nextSale = 9001
+  let nextPayment = 7001
+  return (call) => {
+    const special = overrides(call)
+    if (special.status !== 599) return special
+    if (call.method === 'GET') return { json: [] }
+    if (call.method === 'POST' && call.path === `${BASE}/contacts`) return { created: 501 }
+    if (call.method === 'POST' && call.path === `${BASE}/sales`) return { created: nextSale++ }
+    if (call.method === 'POST' && call.path.endsWith('/attachments')) {
+      // An attachment is known by a uuid, so its Location does not end in a number.
+      return {
+        status: 201,
+        headers: {
+          Location: `https://api.fiken.no${call.path}/745b2f15-1234-4408-8bf2-b1d2d7610cb2`,
+        },
+      }
+    }
+    if (call.method === 'POST' && call.path.endsWith('/payments')) return { created: nextPayment++ }
+    if (call.method === 'PATCH' && call.path.endsWith('/delete')) return { json: { deleted: true } }
+    if (call.method === 'DELETE') return { status: 204 }
+    throw new Error(`unexpected ${call.method} ${call.path}`)
+  }
+}
+
+const pass: Reply = { status: 599 }
+const push = (t: ReturnType<typeof makeCtx>, id = 'svc1') =>
+  connector.jobs['accounting.invoice'](t.ctx, { entityId: id })
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  loadInvoice.mockResolvedValue(invoice)
+  loadPayment.mockResolvedValue(payment)
+  loadCustomer.mockResolvedValue(customer)
+  loadPdf.mockResolvedValue({ buffer: new Uint8Array([37, 80, 68, 70]), filename: 'INV-1001.pdf' })
+  workshopCurrency.mockResolvedValue('NOK')
+  recordPulledPayment.mockResolvedValue({ id: 'pulled1', created: true })
+  removePulledPayment.mockResolvedValue(true)
+})
+
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+describe('Fiken: pushing an invoice', () => {
+  it('creates the contact, then the sale under its own number, then files the PDF on it', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 created')
+
+    const [findContact, createContact, findSale, createSale, attach] = t.calls
+    expect(t.calls).toHaveLength(5)
+    expect(findContact.host).toBe('api.fiken.no')
+    expect(findContact.path).toBe(`${BASE}/contacts`)
+    expect(findContact.query).toEqual({ name: 'Anna Berg', customer: 'true', ...PAGE })
+    expect(findContact.headers.accept).toBe('application/json')
+    expect(findContact.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/)
+
+    expect(createContact.method).toBe('POST')
+    expect(createContact.path).toBe(`${BASE}/contacts`)
+    expect(createContact.body).toEqual({
+      name: 'Anna Berg',
+      email: 'anna@example.com',
+      phoneNumber: '912 34 567',
+      memberNumberString: 'C-0042',
+      customer: true,
+    })
+
+    expect(findSale.path).toBe(`${BASE}/sales`)
+    expect(findSale.query).toEqual({ saleNumber: 'INV-1001', ...PAGE })
+
+    expect(createSale.method).toBe('POST')
+    expect(createSale.path).toBe(`${BASE}/sales`)
+    expect(createSale.query).toEqual({})
+    expect(createSale.body).toEqual(saleBody)
+
+    expect(attach.method).toBe('POST')
+    expect(attach.path).toBe(`${BASE}/sales/9001/attachments`)
+    expect(attach.query).toEqual({ attachToSale: 'true' })
+    expect(attach.form).toEqual({
+      filename: 'INV-1001.pdf',
+      file: 'file:INV-1001.pdf:application/pdf:4',
+    })
+    // A form writes its own content type; a JSON one here would break the upload.
+    expect(attach.headers['content-type']).toBeUndefined()
+
+    expect(t.links.get('ServiceRecord:svc1')).toMatchObject({
+      remoteId: '9001',
+      metadata: {
+        company: SLUG,
+        saleNumber: 'INV-1001',
+        contactId: 501,
+        attached: true,
+        payments: [],
+      },
+    })
+    expect(t.links.get('Customer:cus1')).toMatchObject({
+      remoteId: '501',
+      metadata: { company: SLUG, reused: false },
+    })
+  })
+
+  it('sends nothing the second time when nothing changed', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    await push(t)
+    t.reset()
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 unchanged')
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('reverses a changed sale and enters it again, because Fiken cannot edit one', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { json: { saleId: 9001, salePayments: [], totalPaid: 0, settled: false } }
+          : pass
+      ),
+    })
+    await push(t)
+    t.reset()
+    loadInvoice.mockResolvedValue({ ...invoice, dueDate: new Date('2026-09-30T10:00:00Z') })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 reversed and entered again')
+
+    const [readSale, reverse, findSale, createSale, attach] = t.calls
+    expect(t.calls).toHaveLength(5)
+    expect(readSale.path).toBe(`${BASE}/sales/9001`)
+    expect(reverse.method).toBe('PATCH')
+    expect(reverse.path).toBe(`${BASE}/sales/9001/delete`)
+    expect(reverse.query).toEqual({ description: 'Changed in Torqvoice' })
+    expect(reverse.body).toBeNull()
+    expect(findSale.query.saleNumber).toBe('INV-1001')
+    expect(createSale.body).toEqual({ ...saleBody, dueDate: '2026-09-30' })
+    expect(attach.path).toBe(`${BASE}/sales/9002/attachments`)
+    expect(t.links.get('ServiceRecord:svc1')?.remoteId).toBe('9002')
+  })
+
+  it('leaves a paid sale alone when the invoice changes, and says so once', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { json: { saleId: 9001, salePayments: [{ paymentId: 7100 }], totalPaid: 37500 } }
+          : pass
+      ),
+    })
+    await push(t)
+    t.reset()
+    loadInvoice.mockResolvedValue({ ...invoice, dueDate: new Date('2026-09-30T10:00:00Z') })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 left as it is in Fiken, has payments')
+    expect(t.calls.map((c) => c.method)).toEqual(['GET'])
+    await push(t)
+    const warnings = t.logs.filter((l) => l.level === 'warn')
+    expect(warnings).toHaveLength(1)
+    expect(warnings[0].message).toContain('changed here after it was paid in Fiken')
+    expect(t.links.get('ServiceRecord:svc1')?.remoteId).toBe('9001')
+  })
+
+  it('takes over a sale Fiken already has under the number instead of entering it twice', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales` && call.query.saleNumber
+          ? {
+              json: [
+                {
+                  saleId: 8000,
+                  date: '2026-09-04',
+                  netAmount: 30000,
+                  vatAmount: 7500,
+                  deleted: true,
+                },
+                { saleId: 8001, date: '2026-09-04', netAmount: 30000, vatAmount: 7500 },
+              ],
+            }
+          : pass
+      ),
+    })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 linked')
+    expect(t.calls.some((c) => c.method === 'POST' && c.path === `${BASE}/sales`)).toBe(false)
+    expect(t.calls.some((c) => c.path.endsWith('/attachments'))).toBe(false)
+    expect(t.links.get('ServiceRecord:svc1')?.remoteId).toBe('8001')
+  })
+
+  it('adds the invoice beside a sale with the same number and other amounts, with a warning', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales` && call.query.saleNumber
+          ? { json: [{ saleId: 8001, date: '2026-09-04', netAmount: 99900, vatAmount: 0 }] }
+          : pass
+      ),
+    })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(t.logs.some((l) => l.level === 'warn' && l.message.includes('added beside it'))).toBe(
+      true
+    )
+    expect(t.links.get('ServiceRecord:svc1')?.remoteId).toBe('9001')
+  })
+
+  it('keeps the sale when the PDF cannot be attached, and tries the PDF again next time', async () => {
+    let refuse = true
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.path.endsWith('/attachments') && refuse
+          ? { status: 400, json: { message: 'Ugyldig fil' } }
+          : pass
+      ),
+    })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(t.links.get('ServiceRecord:svc1')?.metadata?.attached).toBe(false)
+    expect(t.logs.find((l) => l.level === 'warn')?.message).toContain('Ugyldig fil')
+
+    refuse = false
+    t.reset()
+    await push(t)
+    expect(t.calls.map((c) => c.path)).toEqual([`${BASE}/sales/9001/attachments`])
+    expect(t.links.get('ServiceRecord:svc1')?.metadata?.attached).toBe(true)
+  })
+
+  it('does not render or send a PDF when the workshop switched that off', async () => {
+    const t = makeCtx({ settings: { attachPdf: false }, answer: emptyCompany() })
+    await push(t)
+    expect(loadPdf).not.toHaveBeenCalled()
+    expect(t.calls.some((c) => c.path.endsWith('/attachments'))).toBe(false)
+  })
+
+  it('books counter sales to one walk-in contact, found or created once', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, customer: null })
+    await push(t)
+    expect(t.calls[0].query).toEqual({ name: 'Kontantkunde', customer: 'true', ...PAGE })
+    expect(t.calls[1].body).toEqual({ name: 'Kontantkunde', customer: true })
+    expect(t.state.walkIn).toEqual({ slug: SLUG, contactId: 501 })
+
+    t.reset()
+    loadInvoice.mockResolvedValue({
+      ...invoice,
+      id: 'svc2',
+      invoiceNumber: 'INV-1002',
+      customer: null,
+    })
+    await push(t, 'svc2')
+    expect(t.calls.some((c) => c.path === `${BASE}/contacts`)).toBe(false)
+  })
+
+  it('uses the accounts the workshop chose', async () => {
+    const t = makeCtx({
+      settings: { laborAccount: '3020', partsAccount: '3030' },
+      answer: emptyCompany(),
+    })
+    await push(t)
+    const sale = t.calls.find((c) => c.method === 'POST' && c.path === `${BASE}/sales`)
+    expect((sale?.body?.lines as { account: string }[]).map((l) => l.account)).toEqual([
+      '3020',
+      '3030',
+    ])
+  })
+})
+
+describe('Fiken: what stays out', () => {
+  it('waits for a company to be chosen', async () => {
+    const t = makeCtx({ settings: { companySlug: '' }, answer: emptyCompany() })
+    expect((await push(t))?.summary).toBe('no Fiken company chosen yet')
+    expect((await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pay1' }))?.summary).toBe(
+      'no Fiken company chosen yet'
+    )
+    expect((await connector.jobs['accounting.pull'](t.ctx, {}))?.summary).toBe(
+      'no Fiken company chosen yet'
+    )
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('holds back drafts, invoices before the start date and invoices without an amount', async () => {
+    const t = makeCtx({ settings: { startDate: '2026-09-05' }, answer: emptyCompany() })
+    expect((await push(t))?.summary).toBe('dated before the start date')
+    loadInvoice.mockResolvedValue({ ...invoice, issuedAt: null })
+    expect((await push(t))?.summary).toBe('not issued yet')
+    const open = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, totalAmount: 0, taxAmount: 0 })
+    expect((await push(open))?.summary).toBe('no amount to book')
+    expect(t.calls.length + open.calls.length).toBe(0)
+  })
+
+  it('sends a completed job before it is issued when asked to', async () => {
+    const t = makeCtx({ settings: { pushOnComplete: true }, answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, issuedAt: null })
+    expect((await push(t))?.summary).toBe('invoice INV-1001 created')
+  })
+
+  it('does not put another currency into books kept in kroner', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    workshopCurrency.mockResolvedValue('EUR')
+    expect((await push(t))?.summary).toBe('not sent: the workshop bills in EUR and Fiken keeps NOK')
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('does not dress a foreign VAT rate up as a Norwegian one', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, taxRate: 20, taxAmount: 60, totalAmount: 360 })
+    expect((await push(t))?.summary).toBe('invoice INV-1001 not sent, no VAT type for 20%')
+    expect(t.logs[0]).toMatchObject({ level: 'warn' })
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('asks a VAT registered company how to book a sale without VAT, and does not guess', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, taxAmount: 0, totalAmount: 300 })
+    expect((await push(t))?.summary).toBe(
+      'invoice INV-1001 not sent, VAT type for tax-free sales not chosen'
+    )
+    expect(t.logs[0].message).toContain('Choose the VAT type')
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('books a sale without VAT under the type and account the workshop chose', async () => {
+    const t = makeCtx({
+      settings: { zeroVatType: 'EXEMPT', zeroAccount: '3100' },
+      answer: emptyCompany(),
+    })
+    loadInvoice.mockResolvedValue({ ...invoice, taxAmount: 0, totalAmount: 300 })
+    await push(t)
+    const sale = t.calls.find((c) => c.method === 'POST' && c.path === `${BASE}/sales`)
+    expect(sale?.body?.lines).toEqual([
+      { description: 'Brake service', netPrice: 20000, vat: 0, account: '3100', vatType: 'EXEMPT' },
+      {
+        description: 'BP-100 Brake pads',
+        netPrice: 10000,
+        vat: 0,
+        account: '3100',
+        vatType: 'EXEMPT',
+      },
+    ])
+  })
+
+  it('reads the company once and books without VAT handling when it is not VAT registered', async () => {
+    const t = makeCtx({
+      state: { company: null },
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === BASE
+          ? { json: { slug: SLUG, name: 'Verksted AS', vatType: 'no', hasApiAccess: false } }
+          : pass
+      ),
+    })
+    loadInvoice.mockResolvedValue({ ...invoice, taxRate: 0, taxAmount: 0, totalAmount: 300 })
+    await push(t)
+    expect(t.calls[0].path).toBe(BASE)
+    const sale = t.calls.find((c) => c.method === 'POST' && c.path === `${BASE}/sales`)
+    expect((sale?.body?.lines as { vatType: string }[]).map((l) => l.vatType)).toEqual([
+      'NONE',
+      'NONE',
+    ])
+    expect(t.logs.find((l) => l.level === 'warn')?.message).toContain('API module')
+    expect(t.state.company).toMatchObject({ slug: SLUG, vatRegistered: false })
+
+    t.reset()
+    loadInvoice.mockResolvedValue({
+      ...invoice,
+      id: 'svc2',
+      invoiceNumber: 'INV-1002',
+      taxRate: 0,
+      taxAmount: 0,
+      totalAmount: 300,
+    })
+    await push(t, 'svc2')
+    expect(t.calls.some((c) => c.path === BASE)).toBe(false)
+  })
+})
+
+describe('Fiken: contacts', () => {
+  const company: AccountingCustomer = {
+    ...customer,
+    id: 'cus2',
+    name: 'Berg Transport AS',
+    taxId: 'NO 987 654 321 MVA',
+  }
+
+  it('reuses a business Fiken knows by its organisation number, and makes a supplier a customer', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) => {
+        if (call.method === 'GET' && call.query.organizationNumber) {
+          return {
+            json: [
+              {
+                contactId: 77,
+                name: 'Berg Transport',
+                supplier: true,
+                customer: false,
+                email: 'a@b.no',
+              },
+            ],
+          }
+        }
+        if (call.method === 'PUT') return { json: {} }
+        return pass
+      }),
+    })
+    loadInvoice.mockResolvedValue({ ...invoice, customer: company })
+    await push(t)
+    expect(t.calls[0].query).toEqual({ organizationNumber: '987654321', ...PAGE })
+    expect(t.calls[1].method).toBe('PUT')
+    expect(t.calls[1].path).toBe(`${BASE}/contacts/77`)
+    expect(t.calls[1].body).toEqual({
+      supplier: true,
+      email: 'a@b.no',
+      name: 'Berg Transport',
+      customer: true,
+    })
+    expect(t.calls.some((c) => c.method === 'POST' && c.path === `${BASE}/contacts`)).toBe(false)
+    expect(t.links.get('Customer:cus2')).toMatchObject({
+      remoteId: '77',
+      metadata: { reused: true },
+    })
+    const sale = t.calls.find((c) => c.method === 'POST' && c.path === `${BASE}/sales`)
+    expect(sale?.body?.customerId).toBe(77)
+  })
+
+  it('matches a person on name and email, and never picks between two people of one name', async () => {
+    const same = (rows: unknown[]) =>
+      emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/contacts` ? { json: rows } : pass
+      )
+    const byEmail = makeCtx({
+      answer: same([
+        { contactId: 1, name: 'Anna Berg', email: 'other@example.com', customer: true },
+        { contactId: 2, name: 'Anna Berg', email: 'ANNA@example.com', customer: true },
+      ]),
+    })
+    await push(byEmail)
+    expect(byEmail.links.get('Customer:cus1')?.remoteId).toBe('2')
+
+    const twoStrangers = makeCtx({
+      answer: same([
+        { contactId: 1, name: 'Anna Berg', email: 'one@example.com', customer: true },
+        { contactId: 3, name: 'Anna Berg', email: 'two@example.com', customer: true },
+      ]),
+    })
+    await push(twoStrangers)
+    expect(twoStrangers.links.get('Customer:cus1')?.remoteId).toBe('501')
+  })
+
+  it('updates the contact when the customer is edited, keeping what Fiken holds beside it', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) => {
+        if (call.method === 'GET' && call.path === `${BASE}/contacts/501`) {
+          return {
+            json: {
+              contactId: 501,
+              name: 'Anna Berg',
+              email: 'anna@example.com',
+              customer: true,
+              customerNumber: 10001,
+              address: { streetAddress: 'Storgata 1', country: 'Norway' },
+            },
+          }
+        }
+        if (call.method === 'PUT') return { json: {} }
+        return pass
+      }),
+    })
+    await push(t)
+    t.reset()
+    loadCustomer.mockResolvedValue({ ...customer, phone: '400 00 000' })
+    const out = await connector.jobs['accounting.customer'](t.ctx, { entityId: 'cus1' })
+    expect(out?.summary).toBe('customer updated')
+    expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
+      `GET ${BASE}/contacts/501`,
+      `PUT ${BASE}/contacts/501`,
+    ])
+    expect(t.calls[1].body).toEqual({
+      address: { streetAddress: 'Storgata 1', country: 'Norway' },
+      name: 'Anna Berg',
+      email: 'anna@example.com',
+      phoneNumber: '400 00 000',
+      memberNumberString: 'C-0042',
+      customer: true,
+    })
+  })
+
+  it('leaves a customer alone until their first invoice has gone over', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    const out = await connector.jobs['accounting.customer'](t.ctx, { entityId: 'cus1' })
+    expect(out?.summary).toBe('not in Fiken yet')
+    expect(t.calls).toHaveLength(0)
+  })
+})
+
+describe('Fiken: moving to another company', () => {
+  it('starts over in the new company and never reuses ids from the old one', async () => {
+    let answer: Answer = emptyCompany()
+    const t = makeCtx({ answer: (call) => answer(call) })
+    await push(t)
+    t.reset()
+    t.ctx.connection.settings.companySlug = 'ekte-verksted-as'
+    t.state.company = {
+      slug: 'ekte-verksted-as',
+      vatRegistered: true,
+      fetchedAt: new Date().toISOString(),
+    }
+    const other = '/api/v2/companies/ekte-verksted-as'
+    answer = (call) => {
+      if (call.method === 'GET') return { json: [] }
+      if (call.path === `${other}/contacts`) return { created: 601 }
+      if (call.path === `${other}/sales`) return { created: 9500 }
+      if (call.path === `${other}/sales/9500/attachments`) return { status: 201 }
+      throw new Error(`unexpected ${call.method} ${call.path}`)
+    }
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
+      `GET ${other}/contacts`,
+      `POST ${other}/contacts`,
+      `GET ${other}/sales`,
+      `POST ${other}/sales`,
+      `POST ${other}/sales/9500/attachments`,
+    ])
+    expect(t.calls[3].body?.customerId).toBe(601)
+    expect(t.links.get('ServiceRecord:svc1')).toMatchObject({
+      remoteId: '9500',
+      metadata: { company: 'ekte-verksted-as', contactId: 601 },
+    })
+  })
+})
+
+describe('Fiken: a deleted invoice', () => {
+  it('reverses the sale', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { json: { saleId: 9001, salePayments: [] } }
+          : pass
+      ),
+    })
+    await push(t)
+    t.reset()
+    loadInvoice.mockResolvedValue(null)
+    const out = await push(t)
+    expect(out?.summary).toBe('sale INV-1001 reversed')
+    expect(t.calls[1]).toMatchObject({
+      method: 'PATCH',
+      path: `${BASE}/sales/9001/delete`,
+      query: { description: 'Deleted in Torqvoice' },
+    })
+    expect(t.links.has('ServiceRecord:svc1')).toBe(false)
+  })
+
+  it('leaves a sale with payments in Fiken', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { json: { saleId: 9001, settled: true } }
+          : pass
+      ),
+    })
+    await push(t)
+    t.reset()
+    loadInvoice.mockResolvedValue(null)
+    const out = await push(t)
+    expect(out?.summary).toBe('left in Fiken, has payments')
+    expect(t.calls.map((c) => c.method)).toEqual(['GET'])
+    expect(t.links.has('ServiceRecord:svc1')).toBe(false)
+  })
+
+  it('forgets a sale Fiken no longer has', async () => {
+    const t = makeCtx({
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { status: 404, json: { message: 'Not found' } }
+          : pass
+      ),
+    })
+    await push(t)
+    loadInvoice.mockResolvedValue(null)
+    expect((await push(t))?.summary).toBe('already gone from Fiken')
+    expect((await push(t))?.summary).toBe('nothing to do')
+  })
+})
+
+describe('Fiken: payments', () => {
+  it('records a payment on the sale, in øre, on the chosen account', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment] })
+    const out = await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pay1' })
+    expect(out?.summary).toBe('payment recorded')
+    const pay = t.calls.at(-1)
+    expect(pay).toMatchObject({ method: 'POST', path: `${BASE}/sales/9001/payments` })
+    expect(pay?.body).toEqual({ date: '2026-09-05', account: '1920:10001', amount: 37500 })
+    expect(t.links.get('Payment:pay1')).toMatchObject({
+      remoteId: '7001',
+      metadata: { company: SLUG, createdByUs: true, serviceRecordId: 'svc1', saleId: '9001' },
+    })
+    expect(t.links.get('ServiceRecord:svc1')?.metadata?.payments).toEqual(['7001'])
+
+    t.reset()
+    expect((await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pay1' }))?.summary).toBe(
+      'unchanged'
+    )
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('finds the first ordinary bank account once when none is chosen', async () => {
+    const t = makeCtx({
+      settings: { paymentAccount: '' },
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/bankAccounts`
+          ? {
+              json: [
+                { accountCode: '1950:10001', type: 'tax_deduction' },
+                { accountCode: '1920:10007', type: 'normal' },
+              ],
+            }
+          : pass
+      ),
+    })
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment, { ...payment, id: 'pay2' }] })
+    await push(t)
+    const bank = t.calls.filter((c) => c.path === `${BASE}/bankAccounts`)
+    expect(bank).toHaveLength(1)
+    expect(bank[0].query).toEqual({ inactive: 'false', ...PAGE })
+    const pays = t.calls.filter((c) => c.path.endsWith('/payments'))
+    expect(pays.map((c) => c.body?.account)).toEqual(['1920:10007', '1920:10007'])
+    expect(t.state.paymentAccount).toEqual({ slug: SLUG, code: '1920:10007' })
+  })
+
+  it('does not send payments when that is switched off, or money handed back', async () => {
+    const off = makeCtx({ settings: { pushPayments: false }, answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment] })
+    expect(
+      (await connector.jobs['accounting.payment'](off.ctx, { entityId: 'pay1' }))?.summary
+    ).toBe('payments not sent')
+    const refund = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [{ ...payment, amount: -50 }] })
+    await push(refund)
+    expect(refund.calls.some((c) => c.path.endsWith('/payments'))).toBe(false)
+  })
+
+  it('removes a payment deleted here, and leaves one that came from Fiken', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment] })
+    await push(t)
+    t.reset()
+    loadPayment.mockResolvedValue(null)
+    const out = await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pay1' })
+    expect(out?.summary).toBe('payment deleted')
+    expect(t.calls).toHaveLength(1)
+    expect(t.calls[0]).toMatchObject({
+      method: 'DELETE',
+      path: `${BASE}/sales/9001/payments/7001`,
+      query: { description: 'Deleted in Torqvoice' },
+    })
+    expect(t.links.has('Payment:pay1')).toBe(false)
+    expect(t.links.get('ServiceRecord:svc1')?.metadata?.payments).toEqual([])
+
+    await t.ctx.links.set('Payment', 'pulled1', {
+      remoteId: '7100',
+      metadata: { company: SLUG, createdByUs: false, serviceRecordId: 'svc1', saleId: '9001' },
+    })
+    t.reset()
+    const kept = await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pulled1' })
+    expect(kept?.summary).toBe('payment came from Fiken; left there')
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('closes an invoice marked paid by hand with one payment for what Fiken shows as owed', async () => {
+    const t = makeCtx({
+      settings: { manualPaidAsPayment: true },
+      answer: emptyCompany((call) =>
+        call.method === 'GET' && call.path === `${BASE}/sales/9001`
+          ? { json: { saleId: 9001, outstandingBalance: 37500, settled: false } }
+          : pass
+      ),
+    })
+    loadInvoice.mockResolvedValue({ ...invoice, manuallyPaid: true })
+    const out = await push(t)
+    expect(out?.summary).toBe('invoice INV-1001 created, 1 payments recorded')
+    expect(t.calls.at(-1)?.body).toEqual({
+      date: '2026-09-04',
+      account: '1920:10001',
+      amount: 37500,
+    })
+    expect(t.links.get('Payment:manual:svc1')?.metadata).toMatchObject({ manual: true })
+
+    t.reset()
+    await push(t)
+    expect(t.calls).toHaveLength(0)
+  })
+})
+
+describe('Fiken: pulling payments', () => {
+  const salesWith = (payments: unknown[], extra: Record<string, unknown> = {}): Answer =>
+    emptyCompany((call) =>
+      call.method === 'GET' && call.path === `${BASE}/sales` && call.query.lastModifiedGe
+        ? {
+            json: [
+              { saleId: 9001, salePayments: payments, ...extra },
+              {
+                saleId: 4444,
+                salePayments: [
+                  { paymentId: 1, date: '2026-09-06', account: '1920:10001', amount: 100 },
+                ],
+              },
+            ],
+          }
+        : pass
+    )
+
+  it('records a payment registered in Fiken on the invoice here, once', async () => {
+    vi.useFakeTimers({ toFake: ['Date'] })
+    vi.setSystemTime(new Date('2026-09-10T08:00:00Z'))
+    let answer = emptyCompany()
+    const t = makeCtx({ answer: (call) => answer(call) })
+    await push(t)
+    t.reset()
+    answer = salesWith([
+      { paymentId: 7100, date: '2026-09-06', account: '1920:10001', amount: 37500 },
+    ])
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('1 payments recorded')
+    expect(t.calls).toHaveLength(1)
+    expect(t.calls[0].query).toEqual({ lastModifiedGe: '2026-09-09', ...PAGE })
+    expect(recordPulledPayment).toHaveBeenCalledTimes(1)
+    expect(recordPulledPayment).toHaveBeenCalledWith('org1', {
+      serviceRecordId: 'svc1',
+      amount: 375,
+      date: new Date('2026-09-06T12:00:00Z'),
+      method: 'transfer',
+      provider: 'fiken',
+      externalId: '7100',
+      note: 'Recorded in Fiken',
+    })
+    expect(t.links.get('Payment:pulled1')).toMatchObject({
+      remoteId: '7100',
+      metadata: { createdByUs: false, serviceRecordId: 'svc1', saleId: '9001' },
+    })
+    expect(t.state.lastPullDay).toBe('2026-09-10')
+
+    // The next pull reads from the day before the last one and sees the same payment.
+    vi.setSystemTime(new Date('2026-09-12T08:00:00Z'))
+    t.reset()
+    const again = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(again?.summary).toBe('no changes')
+    expect(t.calls[0].query.lastModifiedGe).toBe('2026-09-09')
+    expect(recordPulledPayment).toHaveBeenCalledTimes(1)
+  })
+
+  it('removes a pulled payment that was deleted in Fiken', async () => {
+    let answer = emptyCompany()
+    const t = makeCtx({ answer: (call) => answer(call) })
+    await push(t)
+    answer = salesWith([{ paymentId: 7100, date: '2026-09-06', account: '1900', amount: 37500 }])
+    await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(recordPulledPayment.mock.calls[0][1]).toMatchObject({ method: 'cash' })
+    answer = salesWith([])
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('1 payments removed')
+    expect(removePulledPayment).toHaveBeenCalledWith('org1', 'pulled1', 'fiken')
+    expect(t.links.has('Payment:pulled1')).toBe(false)
+    expect(t.links.get('ServiceRecord:svc1')?.metadata?.payments).toEqual([])
+  })
+
+  it('keeps a payment made here when Fiken deleted its copy, and says so', async () => {
+    let answer = emptyCompany()
+    const t = makeCtx({ answer: (call) => answer(call) })
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment] })
+    await push(t)
+    answer = salesWith([])
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('no changes')
+    expect(removePulledPayment).not.toHaveBeenCalled()
+    expect(t.links.has('Payment:pay1')).toBe(false)
+    expect(t.logs.at(-1)).toMatchObject({ level: 'warn' })
+    expect(t.logs.at(-1)?.message).toContain('deleted in Fiken; it is kept here')
+  })
+
+  it('does not bring back a payment this app sent', async () => {
+    let answer = emptyCompany()
+    const t = makeCtx({ answer: (call) => answer(call) })
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment] })
+    await push(t)
+    answer = salesWith([
+      { paymentId: 7001, date: '2026-09-05', account: '1920:10001', amount: 37500 },
+    ])
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('no changes')
+    expect(recordPulledPayment).not.toHaveBeenCalled()
+  })
+
+  it('unlinks a sale deleted in Fiken so an edit here enters it again', async () => {
+    let answer = emptyCompany()
+    const t = makeCtx({ answer: (call) => answer(call) })
+    await push(t)
+    answer = salesWith([], { deleted: true })
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('1 sales unlinked')
+    expect(t.links.has('ServiceRecord:svc1')).toBe(false)
+  })
+
+  it('does nothing when the pull is switched off', async () => {
+    const t = makeCtx({ settings: { pullPayments: false }, answer: emptyCompany() })
+    expect((await connector.jobs['accounting.pull'](t.ctx, {}))?.summary).toBe('pull switched off')
+    expect(t.calls).toHaveLength(0)
+  })
+})
+
+describe('Fiken: the connection', () => {
+  it('names the person who gave access without exposing their email as the id', async () => {
+    const t = makeCtx({
+      answer: () => ({ json: { name: 'Kari Nordmann', email: 'kari@example.com' } }),
+    })
+    const who = await connector.identify?.(t.ctx)
+    expect(t.calls[0].path).toBe('/api/v2/user')
+    expect(who?.name).toBe('Kari Nordmann')
+    expect(who?.id).toMatch(/^fiken-[0-9a-f]{16}$/)
+  })
+
+  it('fails the test when the company has no API module', async () => {
+    const t = makeCtx({
+      answer: (call) =>
+        call.path === BASE
+          ? { json: { slug: SLUG, name: 'Verksted AS', hasApiAccess: false } }
+          : { json: { name: 'Kari' } },
+    })
+    expect(await connector.test(t.ctx)).toEqual({
+      ok: false,
+      message: 'The API module is not switched on for Verksted AS in Fiken',
+    })
+    const refused = makeCtx({ answer: () => ({ status: 403, json: { message: 'Ingen tilgang' } }) })
+    const result = await connector.test(refused.ctx)
+    expect(result.ok).toBe(false)
+    expect(result.message).toContain('Fiken: Ingen tilgang (request ')
+  })
+
+  it('lists companies, income accounts and bank accounts for the settings', async () => {
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.path === '/api/v2/companies') {
+          return {
+            json: [
+              { slug: 'verksted-as', name: 'Verksted AS', organizationNumber: '987654321' },
+              { slug: 'test', name: 'Torqvoice Test' },
+            ],
+          }
+        }
+        if (call.path === `${BASE}/accounts`) {
+          return {
+            json: [
+              { code: '3100', name: 'Salgsinntekt, avgiftsfri' },
+              { code: '3000', name: 'Salgsinntekt, avgiftspliktig' },
+            ],
+          }
+        }
+        return { json: [{ accountCode: '1920:10001', name: 'Driftskonto', type: 'normal' }] }
+      },
+    })
+    expect(await connector.remoteOptions?.companies(t.ctx)).toEqual([
+      { value: 'test', label: 'Torqvoice Test' },
+      { value: 'verksted-as', label: 'Verksted AS, 987654321' },
+    ])
+    expect(await connector.remoteOptions?.incomeAccounts(t.ctx)).toEqual([
+      { value: '3000', label: '3000 Salgsinntekt, avgiftspliktig' },
+      { value: '3100', label: '3100 Salgsinntekt, avgiftsfri' },
+    ])
+    expect(t.calls[1].query).toEqual({ fromAccount: '3000', toAccount: '3999' })
+    expect(await connector.remoteOptions?.paymentAccounts(t.ctx)).toEqual([
+      { value: '1920:10001', label: 'Driftskonto (1920:10001)' },
+    ])
+
+    const unset = makeCtx({ settings: { companySlug: '' }, answer: () => ({ json: [] }) })
+    expect(await connector.remoteOptions?.incomeAccounts(unset.ctx)).toEqual([])
+    expect(unset.calls).toHaveLength(0)
+  })
+
+  it('reads a long list page by page', async () => {
+    const t = makeCtx({
+      answer: (call) => ({
+        json:
+          call.query.page === '0'
+            ? Array.from({ length: 100 }, (_, i) => ({ slug: `a${i}`, name: `A ${i}` }))
+            : [{ slug: 'z', name: 'Z' }],
+        headers: { 'Fiken-Api-Page-Count': '2' },
+      }),
+    })
+    const options = await connector.remoteOptions?.companies(t.ctx)
+    expect(options).toHaveLength(101)
+    expect(t.calls.map((c) => c.query.page)).toEqual(['0', '1'])
+  })
+
+  it('sends one request at a time, as Fiken demands', async () => {
+    let inFlight = 0
+    let most = 0
+    const t = makeCtx({ answer: () => ({ json: [] }) })
+    const fetchNow = t.ctx.http.fetch
+    t.ctx.http.fetch = async (url, init) => {
+      inFlight++
+      most = Math.max(most, inFlight)
+      await new Promise((resolve) => setTimeout(resolve, 5))
+      const res = await fetchNow(url, init)
+      inFlight--
+      return res
+    }
+    await Promise.all([
+      connector.remoteOptions?.companies(t.ctx),
+      connector.remoteOptions?.incomeAccounts(t.ctx),
+      connector.remoteOptions?.paymentAccounts(t.ctx),
+    ])
+    expect(t.calls).toHaveLength(3)
+    expect(most).toBe(1)
+  })
+
+  it('hands the grant back on disconnect', async () => {
+    const t = makeCtx({ answer: () => ({ json: {} }) })
+    await connector.onDisconnect?.(t.ctx)
+    expect(t.calls).toHaveLength(1)
+    expect(t.calls[0]).toMatchObject({ method: 'POST', host: 'fiken.no', path: '/oauth/revoke' })
+  })
+})

+ 367 - 0
src/__tests__/features/integrations/fiken.test.ts

@@ -0,0 +1,367 @@
+import { describe, expect, it } from 'vitest'
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+} from '@/features/integrations/Lib/accounting-sync'
+import { manifest } from '@/integrations/fiken/manifest'
+import {
+  allocate,
+  buildContact,
+  buildPayment,
+  buildSale,
+  carriesVat,
+  contactName,
+  faultMessage,
+  fromCents,
+  idFromLocation,
+  localPaymentMethod,
+  mergeContact,
+  organizationNumber,
+  toCents,
+  vatTypeForRate,
+} from '@/integrations/fiken/mapping'
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: 'Berg Transport AS',
+  email: 'post@bergtransport.no',
+  phone: '+47 912 34 567',
+  address: 'Storgata 1\n0155 Oslo',
+  company: 'Berg Transport AS',
+  taxId: 'NO 987 654 321 MVA',
+  taxExempt: false,
+  customerNumber: 'C-0042',
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  invoiceNumber: 'INV-2026-000123',
+  status: 'completed',
+  issuedAt: new Date('2026-09-04T10:00:00Z'),
+  // Half past midnight on the 4th in Oslo, still the 3rd in UTC.
+  invoiceDate: new Date('2026-09-03T22:30:00Z'),
+  serviceDate: new Date('2026-09-02T09:00:00Z'),
+  dueDate: new Date('2026-09-18T12:00:00Z'),
+  mileage: 84200,
+  notes: null,
+  subtotal: 1500,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 25,
+  taxAmount: 375,
+  taxInclusive: false,
+  totalAmount: 1875,
+  manuallyPaid: false,
+  customer,
+  vehicle: { year: 2018, make: 'Toyota', model: 'Corolla', licensePlate: 'AB 12345' },
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Brake service',
+      partNumber: null,
+      quantity: 2,
+      unitPrice: 500,
+      total: 1000,
+    },
+    {
+      kind: 'part',
+      description: 'Brake pads',
+      partNumber: 'BP-100',
+      quantity: 1,
+      unitPrice: 500,
+      total: 500,
+    },
+  ],
+  payments: [],
+}
+
+const options = {
+  customerId: 501,
+  laborAccount: '3000',
+  partsAccount: '3010',
+  zeroAccount: null,
+  vatType: 'HIGH',
+  zeroVatType: 'NONE',
+  timezone: 'Europe/Oslo',
+}
+
+type Line = { description: string; netPrice: number; vat: number; account: string; vatType: string }
+const linesOf = (body: Record<string, unknown>) => body.lines as Line[]
+const sum = (rows: Line[], key: 'netPrice' | 'vat') => rows.reduce((a, r) => a + r[key], 0)
+
+describe('Fiken mapping: money', () => {
+  it('counts in whole øre', () => {
+    expect(toCents(1875)).toBe(187500)
+    expect(toCents(0.29)).toBe(29)
+    expect(toCents(1.005)).toBe(101)
+    expect(fromCents(187550)).toBe(1875.5)
+  })
+
+  it('names the Norwegian VAT type for a rate, and none for a rate Norway does not have', () => {
+    expect(vatTypeForRate(25)).toBe('HIGH')
+    expect(vatTypeForRate(15)).toBe('MEDIUM')
+    expect(vatTypeForRate(12)).toBe('LOW')
+    expect(vatTypeForRate(11.11)).toBe('RAW_FISH')
+    expect(vatTypeForRate(20)).toBeNull()
+    expect(vatTypeForRate(0)).toBeNull()
+  })
+
+  it('splits an amount so the parts always add up to it', () => {
+    expect(allocate(100, [1, 1, 1])).toEqual([34, 33, 33])
+    expect(allocate(10, [3, 0, 7])).toEqual([3, 0, 7])
+    expect(allocate(-100, [1, 1, 1]).reduce((a, b) => a + b, 0)).toBe(-100)
+    expect(allocate(999, [200, -50, 150]).reduce((a, b) => a + b, 0)).toBe(999)
+    expect(allocate(50, [])).toEqual([])
+    expect(allocate(50, [0, 0])).toEqual([0, 0])
+  })
+})
+
+describe('Fiken mapping: a sale', () => {
+  it('books an invoice as an externally numbered sale, dated in the workshop timezone', () => {
+    expect(buildSale(invoice, options)).toEqual({
+      saleNumber: 'INV-2026-000123',
+      date: '2026-09-04',
+      kind: 'external_invoice',
+      lines: [
+        {
+          description: 'Brake service',
+          netPrice: 100000,
+          vat: 25000,
+          account: '3000',
+          vatType: 'HIGH',
+        },
+        {
+          description: 'BP-100 Brake pads',
+          netPrice: 50000,
+          vat: 12500,
+          account: '3010',
+          vatType: 'HIGH',
+        },
+      ],
+      customerId: 501,
+      currency: 'NOK',
+      dueDate: '2026-09-18',
+    })
+  })
+
+  it('spreads a discount over the lines and keeps the billed total to the øre', () => {
+    // 10% off 1500 is 1350 net, 337.50 VAT, 1687.50 to pay.
+    const body = buildSale(
+      {
+        ...invoice,
+        discountType: 'percentage',
+        discountValue: 10,
+        discountAmount: 150,
+        taxAmount: 337.5,
+        totalAmount: 1687.5,
+      },
+      options
+    )
+    const lines = linesOf(body)
+    expect(lines.map((l) => l.netPrice)).toEqual([90000, 45000])
+    expect(lines.map((l) => l.vat)).toEqual([22500, 11250])
+    expect(sum(lines, 'netPrice') + sum(lines, 'vat')).toBe(168750)
+  })
+
+  it('takes the VAT out of prices entered with VAT included', () => {
+    // Lines are gross: 1250 + 625 = 1875, of which 375 is VAT.
+    const body = buildSale(
+      {
+        ...invoice,
+        taxInclusive: true,
+        lines: [
+          { ...invoice.lines[0], unitPrice: 625, total: 1250 },
+          { ...invoice.lines[1], unitPrice: 625, total: 625 },
+        ],
+      },
+      options
+    )
+    const lines = linesOf(body)
+    expect(lines.map((l) => l.netPrice)).toEqual([100000, 50000])
+    expect(lines.map((l) => l.vat)).toEqual([25000, 12500])
+  })
+
+  it('never loses an øre to rounding, whatever the lines are', () => {
+    const body = buildSale(
+      {
+        ...invoice,
+        taxAmount: 83.33,
+        totalAmount: 416.66,
+        lines: [
+          { ...invoice.lines[0], quantity: 1, unitPrice: 111.11, total: 111.11 },
+          { ...invoice.lines[1], quantity: 1, unitPrice: 111.11, total: 111.11 },
+          { ...invoice.lines[1], quantity: 1, unitPrice: 111.11, total: 111.11 },
+        ],
+      },
+      options
+    )
+    const lines = linesOf(body)
+    expect(sum(lines, 'netPrice')).toBe(33333)
+    expect(sum(lines, 'vat')).toBe(8333)
+    expect(lines.map((l) => l.netPrice)).toEqual([11111, 11111, 11111])
+  })
+
+  it('books an invoice without VAT under the chosen type and account', () => {
+    const body = buildSale(
+      { ...invoice, taxRate: 25, taxAmount: 0, totalAmount: 1500 },
+      { ...options, zeroVatType: 'EXEMPT', zeroAccount: '3100' }
+    )
+    expect(linesOf(body)).toEqual([
+      {
+        description: 'Brake service',
+        netPrice: 100000,
+        vat: 0,
+        account: '3100',
+        vatType: 'EXEMPT',
+      },
+      {
+        description: 'BP-100 Brake pads',
+        netPrice: 50000,
+        vat: 0,
+        account: '3100',
+        vatType: 'EXEMPT',
+      },
+    ])
+    expect(carriesVat({ ...invoice, taxAmount: 0 })).toBe(false)
+  })
+
+  it('keeps the income accounts for a tax-free invoice when no other is chosen', () => {
+    const body = buildSale({ ...invoice, taxAmount: 0, totalAmount: 1500 }, options)
+    expect(linesOf(body).map((l) => [l.account, l.vatType])).toEqual([
+      ['3000', 'NONE'],
+      ['3010', 'NONE'],
+    ])
+  })
+
+  it('leaves out lines that carry no money and shortens long descriptions', () => {
+    const body = buildSale(
+      {
+        ...invoice,
+        lines: [
+          { ...invoice.lines[0], description: 'x'.repeat(300) },
+          { ...invoice.lines[1], quantity: 1, unitPrice: 0, total: 0 },
+          { ...invoice.lines[1] },
+        ],
+      },
+      options
+    )
+    const lines = linesOf(body)
+    expect(lines).toHaveLength(2)
+    expect(lines[0].description).toHaveLength(200)
+    expect(sum(lines, 'netPrice') + sum(lines, 'vat')).toBe(187500)
+  })
+
+  it('books an old record with a total and no lines as one line', () => {
+    const body = buildSale({ ...invoice, lines: [], dueDate: null }, options)
+    expect(linesOf(body)).toEqual([
+      {
+        description: 'Faktura INV-2026-000123',
+        netPrice: 150000,
+        vat: 37500,
+        account: '3000',
+        vatType: 'HIGH',
+      },
+    ])
+    expect(body).not.toHaveProperty('dueDate')
+  })
+})
+
+describe('Fiken mapping: contacts and payments', () => {
+  it('reads a Norwegian organisation number however it was typed', () => {
+    expect(organizationNumber('NO 987 654 321 MVA')).toBe('987654321')
+    expect(organizationNumber('987654321')).toBe('987654321')
+    expect(organizationNumber('GB123456789012')).toBeNull()
+    expect(organizationNumber(null)).toBeNull()
+  })
+
+  it('builds a customer contact without guessing an address', () => {
+    expect(buildContact(customer)).toEqual({
+      name: 'Berg Transport AS',
+      email: 'post@bergtransport.no',
+      organizationNumber: '987654321',
+      phoneNumber: '+47 912 34 567',
+      memberNumberString: 'C-0042',
+      customer: true,
+    })
+    expect(
+      buildContact({ ...customer, email: null, phone: null, taxId: null, customerNumber: null })
+    ).toEqual({ name: 'Berg Transport AS', customer: true })
+    expect(contactName({ ...customer, name: '  ', company: 'Berg AS' })).toBe('Berg AS')
+  })
+
+  it('updates a contact without wiping what the bookkeeper filled in', () => {
+    const current = {
+      contactId: 501,
+      name: 'Berg Transport',
+      email: 'faktura@bergtransport.no',
+      organizationNumber: '987654321',
+      customer: true,
+      supplier: true,
+      customerNumber: 10042,
+      customerAccountCode: '1500:10042',
+      address: { streetAddress: 'Storgata 1', postCode: '0155', city: 'Oslo', country: 'Norway' },
+      language: 'Norwegian',
+      daysUntilInvoicingDueDate: 14,
+    }
+    expect(mergeContact(current, buildContact({ ...customer, email: null }))).toEqual({
+      supplier: true,
+      language: 'Norwegian',
+      daysUntilInvoicingDueDate: 14,
+      address: { streetAddress: 'Storgata 1', postCode: '0155', city: 'Oslo', country: 'Norway' },
+      email: 'faktura@bergtransport.no',
+      organizationNumber: '987654321',
+      name: 'Berg Transport AS',
+      phoneNumber: '+47 912 34 567',
+      memberNumberString: 'C-0042',
+      customer: true,
+    })
+  })
+
+  it('dates a payment in the workshop timezone and counts it in øre', () => {
+    expect(
+      buildPayment(
+        { amount: 1875, date: new Date('2026-09-05T22:30:00Z') },
+        { account: '1920:10001', timezone: 'Europe/Oslo' }
+      )
+    ).toEqual({ date: '2026-09-06', account: '1920:10001', amount: 187500 })
+  })
+
+  it('reads cash from the account a Fiken payment landed on', () => {
+    expect(localPaymentMethod('1900')).toBe('cash')
+    expect(localPaymentMethod('1920:10001')).toBe('transfer')
+    expect(localPaymentMethod(undefined)).toBe('transfer')
+  })
+
+  it('takes a new record id from the Location header', () => {
+    expect(idFromLocation('https://api.fiken.no/api/v2/companies/verksted-as/sales/9001')).toBe(
+      9001
+    )
+    expect(idFromLocation('https://api.fiken.no/api/v2/companies/x/contacts/501/')).toBe(501)
+    expect(idFromLocation('https://api.fiken.no/api/v2/companies/x/sales')).toBeNull()
+    expect(idFromLocation(null)).toBeNull()
+  })
+
+  it("passes on Fiken's own wording for a failure", () => {
+    expect(faultMessage(JSON.stringify({ error: 'x', error_description: 'Ugyldig konto' }))).toBe(
+      'Ugyldig konto'
+    )
+    expect(faultMessage(JSON.stringify([{ message: 'A' }, { message: 'B' }]))).toBe('A; B')
+    expect(faultMessage('<html>502</html>')).toBe('<html>502</html>')
+  })
+})
+
+describe('Fiken manifest', () => {
+  it('asks for the company first and uses Fiken OAuth as documented', () => {
+    expect(manifest.settings[0]).toMatchObject({ key: 'companySlug', required: true })
+    expect(manifest.auth).toMatchObject({
+      type: 'oauth2',
+      authorizeUrl: 'https://fiken.no/oauth/authorize',
+      tokenUrl: 'https://fiken.no/oauth/token',
+      scopes: [],
+      tokenAuth: 'basic',
+      stateOnExchange: true,
+    })
+  })
+})

+ 44 - 0
src/__tests__/features/integrations/http.test.ts

@@ -0,0 +1,44 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+const { createConnectorHttp } = await import('@/features/integrations/Lib/http')
+
+/**
+ * The client labels a body as JSON when the caller did not say what it is.
+ * A form has to be left alone: only fetch can write its content type, with
+ * the boundary that separates the parts, and a file upload sent as JSON is
+ * refused by the vendor.
+ */
+describe('connector http', () => {
+  afterEach(() => {
+    vi.restoreAllMocks()
+  })
+
+  const client = () =>
+    createConnectorHttp({
+      connectionId: 'c1',
+      credentials: { accessToken: 'tok' },
+      auth: {},
+      log: () => Promise.resolve(),
+    })
+
+  it('labels a text body as JSON and signs the request', async () => {
+    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
+    await client().fetch('https://api.example.com/x', { method: 'POST', body: '{"a":1}' })
+    const headers = new Headers(fetchMock.mock.calls[0][1]?.headers)
+    expect(headers.get('content-type')).toBe('application/json')
+    expect(headers.get('authorization')).toBe('Bearer tok')
+  })
+
+  it('leaves the content type of a form to fetch', async () => {
+    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
+    const form = new FormData()
+    form.set('filename', 'INV-1.pdf')
+    form.set('file', new Blob(['%PDF'], { type: 'application/pdf' }), 'INV-1.pdf')
+    await client().fetch('https://api.example.com/upload', { method: 'POST', body: form })
+    const init = fetchMock.mock.calls[0][1]
+    expect(new Headers(init?.headers).has('content-type')).toBe(false)
+    expect(init?.body).toBe(form)
+  })
+})

+ 68 - 0
src/__tests__/features/integrations/oauth.test.ts

@@ -10,6 +10,7 @@ import {
   refreshToken,
   resolveClient,
 } from '@/features/integrations/Lib/oauth'
+import { manifest as fiken } from '@/integrations/fiken/manifest'
 import { manifest as google } from '@/integrations/google-calendar/manifest'
 
 const spec = google.auth.type === 'oauth2' ? google.auth : null
@@ -146,6 +147,73 @@ describe('integration oauth', () => {
     expect(sent.get('grant_type')).toBe('authorization_code')
   })
 
+  it('follows Fiken: no scope on the consent page, and the state again on the exchange', async () => {
+    const fikenSpec = fiken.auth.type === 'oauth2' ? fiken.auth : null
+    if (!fikenSpec) throw new Error('fiken manifest is not oauth2')
+    const client = { clientId: 'fid', clientSecret: 'fsec', ownership: 'platform' as const }
+    const url = new URL(
+      buildAuthorizeUrl({
+        spec: fikenSpec,
+        client,
+        redirectUri: redirectUriFor('https://shop.example.com', 'fiken'),
+        state: 'st4te',
+      })
+    )
+    expect(url.origin + url.pathname).toBe('https://fiken.no/oauth/authorize')
+    expect(Object.fromEntries(url.searchParams.entries())).toEqual({
+      response_type: 'code',
+      client_id: 'fid',
+      redirect_uri: 'https://shop.example.com/api/integrations/fiken/oauth/callback',
+      state: 'st4te',
+    })
+
+    const fetchMock = vi.spyOn(globalThis, 'fetch')
+    fetchMock.mockResolvedValueOnce(
+      new Response(
+        JSON.stringify({
+          access_token: 'fa',
+          refresh_token: 'fr',
+          token_type: 'bearer',
+          expires_in: 86157,
+        }),
+        { status: 200 }
+      )
+    )
+    await exchangeCode({
+      spec: fikenSpec,
+      client,
+      code: 'AUTHCODE',
+      redirectUri: 'https://shop.example.com/api/integrations/fiken/oauth/callback',
+      state: 'st4te',
+    })
+    const [tokenUrl, init] = fetchMock.mock.calls[0]
+    expect(tokenUrl).toBe('https://fiken.no/oauth/token')
+    expect(new Headers(init?.headers).get('authorization')).toBe(
+      `Basic ${Buffer.from('fid:fsec').toString('base64')}`
+    )
+    expect(Object.fromEntries(new URLSearchParams(String(init?.body)).entries())).toEqual({
+      grant_type: 'authorization_code',
+      code: 'AUTHCODE',
+      redirect_uri: 'https://shop.example.com/api/integrations/fiken/oauth/callback',
+      state: 'st4te',
+    })
+  })
+
+  it('keeps the state off the exchange for vendors that follow the standard', async () => {
+    const fetchMock = vi.spyOn(globalThis, 'fetch')
+    fetchMock.mockResolvedValueOnce(
+      new Response(JSON.stringify({ access_token: 'a', expires_in: 3600 }), { status: 200 })
+    )
+    await exchangeCode({
+      spec,
+      client: { clientId: 'c', clientSecret: 's', ownership: 'tenant' },
+      code: 'x',
+      redirectUri: 'https://x/cb',
+      state: 'st4te',
+    })
+    expect(new URLSearchParams(String(fetchMock.mock.calls[0][1]?.body)).get('state')).toBeNull()
+  })
+
   it('knows when a token is about to expire', () => {
     expect(needsRefresh({ accessToken: '' })).toBe(true)
     expect(needsRefresh({ accessToken: 'a' })).toBe(false)

+ 2 - 1
src/__tests__/features/integrations/registry.test.ts

@@ -53,7 +53,8 @@ describe('integration registry', () => {
         if (s.type === 'remote-select') expect(s.source).toBeTruthy()
       }
       if (m.auth.type === 'oauth2') {
-        expect(m.auth.scopes.length).toBeGreaterThan(0)
+        // Fiken's consent has no scopes; every other vendor names what it asks for.
+        if (m.id !== 'fiken') expect(m.auth.scopes.length).toBeGreaterThan(0)
         expect(m.auth.authorizeUrl).toMatch(/^https:\/\//)
         expect(m.auth.tokenUrl).toMatch(/^https:\/\//)
       }

+ 7 - 0
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -577,6 +577,13 @@ export function ConnectionSettings({
 
       {connected && manifest.settings.length > 0 && (
         <SettingsForm
+          // A list can hang on a choice above it, the way a company's
+          // accounts hang on the company. Once that choice is saved the form
+          // starts over and reads the lists again.
+          key={manifest.settings
+            .filter((f) => f.type === 'remote-select' && f.required)
+            .map((f) => String(connection.settings[f.key] ?? ''))
+            .join('|')}
           connectorId={manifest.id}
           fields={manifest.settings}
           initial={connection.settings}

+ 1 - 0
src/app/api/integrations/[connector]/oauth/callback/route.ts

@@ -65,6 +65,7 @@ export async function GET(
       code,
       redirectUri: redirectUriFor(appUrl(), connector),
       codeVerifier: previous.codeVerifier,
+      state,
       previous,
     })
   } catch (err) {

+ 18 - 0
src/features/integrations/Lib/accounting-sync.ts

@@ -231,6 +231,24 @@ export function invoiceUrl(appUrl: string, invoice: Pick<AccountingInvoice, 'id'
   return serviceUrl(appUrl, invoice)
 }
 
+/**
+ * The invoice as the customer received it, for a ledger that files the
+ * document behind each sale. The same copy the share link serves, in the
+ * language the workshop sends its customers.
+ */
+export async function loadInvoicePdfForAccounting(
+  organizationId: string,
+  serviceRecordId: string
+): Promise<{ buffer: Uint8Array; filename: string } | null> {
+  const record = await db.serviceRecord.findFirst({
+    where: { id: serviceRecordId, organizationId },
+    select: { id: true },
+  })
+  if (!record) return null
+  const { buildInvoicePdfBuffer } = await import('@/features/invoices/Pdf/buildInvoicePdfBuffer')
+  return buildInvoicePdfBuffer(serviceRecordId, null)
+}
+
 export async function loadPaymentForAccounting(
   organizationId: string,
   paymentId: string

+ 10 - 0
src/features/integrations/Lib/checksum.ts

@@ -0,0 +1,10 @@
+/** Stable hash of a body, so an unchanged record is not pushed twice. */
+export function checksumOf(body: unknown): string {
+  const input = JSON.stringify(body)
+  let h = 2166136261
+  for (let i = 0; i < input.length; i++) {
+    h ^= input.charCodeAt(i)
+    h = Math.imul(h, 16777619) >>> 0
+  }
+  return h.toString(16)
+}

+ 2 - 1
src/features/integrations/Lib/http.ts

@@ -115,7 +115,8 @@ export function createConnectorHttp(input: {
       const headers = new Headers(init?.headers)
       for (const [k, v] of Object.entries(await authHeaders(forceRefresh))) headers.set(k, v)
       forceRefresh = false
-      if (init?.body && !headers.has('Content-Type'))
+      // A form carries its own type, with the boundary only fetch can write.
+      if (init?.body && !(init.body instanceof FormData) && !headers.has('Content-Type'))
         headers.set('Content-Type', 'application/json')
       let res: Response
       try {

+ 5 - 1
src/features/integrations/Lib/oauth.ts

@@ -76,7 +76,8 @@ export function buildAuthorizeUrl(input: {
   url.searchParams.set('response_type', 'code')
   url.searchParams.set('client_id', input.client.clientId)
   url.searchParams.set('redirect_uri', input.redirectUri)
-  url.searchParams.set('scope', input.spec.scopes.join(' '))
+  // A vendor without scopes, such as Fiken, gets no empty scope parameter.
+  if (input.spec.scopes.length > 0) url.searchParams.set('scope', input.spec.scopes.join(' '))
   url.searchParams.set('state', input.state)
   for (const [k, v] of Object.entries(input.spec.authorizeParams ?? {})) url.searchParams.set(k, v)
   if (input.codeVerifier) {
@@ -152,6 +153,8 @@ export async function exchangeCode(input: {
   code: string
   redirectUri: string
   codeVerifier?: string
+  /** The state the callback carried, for vendors that want it again here. */
+  state?: string
   previous?: OAuthCredentials
 }): Promise<OAuthCredentials> {
   const body = await tokenRequest(input.spec, input.client, {
@@ -159,6 +162,7 @@ export async function exchangeCode(input: {
     code: input.code,
     redirect_uri: input.redirectUri,
     ...(input.codeVerifier && { code_verifier: input.codeVerifier }),
+    ...(input.spec.stateOnExchange && input.state && { state: input.state }),
   })
   return toCredentials(body, input.previous ?? { accessToken: '' })
 }

+ 5 - 0
src/features/integrations/Lib/types.ts

@@ -79,6 +79,11 @@ export type AuthSpec =
        * else.
        */
       callbackParams?: string[]
+      /**
+       * Repeat the state on the code exchange. The standard only puts it on
+       * the authorize request; Fiken lists it as required on both.
+       */
+      stateOnExchange?: boolean
       /** Environment variable names holding the platform-owned app's client id and secret. */
       platformEnv?: { clientId: string; clientSecret: string }
       /** Fields a workshop fills in to use its own app. */

+ 147 - 0
src/integrations/fiken/manifest.ts

@@ -0,0 +1,147 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * Fiken: issued invoices, their customers and their payments go to the
+ * books, and payments recorded in Fiken come back.
+ *
+ * Fiken numbers every invoice it issues itself, so an invoice written here
+ * goes in as a sale ("Annet salg") of the kind Fiken keeps for invoices made
+ * elsewhere, under its own number, with the document attached.
+ *
+ * The OAuth app has no scopes, takes the client credentials in a Basic
+ * header and wants the state repeated on the code exchange. One Fiken user
+ * can hold several companies and the callback names none, so which company
+ * to write to is the first setting.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'fiken',
+  name: 'Fiken',
+  category: 'accounting',
+  countries: ['NO'],
+  logo: '/images/integrations/fiken.svg',
+  docs: '/docs/integrations/fiken',
+  auth: {
+    type: 'oauth2',
+    authorizeUrl: 'https://fiken.no/oauth/authorize',
+    tokenUrl: 'https://fiken.no/oauth/token',
+    scopes: [],
+    tokenAuth: 'basic',
+    stateOnExchange: true,
+    platformEnv: {
+      clientId: 'FIKEN_INTEGRATION_CLIENT_ID',
+      clientSecret: 'FIKEN_INTEGRATION_CLIENT_SECRET',
+    },
+    tenantFields: [
+      { key: 'clientId', label: 'clientId', type: 'text', required: true },
+      { key: 'clientSecret', label: 'clientSecret', type: 'password', required: true },
+    ],
+    tenantHelp: 'tenantHelp',
+  },
+  capabilities: ['accounting.invoices', 'accounting.customers', 'accounting.payments'],
+  settings: [
+    {
+      key: 'companySlug',
+      type: 'remote-select',
+      label: 'companySlug',
+      help: 'companySlugHelp',
+      source: 'companies',
+      required: true,
+    },
+    { key: 'pushInvoices', type: 'boolean', label: 'pushInvoices', default: true },
+    {
+      key: 'pushOnComplete',
+      type: 'boolean',
+      label: 'pushOnComplete',
+      help: 'pushOnCompleteHelp',
+      default: false,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'startDate',
+      type: 'date',
+      label: 'startDate',
+      help: 'startDateHelp',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'attachPdf',
+      type: 'boolean',
+      label: 'attachPdf',
+      help: 'attachPdfHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'laborAccount',
+      type: 'remote-select',
+      label: 'laborAccount',
+      help: 'laborAccountHelp',
+      source: 'incomeAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'partsAccount',
+      type: 'remote-select',
+      label: 'partsAccount',
+      help: 'partsAccountHelp',
+      source: 'incomeAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'zeroVatType',
+      type: 'select',
+      label: 'zeroVatType',
+      help: 'zeroVatTypeHelp',
+      options: [
+        { value: 'NONE', label: 'vatNone' },
+        { value: 'EXEMPT', label: 'vatExempt' },
+        { value: 'OUTSIDE', label: 'vatOutside' },
+        { value: 'EXEMPT_IMPORT_EXPORT', label: 'vatExport' },
+        { value: 'EXEMPT_REVERSE', label: 'vatReverse' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'zeroAccount',
+      type: 'remote-select',
+      label: 'zeroAccount',
+      help: 'zeroAccountHelp',
+      source: 'incomeAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    { key: 'pushPayments', type: 'boolean', label: 'pushPayments', default: true },
+    {
+      key: 'paymentAccount',
+      type: 'remote-select',
+      label: 'paymentAccount',
+      help: 'paymentAccountHelp',
+      source: 'paymentAccounts',
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'manualPaidAsPayment',
+      type: 'boolean',
+      label: 'manualPaidAsPayment',
+      help: 'manualPaidAsPaymentHelp',
+      default: false,
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'pullPayments',
+      type: 'boolean',
+      label: 'pullPayments',
+      help: 'pullPaymentsHelp',
+      default: true,
+    },
+  ],
+  subscriptions: [
+    { event: 'service.create', job: 'accounting.invoice' },
+    { event: 'service.update', job: 'accounting.invoice' },
+    { event: 'service.status', job: 'accounting.invoice' },
+    { event: 'service.delete', job: 'accounting.invoice' },
+    { event: 'customer.update', job: 'accounting.customer' },
+    { event: 'payment.create', job: 'accounting.payment' },
+    { event: 'payment.delete', job: 'accounting.payment' },
+  ],
+  schedules: [{ job: 'accounting.pull', everyMinutes: 30 }],
+}

+ 363 - 0
src/integrations/fiken/mapping.ts

@@ -0,0 +1,363 @@
+/**
+ * Torqvoice records as Fiken entities, and back.
+ *
+ * Pure functions so the shape the API receives can be tested without a
+ * company. Fiken counts money in whole øre and takes the net and the VAT of
+ * every line as given, so a body carries exactly what the workshop billed:
+ * the lines here are shares of the invoice's own net and VAT, and add up to
+ * its total to the øre.
+ */
+
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+  AccountingPayment,
+} from '@/features/integrations/Lib/accounting-sync'
+import { zonedDayKey } from '@/lib/timezone'
+
+export { checksumOf } from '@/features/integrations/Lib/checksum'
+
+export const API = 'https://api.fiken.no/api/v2'
+export const REVOKE_URL = 'https://fiken.no/oauth/revoke'
+/** Fiken keeps its books in kroner; a sale in another currency needs both amounts. */
+export const CURRENCY = 'NOK'
+/** The contact counter sales are booked to when no customer was recorded. */
+export const WALK_IN_NAME = 'Kontantkunde'
+/** Sales income liable to VAT in the Norwegian standard chart of accounts. */
+export const DEFAULT_INCOME_ACCOUNT = '3000'
+/** Contact names, emails and line descriptions stop here. */
+export const TEXT_MAX = 200
+export const PHONE_MAX = 20
+/** The most a list call returns per page. */
+export const PAGE_SIZE = 100
+
+/** The VAT types a sale can carry, as Fiken names them. */
+export const SALES_VAT_TYPES = [
+  'NONE',
+  'HIGH',
+  'MEDIUM',
+  'RAW_FISH',
+  'LOW',
+  'EXEMPT_IMPORT_EXPORT',
+  'EXEMPT',
+  'OUTSIDE',
+  'EXEMPT_REVERSE',
+] as const
+
+/** The ones without a rate, for an invoice that carries no VAT. */
+export const ZERO_VAT_TYPES = [
+  'NONE',
+  'EXEMPT',
+  'OUTSIDE',
+  'EXEMPT_IMPORT_EXPORT',
+  'EXEMPT_REVERSE',
+] as const
+
+export type ZeroVatType = (typeof ZERO_VAT_TYPES)[number]
+
+export interface FikenUser {
+  name?: string
+  email?: string
+}
+
+export interface FikenCompany {
+  name?: string
+  slug: string
+  organizationNumber?: string
+  /** "no" when the company is not registered for VAT, otherwise how often it reports. */
+  vatType?: string
+  hasApiAccess?: boolean
+  testCompany?: boolean
+}
+
+export interface FikenContact {
+  contactId: number
+  name: string
+  email?: string
+  organizationNumber?: string
+  customer?: boolean
+  supplier?: boolean
+  inactive?: boolean
+  [key: string]: unknown
+}
+
+export interface FikenAccount {
+  code: string
+  name?: string
+}
+
+export interface FikenBankAccount {
+  bankAccountId?: number
+  name?: string
+  accountCode: string
+  type?: string
+  inactive?: boolean
+}
+
+export interface FikenPayment {
+  paymentId?: number
+  date: string
+  account: string
+  /** Øre. */
+  amount: number
+  currency?: string
+}
+
+export interface FikenOrderLine {
+  description: string
+  /** Øre, without VAT. */
+  netPrice: number
+  /** Øre. */
+  vat: number
+  account: string
+  vatType: string
+}
+
+export interface FikenSale {
+  saleId: number
+  saleNumber?: string
+  date?: string
+  kind?: string
+  netAmount?: number
+  vatAmount?: number
+  settled?: boolean
+  totalPaid?: number
+  outstandingBalance?: number
+  salePayments?: FikenPayment[]
+  deleted?: boolean
+}
+
+/** An amount as Fiken counts it: whole øre. */
+export function toCents(amount: number): number {
+  return Math.round((amount + Number.EPSILON) * 100)
+}
+
+export function fromCents(cents: number): number {
+  return Math.round(cents) / 100
+}
+
+/**
+ * The Norwegian VAT type that carries a rate, or null when no type does.
+ * These are the rates of the VAT act: 25 general, 15 food, 12 transport and
+ * lodging, 11.11 first-hand sale of fish.
+ */
+export function vatTypeForRate(rate: number): string | null {
+  if (Math.abs(rate - 25) < 0.005) return 'HIGH'
+  if (Math.abs(rate - 15) < 0.005) return 'MEDIUM'
+  if (Math.abs(rate - 12) < 0.005) return 'LOW'
+  if (Math.abs(rate - 11.11) < 0.005) return 'RAW_FISH'
+  return null
+}
+
+export function isZeroVatType(value: unknown): value is ZeroVatType {
+  return typeof value === 'string' && (ZERO_VAT_TYPES as readonly string[]).includes(value)
+}
+
+/**
+ * Split a whole number of øre over weights so the parts add up to it
+ * exactly: each part is rounded down and the øre left over go to the parts
+ * that lost the most to the rounding.
+ */
+export function allocate(total: number, weights: number[]): number[] {
+  const sum = weights.reduce((a, b) => a + b, 0)
+  if (weights.length === 0 || sum === 0) return weights.map(() => 0)
+  const exact = weights.map((w) => (total * w) / sum)
+  const parts = exact.map((x) => Math.floor(x))
+  let left = total - parts.reduce((a, b) => a + b, 0)
+  const order = exact
+    .map((x, i) => ({ i, lost: x - Math.floor(x) }))
+    .sort((a, b) => b.lost - a.lost || a.i - b.i)
+  for (const { i } of order) {
+    if (left <= 0) break
+    parts[i] += 1
+    left -= 1
+  }
+  return parts
+}
+
+/** The vendor's own wording for a failed call, or the raw body when there is none. */
+export function faultMessage(body: string): string {
+  try {
+    const parsed = JSON.parse(body) as unknown
+    const rows = Array.isArray(parsed) ? parsed : [parsed]
+    const lines = rows
+      .map((row) => {
+        if (!row || typeof row !== 'object') return null
+        const r = row as Record<string, unknown>
+        const text = r.error_description ?? r.message ?? r.error
+        return typeof text === 'string' ? text : null
+      })
+      .filter(Boolean)
+    if (lines.length) return lines.join('; ')
+  } catch {
+    // not JSON
+  }
+  return body.slice(0, 300)
+}
+
+/** The id a create answers with: the last segment of its Location header. */
+export function idFromLocation(location: string | null): number | null {
+  const last = location?.split('?')[0].replace(/\/$/, '').split('/').pop()
+  const id = Number(last)
+  return last && Number.isInteger(id) && id > 0 ? id : null
+}
+
+export function contactName(c: AccountingCustomer): string {
+  return c.name.trim().slice(0, TEXT_MAX) || c.company?.trim().slice(0, TEXT_MAX) || 'Kunde'
+}
+
+/**
+ * A Norwegian organisation number out of a free-text tax id, or null. Nine
+ * digits, however it was typed: "NO 987 654 321 MVA" is the same number.
+ */
+export function organizationNumber(taxId: string | null): string | null {
+  const digits = (taxId ?? '').replace(/\D/g, '')
+  return digits.length === 9 ? digits : null
+}
+
+/**
+ * The contact as Fiken stores one. The address stays out: it is one
+ * free-text block here and Fiken wants street, postcode, city and a country
+ * it recognises, and a wrong guess would refuse the whole contact.
+ */
+export function buildContact(c: AccountingCustomer): Record<string, unknown> {
+  const orgNumber = organizationNumber(c.taxId)
+  return {
+    name: contactName(c),
+    ...(c.email && { email: c.email.slice(0, TEXT_MAX) }),
+    ...(orgNumber && { organizationNumber: orgNumber }),
+    ...(c.phone && { phoneNumber: c.phone.slice(0, PHONE_MAX) }),
+    ...(c.customerNumber && { memberNumberString: c.customerNumber }),
+    customer: true,
+  }
+}
+
+/** What a contact update may carry over from the one Fiken already has. */
+const CONTACT_KEPT = [
+  'supplier',
+  'bankAccountNumber',
+  'language',
+  'inactive',
+  'daysUntilInvoicingDueDate',
+  'address',
+  'groups',
+  'discount',
+] as const
+
+/**
+ * The body for an update. Fiken replaces the contact with what it is sent,
+ * so what the bookkeeper filled in there rides along, and only the fields
+ * this app owns are overwritten.
+ */
+export function mergeContact(
+  current: FikenContact,
+  ours: Record<string, unknown>
+): Record<string, unknown> {
+  const kept: Record<string, unknown> = {}
+  for (const key of CONTACT_KEPT) {
+    if (current[key] !== undefined && current[key] !== null) kept[key] = current[key]
+  }
+  return {
+    ...kept,
+    // Without one of ours, the email and organisation number there stay.
+    ...(current.email && { email: current.email }),
+    ...(current.organizationNumber && { organizationNumber: current.organizationNumber }),
+    ...ours,
+  }
+}
+
+export interface SaleOptions {
+  customerId: number
+  laborAccount: string
+  partsAccount: string
+  /** Income account for an invoice without VAT; the two above when null. */
+  zeroAccount: string | null
+  /** The VAT type for this invoice's rate, when it carries VAT. */
+  vatType: string | null
+  /** The VAT type for an invoice without VAT. */
+  zeroVatType: string
+  timezone: string
+}
+
+/** Whether VAT was billed, which is what decides how the sale is booked. */
+export function carriesVat(inv: AccountingInvoice): boolean {
+  return toCents(inv.taxAmount) > 0
+}
+
+function lineDescription(line: AccountingInvoice['lines'][number]): string {
+  const text = [line.partNumber, line.description].filter(Boolean).join(' ').trim()
+  return (text || (line.kind === 'labor' ? 'Arbeid' : 'Deler')).slice(0, TEXT_MAX)
+}
+
+/**
+ * The sale body: "Annet salg" of the kind Fiken keeps for invoices written
+ * and numbered elsewhere. The invoice's net and VAT are shared over its
+ * lines by their size, which spreads a discount the way the invoice did and
+ * keeps the total to the øre whether prices were entered with or without
+ * VAT. Lines without an amount carry no money and stay out.
+ */
+export function buildSale(inv: AccountingInvoice, o: SaleOptions): Record<string, unknown> {
+  const gross = toCents(inv.totalAmount)
+  const taxable = carriesVat(inv)
+  const vat = taxable ? toCents(inv.taxAmount) : 0
+  const net = gross - vat
+  const vatType = taxable && o.vatType ? o.vatType : o.zeroVatType
+  const accountFor = (kind: 'labor' | 'part') =>
+    (!taxable && o.zeroAccount) || (kind === 'labor' ? o.laborAccount : o.partsAccount)
+
+  const charged = inv.lines.filter((l) => toCents(l.total) !== 0)
+  const weights = charged.map((l) => toCents(l.total))
+  const weightSum = weights.reduce((a, b) => a + b, 0)
+  let lines: FikenOrderLine[]
+  if (charged.length > 0 && weightSum !== 0 && net !== 0) {
+    const nets = allocate(net, weights)
+    const vats = allocate(vat, nets)
+    lines = charged.map((line, i) => ({
+      description: lineDescription(line),
+      netPrice: nets[i],
+      vat: vats[i],
+      account: accountFor(line.kind),
+      vatType,
+    }))
+  } else {
+    // An old record with a total and no lines, or lines that cancel out.
+    lines = [
+      {
+        description: `Faktura ${inv.invoiceNumber ?? ''}`.trim().slice(0, TEXT_MAX),
+        netPrice: net,
+        vat,
+        account: accountFor('labor'),
+        vatType,
+      },
+    ]
+  }
+  return {
+    saleNumber: inv.invoiceNumber,
+    date: zonedDayKey(inv.invoiceDate, o.timezone),
+    kind: 'external_invoice',
+    lines,
+    customerId: o.customerId,
+    currency: CURRENCY,
+    ...(inv.dueDate && { dueDate: zonedDayKey(inv.dueDate, o.timezone) }),
+  }
+}
+
+export function buildPayment(
+  p: Pick<AccountingPayment, 'amount' | 'date'>,
+  o: { account: string; timezone: string }
+): FikenPayment {
+  return {
+    date: zonedDayKey(p.date, o.timezone),
+    account: o.account,
+    amount: toCents(p.amount),
+  }
+}
+
+/**
+ * A Fiken payment account as one of the app's own methods. 1900 is cash in
+ * hand in the standard chart; everything else a payment lands on is a bank
+ * account.
+ */
+export function localPaymentMethod(account: string | undefined): string {
+  return (account ?? '').startsWith('1900') ? 'cash' : 'transfer'
+}

+ 1008 - 0
src/integrations/fiken/server.ts

@@ -0,0 +1,1008 @@
+import { createHash, randomUUID } from 'node:crypto'
+import {
+  type AccountingCustomer,
+  type AccountingInvoice,
+  type AccountingPayment,
+  CUSTOMER_ENTITY,
+  INVOICE_ENTITY,
+  PAYMENT_ENTITY,
+  loadCustomerForAccounting,
+  loadInvoiceForAccounting,
+  loadInvoicePdfForAccounting,
+  loadPaymentForAccounting,
+  recordPulledPayment,
+  removePulledPayment,
+  workshopCurrency,
+} from '@/features/integrations/Lib/accounting-sync'
+import type {
+  ConnectorContext,
+  ConnectorServer,
+  JobOutcome,
+  LinkRecord,
+} from '@/features/integrations/Lib/types'
+import { zonedDayKey } from '@/lib/timezone'
+import { manifest } from './manifest'
+import {
+  API,
+  CURRENCY,
+  DEFAULT_INCOME_ACCOUNT,
+  type FikenAccount,
+  type FikenBankAccount,
+  type FikenCompany,
+  type FikenContact,
+  type FikenSale,
+  type FikenUser,
+  PAGE_SIZE,
+  REVOKE_URL,
+  WALK_IN_NAME,
+  type ZeroVatType,
+  buildContact,
+  buildPayment,
+  buildSale,
+  carriesVat,
+  checksumOf,
+  contactName,
+  faultMessage,
+  fromCents,
+  idFromLocation,
+  isZeroVatType,
+  localPaymentMethod,
+  mergeContact,
+  organizationNumber,
+  toCents,
+  vatTypeForRate,
+} from './mapping'
+
+const PROVIDER = 'fiken'
+/** A list is read to the end, but not without one: 50 pages is 5000 rows. */
+const MAX_PAGES = 50
+/** What is known about the company is read again after a day. */
+const COMPANY_TTL_MS = 24 * 60 * 60 * 1000
+
+class FikenError extends Error {
+  status: number
+  /** The X-Request-ID sent with the call; Fiken's support asks for it. */
+  requestId: string
+  constructor(status: number, body: string, requestId: string) {
+    super(`Fiken: ${faultMessage(body) || `HTTP ${status}`} (request ${requestId})`)
+    this.status = status
+    this.requestId = requestId
+  }
+}
+
+interface CompanyFacts {
+  slug: string
+  /** Not registered for VAT means every sale goes without VAT handling. */
+  vatRegistered: boolean
+  fetchedAt: string
+}
+
+interface State {
+  company: CompanyFacts | null
+  walkIn: { slug: string; contactId: number } | null
+  paymentAccount: { slug: string; code: string } | null
+  lastPullDay: string | null
+}
+
+function stateOf(ctx: ConnectorContext): State {
+  const s = ctx.connection.state
+  const obj = <T>(v: unknown): T | null => (typeof v === 'object' && v !== null ? (v as T) : null)
+  return {
+    company: obj<CompanyFacts>(s.company),
+    walkIn: obj<NonNullable<State['walkIn']>>(s.walkIn),
+    paymentAccount: obj<NonNullable<State['paymentAccount']>>(s.paymentAccount),
+    lastPullDay: typeof s.lastPullDay === 'string' ? s.lastPullDay : null,
+  }
+}
+
+function settingsOf(ctx: ConnectorContext) {
+  const s = ctx.connection.settings
+  const str = (k: string) => (typeof s[k] === 'string' && (s[k] as string).trim()) || null
+  const startDate = str('startDate')
+  const zeroVatType = str('zeroVatType')
+  return {
+    companySlug: str('companySlug'),
+    pushInvoices: s.pushInvoices !== false,
+    pushOnComplete: s.pushOnComplete === true,
+    startDate: startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate) ? startDate : null,
+    attachPdf: s.attachPdf !== false,
+    laborAccount: str('laborAccount') ?? DEFAULT_INCOME_ACCOUNT,
+    partsAccount: str('partsAccount') ?? DEFAULT_INCOME_ACCOUNT,
+    zeroVatType: isZeroVatType(zeroVatType) ? zeroVatType : null,
+    zeroAccount: str('zeroAccount'),
+    pushPayments: s.pushPayments !== false,
+    paymentAccount: str('paymentAccount'),
+    manualPaidAsPayment: s.manualPaidAsPayment === true,
+    pullPayments: s.pullPayments !== false,
+  }
+}
+
+const NO_COMPANY = 'no Fiken company chosen yet'
+
+/* ---------- transport ---------- */
+
+/**
+ * Fiken allows one request at a time and may ban an app that sends two
+ * together, so every call for a connection waits for the one before it,
+ * whether it comes from a job or from somebody opening the settings page.
+ */
+const queues = new Map<string, Promise<unknown>>()
+
+function inTurn<T>(connectionId: string, run: () => Promise<T>): Promise<T> {
+  const before = queues.get(connectionId) ?? Promise.resolve()
+  const result = before.then(run, run)
+  const done = result.catch(() => undefined)
+  queues.set(connectionId, done)
+  void done.then(() => {
+    if (queues.get(connectionId) === done) queues.delete(connectionId)
+  })
+  return result
+}
+
+interface CallInit {
+  method?: string
+  query?: Record<string, string>
+  body?: unknown
+  form?: FormData
+}
+
+async function call(ctx: ConnectorContext, path: string, init: CallInit = {}): Promise<Response> {
+  const url = new URL(`${API}${path}`)
+  for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v)
+  const requestId = randomUUID()
+  const res = await inTurn(ctx.connection.id, () =>
+    ctx.http.fetch(url.toString(), {
+      method: init.method ?? (init.body !== undefined || init.form ? 'POST' : 'GET'),
+      headers: { Accept: 'application/json', 'X-Request-ID': requestId },
+      ...(init.form
+        ? { body: init.form }
+        : init.body !== undefined && { body: JSON.stringify(init.body) }),
+    })
+  )
+  if (!res.ok) throw new FikenError(res.status, await res.text(), requestId)
+  return res
+}
+
+async function read<T>(
+  ctx: ConnectorContext,
+  path: string,
+  query?: Record<string, string>
+): Promise<T> {
+  const res = await call(ctx, path, { query })
+  return (await res.json()) as T
+}
+
+/** Every row of a paged collection. */
+async function list<T>(
+  ctx: ConnectorContext,
+  path: string,
+  query: Record<string, string> = {}
+): Promise<T[]> {
+  const rows: T[] = []
+  for (let page = 0; page < MAX_PAGES; page++) {
+    const res = await call(ctx, path, {
+      query: { ...query, page: String(page), pageSize: String(PAGE_SIZE) },
+    })
+    const batch = (await res.json()) as T[]
+    rows.push(...batch)
+    const pages = Number(res.headers.get('Fiken-Api-Page-Count'))
+    const last = Number.isFinite(pages) && pages > 0 ? page + 1 >= pages : batch.length < PAGE_SIZE
+    if (last) break
+  }
+  return rows
+}
+
+/** A create answers with an empty body and the new record's URL in Location. */
+async function create(
+  ctx: ConnectorContext,
+  path: string,
+  init: Pick<CallInit, 'body' | 'form' | 'query'>
+): Promise<number> {
+  const res = await call(ctx, path, { method: 'POST', ...init })
+  const id = idFromLocation(res.headers.get('Location'))
+  if (!id) throw new Error(`Fiken accepted ${path} but did not say where the new record is`)
+  return id
+}
+
+function isNotFound(err: unknown): boolean {
+  return err instanceof FikenError && err.status === 404
+}
+
+function companyPath(slug: string, rest: string): string {
+  return `/companies/${encodeURIComponent(slug)}${rest}`
+}
+
+/* ---------- links ---------- */
+
+/**
+ * Ids are only good in the company they came from. A workshop that tries the
+ * connection on a test company and then moves to the real one would
+ * otherwise carry the test company's contacts and sales along, so every link
+ * names its company and one from another company counts as no link.
+ */
+async function linkFor(
+  ctx: ConnectorContext,
+  slug: string,
+  entityType: string,
+  entityId: string
+): Promise<LinkRecord | null> {
+  const link = await ctx.links.get(entityType, entityId)
+  return link && link.metadata?.company === slug ? link : null
+}
+
+async function remoteLinkFor(
+  ctx: ConnectorContext,
+  slug: string,
+  entityType: string,
+  remoteId: string
+): Promise<(LinkRecord & { entityId: string }) | null> {
+  const link = await ctx.links.byRemoteId(entityType, remoteId)
+  return link && link.metadata?.company === slug ? link : null
+}
+
+/** Change a link's metadata and keep the rest of it as it was. */
+async function patchLink(
+  ctx: ConnectorContext,
+  entityType: string,
+  entityId: string,
+  link: LinkRecord,
+  patch: Record<string, unknown>
+): Promise<void> {
+  await ctx.links.set(entityType, entityId, {
+    remoteId: link.remoteId,
+    remoteUrl: link.remoteUrl,
+    checksum: link.checksum,
+    metadata: { ...(link.metadata ?? {}), ...patch },
+  })
+}
+
+function paymentIdsOn(link: LinkRecord): string[] {
+  const ids = link.metadata?.payments
+  return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : []
+}
+
+/**
+ * The sale's link keeps the ids of the payments known on it, both the ones
+ * sent from here and the ones brought back, so a pull can tell that one of
+ * them is gone from Fiken.
+ */
+async function rememberPayment(
+  ctx: ConnectorContext,
+  slug: string,
+  serviceRecordId: string,
+  paymentId: string
+): Promise<void> {
+  const link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
+  if (!link || paymentIdsOn(link).includes(paymentId)) return
+  await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, {
+    payments: [...paymentIdsOn(link), paymentId],
+  })
+}
+
+async function forgetPayment(
+  ctx: ConnectorContext,
+  slug: string,
+  serviceRecordId: string,
+  paymentId: string
+): Promise<void> {
+  const link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
+  if (!link || !paymentIdsOn(link).includes(paymentId)) return
+  await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, {
+    payments: paymentIdsOn(link).filter((id) => id !== paymentId),
+  })
+}
+
+/* ---------- company ---------- */
+
+async function companyFacts(ctx: ConnectorContext, slug: string): Promise<CompanyFacts> {
+  const known = stateOf(ctx).company
+  if (known?.slug === slug && Date.now() - new Date(known.fetchedAt).getTime() < COMPANY_TTL_MS) {
+    return known
+  }
+  const company = await read<FikenCompany>(ctx, companyPath(slug, ''))
+  if (company.hasApiAccess === false) {
+    await ctx.log(
+      'warn',
+      'The API module is not switched on for this company in Fiken, so Fiken may refuse what is sent. Order it in Fiken under the company settings, module access.'
+    )
+  }
+  const facts: CompanyFacts = {
+    slug,
+    vatRegistered: company.vatType !== 'no',
+    fetchedAt: new Date().toISOString(),
+  }
+  await ctx.saveState({ company: facts })
+  return facts
+}
+
+/* ---------- contacts ---------- */
+
+async function walkInContact(ctx: ConnectorContext, slug: string): Promise<number> {
+  const known = stateOf(ctx).walkIn
+  if (known?.slug === slug) return known.contactId
+  const found = await list<FikenContact>(ctx, companyPath(slug, '/contacts'), {
+    name: WALK_IN_NAME,
+    customer: 'true',
+  })
+  const contactId =
+    found[0]?.contactId ??
+    (await create(ctx, companyPath(slug, '/contacts'), {
+      body: { name: WALK_IN_NAME, customer: true },
+    }))
+  await ctx.saveState({ walkIn: { slug, contactId } })
+  return contactId
+}
+
+/**
+ * A contact Fiken already has for this customer, so the books do not get a
+ * second one. A business is known by its organisation number. A person is
+ * matched on the name, and only when the email agrees or there is nothing
+ * to tell two people of the same name apart.
+ */
+async function findContact(
+  ctx: ConnectorContext,
+  slug: string,
+  c: AccountingCustomer
+): Promise<FikenContact | null> {
+  const path = companyPath(slug, '/contacts')
+  const orgNumber = organizationNumber(c.taxId)
+  if (orgNumber) {
+    const byNumber = await list<FikenContact>(ctx, path, { organizationNumber: orgNumber })
+    if (byNumber[0]) return byNumber[0]
+  }
+  const byName = await list<FikenContact>(ctx, path, { name: contactName(c), customer: 'true' })
+  const email = c.email?.trim().toLowerCase()
+  if (email) {
+    const same = byName.find((x) => x.email?.trim().toLowerCase() === email)
+    if (same) return same
+    const open = byName.filter((x) => !x.email)
+    return open.length === 1 ? open[0] : null
+  }
+  return byName.length === 1 ? byName[0] : null
+}
+
+/**
+ * Fiken's id for a customer, creating or updating as needed. A contact
+ * found there is left as the bookkeeper has it until the workshop edits the
+ * customer here.
+ */
+async function ensureContact(
+  ctx: ConnectorContext,
+  slug: string,
+  c: AccountingCustomer | null
+): Promise<number> {
+  if (!c) return walkInContact(ctx, slug)
+  const ours = buildContact(c)
+  const checksum = checksumOf(ours)
+  const link = await linkFor(ctx, slug, CUSTOMER_ENTITY, c.id)
+  if (link) {
+    if (link.checksum === checksum) return Number(link.remoteId)
+    try {
+      const path = companyPath(slug, `/contacts/${link.remoteId}`)
+      const current = await read<FikenContact>(ctx, path)
+      await call(ctx, path, { method: 'PUT', body: mergeContact(current, ours) })
+      await patchLink(ctx, CUSTOMER_ENTITY, c.id, { ...link, checksum }, {})
+      return Number(link.remoteId)
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+      await ctx.links.remove(CUSTOMER_ENTITY, c.id)
+    }
+  }
+  const existing = await findContact(ctx, slug, c)
+  if (existing && existing.customer !== true) {
+    // Known there as a supplier only; a sale needs a customer.
+    await call(ctx, companyPath(slug, `/contacts/${existing.contactId}`), {
+      method: 'PUT',
+      body: mergeContact(existing, { name: existing.name, customer: true }),
+    })
+  }
+  const contactId =
+    existing?.contactId ?? (await create(ctx, companyPath(slug, '/contacts'), { body: ours }))
+  await ctx.links.set(CUSTOMER_ENTITY, c.id, {
+    remoteId: String(contactId),
+    checksum,
+    metadata: { company: slug, reused: Boolean(existing) },
+  })
+  return contactId
+}
+
+/* ---------- invoices ---------- */
+
+/** Why the invoice stays out of the books, or null when it goes. */
+function whyNotEligible(
+  inv: AccountingInvoice,
+  s: ReturnType<typeof settingsOf>,
+  tz: string
+): string | null {
+  if (!s.pushInvoices) return 'invoice push switched off'
+  if (!inv.invoiceNumber) return 'no invoice number'
+  const issued = Boolean(inv.issuedAt) || (s.pushOnComplete && inv.status === 'completed')
+  if (!issued) return 'not issued yet'
+  if (s.startDate && zonedDayKey(inv.invoiceDate, tz) < s.startDate) {
+    return 'dated before the start date'
+  }
+  if (toCents(inv.totalAmount) <= 0) return 'no amount to book'
+  return null
+}
+
+function hasPayments(sale: FikenSale): boolean {
+  return (sale.salePayments?.length ?? 0) > 0 || (sale.totalPaid ?? 0) > 0 || sale.settled === true
+}
+
+/** Fiken has no edit for a sale: it is deleted, which books a reversal, and entered again. */
+async function reverseSale(
+  ctx: ConnectorContext,
+  slug: string,
+  saleId: string,
+  description: string
+): Promise<void> {
+  await call(ctx, companyPath(slug, `/sales/${saleId}/delete`), {
+    method: 'PATCH',
+    query: { description },
+  })
+}
+
+/** The record is gone: reverse the sale unless money was taken against it. */
+async function retireInvoice(
+  ctx: ConnectorContext,
+  slug: string,
+  serviceRecordId: string
+): Promise<JobOutcome> {
+  const link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
+  if (!link) return { summary: 'nothing to do' }
+  const number = String(link.metadata?.saleNumber ?? link.remoteId)
+  try {
+    const sale = await read<FikenSale>(ctx, companyPath(slug, `/sales/${link.remoteId}`))
+    if (sale.deleted) {
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      return { summary: 'already deleted in Fiken' }
+    }
+    if (hasPayments(sale)) {
+      await ctx.log(
+        'warn',
+        `Invoice ${number} was deleted here but has payments in Fiken; the sale there is left as it is`
+      )
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      return { summary: 'left in Fiken, has payments' }
+    }
+    await reverseSale(ctx, slug, link.remoteId, 'Deleted in Torqvoice')
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: `sale ${number} reversed` }
+  } catch (err) {
+    if (!isNotFound(err)) throw err
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: 'already gone from Fiken' }
+  }
+}
+
+/**
+ * A new sale. When Fiken already holds one with this number, date and
+ * amounts, it is this invoice from an earlier push whose link was lost, and
+ * is taken over rather than entered twice.
+ */
+async function createSale(
+  ctx: ConnectorContext,
+  slug: string,
+  body: Record<string, unknown>,
+  inv: AccountingInvoice
+): Promise<{ saleId: number; adopted: boolean }> {
+  const lines = body.lines as { netPrice: number; vat: number }[]
+  const net = lines.reduce((a, l) => a + l.netPrice, 0)
+  const vat = lines.reduce((a, l) => a + l.vat, 0)
+  const sameNumber = (
+    await list<FikenSale>(ctx, companyPath(slug, '/sales'), {
+      saleNumber: String(inv.invoiceNumber),
+    })
+  ).filter((s) => !s.deleted)
+  const ours = sameNumber.find(
+    (s) => s.date === body.date && s.netAmount === net && (s.vatAmount ?? 0) === vat
+  )
+  if (ours) {
+    await ctx.log('info', `Invoice ${inv.invoiceNumber} was already in Fiken; linked to it`)
+    return { saleId: ours.saleId, adopted: true }
+  }
+  if (sameNumber.length > 0) {
+    await ctx.log(
+      'warn',
+      `Fiken already has a sale numbered ${inv.invoiceNumber} with other amounts; invoice ${inv.invoiceNumber} was added beside it`
+    )
+  }
+  const saleId = await create(ctx, companyPath(slug, '/sales'), { body })
+  return { saleId, adopted: false }
+}
+
+/** The invoice the customer received, filed behind the sale as its voucher. */
+async function attachInvoice(
+  ctx: ConnectorContext,
+  slug: string,
+  saleId: string,
+  inv: AccountingInvoice
+): Promise<boolean> {
+  try {
+    const pdf = await loadInvoicePdfForAccounting(ctx.connection.organizationId, inv.id)
+    if (!pdf) return false
+    // Fiken takes the name only when it ends in a type it knows.
+    const filename = `${String(inv.invoiceNumber).replace(/[^\w.-]+/g, '_')}.pdf`
+    const form = new FormData()
+    form.set('filename', filename)
+    form.set('file', new Blob([pdf.buffer as BlobPart], { type: 'application/pdf' }), filename)
+    // Not create(): an attachment is known by a uuid, not by a number.
+    await call(ctx, companyPath(slug, `/sales/${saleId}/attachments`), {
+      method: 'POST',
+      query: { attachToSale: 'true' },
+      form,
+    })
+    return true
+  } catch (err) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} is in Fiken but its PDF could not be attached: ${err instanceof Error ? err.message : String(err)}`
+    )
+    return false
+  }
+}
+
+async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  const slug = settings.companySlug
+  if (!slug) return { summary: NO_COMPANY }
+  const inv = await loadInvoiceForAccounting(ctx.connection.organizationId, serviceRecordId)
+  if (!inv) return retireInvoice(ctx, slug, serviceRecordId)
+  const skip = whyNotEligible(inv, settings, ctx.timezone)
+  if (skip) return { summary: skip }
+
+  const currency = await workshopCurrency(ctx.connection.organizationId)
+  if (currency && currency !== CURRENCY) {
+    return { summary: `not sent: the workshop bills in ${currency} and Fiken keeps ${CURRENCY}` }
+  }
+
+  const company = await companyFacts(ctx, slug)
+  const taxable = carriesVat(inv)
+  const vatType = taxable ? vatTypeForRate(inv.taxRate) : null
+  if (taxable && !vatType) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} was not sent: it carries ${inv.taxRate}% VAT and Fiken has no VAT type with that rate (25, 15, 12 or 11.11)`
+    )
+    return { summary: `invoice ${inv.invoiceNumber} not sent, no VAT type for ${inv.taxRate}%` }
+  }
+  // A company outside the VAT register has one way to book a sale. One
+  // inside it has several ways to book a sale without VAT, and which is
+  // right is the bookkeeper's call, not a guess made here.
+  const zeroVatType: ZeroVatType | null =
+    settings.zeroVatType ?? (company.vatRegistered ? null : 'NONE')
+  if (!taxable && !zeroVatType) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} was not sent: it carries no VAT. Choose the VAT type for invoices without VAT in the integration settings.`
+    )
+    return {
+      summary: `invoice ${inv.invoiceNumber} not sent, VAT type for tax-free sales not chosen`,
+    }
+  }
+
+  const customerId = await ensureContact(ctx, slug, inv.customer)
+  const body = buildSale(inv, {
+    customerId,
+    laborAccount: settings.laborAccount,
+    partsAccount: settings.partsAccount,
+    zeroAccount: settings.zeroAccount,
+    vatType,
+    zeroVatType: zeroVatType ?? 'NONE',
+    timezone: ctx.timezone,
+  })
+  const checksum = checksumOf(body)
+  let link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
+
+  let action = 'unchanged'
+  if (link && link.checksum !== checksum) {
+    try {
+      const sale = await read<FikenSale>(ctx, companyPath(slug, `/sales/${link.remoteId}`))
+      if (sale.deleted) {
+        await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+        link = null
+      } else if (hasPayments(sale)) {
+        // Reversing a sale that has been paid would leave the money without
+        // a sale. The bookkeeper hears about it once per change.
+        if (link.metadata?.stale !== checksum) {
+          await ctx.log(
+            'warn',
+            `Invoice ${inv.invoiceNumber} changed here after it was paid in Fiken. The sale there is left as it is; correct it in Fiken.`
+          )
+          await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, { stale: checksum })
+        }
+        action = 'left as it is in Fiken, has payments'
+      } else {
+        await reverseSale(ctx, slug, link.remoteId, 'Changed in Torqvoice')
+        await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+        link = null
+        action = 'reversed and entered again'
+      }
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      link = null
+    }
+  }
+  if (!link) {
+    const made = await createSale(ctx, slug, body, inv)
+    if (action === 'unchanged') action = made.adopted ? 'linked' : 'created'
+    await ctx.links.set(INVOICE_ENTITY, serviceRecordId, {
+      remoteId: String(made.saleId),
+      checksum,
+      metadata: {
+        company: slug,
+        saleNumber: inv.invoiceNumber,
+        contactId: customerId,
+        // A sale taken over may already have its document; it is not sent twice.
+        attached: made.adopted,
+        payments: [],
+      },
+    })
+    link = await linkFor(ctx, slug, INVOICE_ENTITY, serviceRecordId)
+  }
+  if (!link) throw new Error('The sale was saved in Fiken but its link could not be read back')
+
+  if (settings.attachPdf && link.metadata?.attached !== true) {
+    if (await attachInvoice(ctx, slug, link.remoteId, inv)) {
+      await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, { attached: true })
+    }
+  }
+
+  let paymentsPushed = 0
+  if (settings.pushPayments) {
+    for (const p of inv.payments) {
+      if (await linkFor(ctx, slug, PAYMENT_ENTITY, p.id)) continue
+      if (await pushPaymentRow(ctx, slug, p, link.remoteId)) paymentsPushed++
+    }
+    if (settings.manualPaidAsPayment && inv.manuallyPaid) {
+      if (await settleByHand(ctx, slug, inv, link.remoteId)) paymentsPushed++
+    }
+  }
+  const summary = `invoice ${inv.invoiceNumber} ${action}`
+  return { summary: paymentsPushed ? `${summary}, ${paymentsPushed} payments recorded` : summary }
+}
+
+/* ---------- payments ---------- */
+
+/**
+ * The account a payment lands on. The workshop's choice, or the company's
+ * first ordinary bank account, found once and remembered.
+ */
+async function paymentAccount(ctx: ConnectorContext, slug: string): Promise<string> {
+  const chosen = settingsOf(ctx).paymentAccount
+  if (chosen) return chosen
+  const known = stateOf(ctx).paymentAccount
+  if (known?.slug === slug) return known.code
+  const accounts = await list<FikenBankAccount>(ctx, companyPath(slug, '/bankAccounts'), {
+    inactive: 'false',
+  })
+  const account = accounts.find((a) => a.type === 'normal') ?? accounts[0]
+  if (!account) {
+    throw new Error(
+      'Fiken has no bank account to record payments on; add one in Fiken or choose an account in the integration settings'
+    )
+  }
+  await ctx.saveState({ paymentAccount: { slug, code: account.accountCode } })
+  return account.accountCode
+}
+
+async function recordPayment(
+  ctx: ConnectorContext,
+  slug: string,
+  input: { entityId: string; serviceRecordId: string; amount: number; date: Date; manual?: true },
+  saleId: string
+): Promise<void> {
+  const body = buildPayment(input, {
+    account: await paymentAccount(ctx, slug),
+    timezone: ctx.timezone,
+  })
+  const paymentId = await create(ctx, companyPath(slug, `/sales/${saleId}/payments`), { body })
+  await ctx.links.set(PAYMENT_ENTITY, input.entityId, {
+    remoteId: String(paymentId),
+    metadata: {
+      company: slug,
+      createdByUs: true,
+      serviceRecordId: input.serviceRecordId,
+      saleId,
+      ...(input.manual && { manual: true }),
+    },
+  })
+  await rememberPayment(ctx, slug, input.serviceRecordId, String(paymentId))
+}
+
+async function pushPaymentRow(
+  ctx: ConnectorContext,
+  slug: string,
+  p: AccountingPayment,
+  saleId: string
+): Promise<boolean> {
+  // Money handed back is not a payment on the sale; Fiken books that its own way.
+  if (toCents(p.amount) <= 0) return false
+  await recordPayment(
+    ctx,
+    slug,
+    { entityId: p.id, serviceRecordId: p.serviceRecordId, amount: p.amount, date: p.date },
+    saleId
+  )
+  return true
+}
+
+/**
+ * An invoice marked paid by hand has no payment row here, so Fiken would
+ * show it open forever. When the workshop asks for it, one payment for
+ * whatever Fiken still shows as owed closes it, once.
+ */
+async function settleByHand(
+  ctx: ConnectorContext,
+  slug: string,
+  inv: AccountingInvoice,
+  saleId: string
+): Promise<boolean> {
+  const entityId = `manual:${inv.id}`
+  if (await linkFor(ctx, slug, PAYMENT_ENTITY, entityId)) return false
+  const sale = await read<FikenSale>(ctx, companyPath(slug, `/sales/${saleId}`))
+  const owed = sale.outstandingBalance ?? 0
+  if (!(owed > 0) || sale.settled) return false
+  await recordPayment(
+    ctx,
+    slug,
+    {
+      entityId,
+      serviceRecordId: inv.id,
+      amount: fromCents(owed),
+      date: inv.issuedAt ?? new Date(),
+      manual: true,
+    },
+    saleId
+  )
+  return true
+}
+
+async function pushPayment(ctx: ConnectorContext, paymentId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  const slug = settings.companySlug
+  if (!slug) return { summary: NO_COMPANY }
+  const p = await loadPaymentForAccounting(ctx.connection.organizationId, paymentId)
+  const link = await linkFor(ctx, slug, PAYMENT_ENTITY, paymentId)
+  if (!p) {
+    if (!link) return { summary: 'nothing to do' }
+    const serviceRecordId = String(link.metadata?.serviceRecordId ?? '')
+    if (link.metadata?.createdByUs !== true) {
+      // A payment Fiken made and this app then deleted stays in Fiken.
+      await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+      await forgetPayment(ctx, slug, serviceRecordId, link.remoteId)
+      return { summary: 'payment came from Fiken; left there' }
+    }
+    try {
+      await call(
+        ctx,
+        companyPath(slug, `/sales/${String(link.metadata?.saleId)}/payments/${link.remoteId}`),
+        { method: 'DELETE', query: { description: 'Deleted in Torqvoice' } }
+      )
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+    }
+    await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+    await forgetPayment(ctx, slug, serviceRecordId, link.remoteId)
+    return { summary: 'payment deleted' }
+  }
+  if (link) return { summary: 'unchanged' }
+  if (!settings.pushPayments) return { summary: 'payments not sent' }
+
+  // The payment needs its sale in Fiken first; pushing the invoice also
+  // records every payment on it, this one included.
+  const outcome = await pushInvoice(ctx, p.serviceRecordId)
+  if (await linkFor(ctx, slug, PAYMENT_ENTITY, paymentId)) return { summary: 'payment recorded' }
+  return { summary: `payment not recorded: ${outcome.summary ?? 'invoice not in Fiken'}` }
+}
+
+/* ---------- customers on edit ---------- */
+
+async function pushCustomer(ctx: ConnectorContext, customerId: string): Promise<JobOutcome> {
+  const slug = settingsOf(ctx).companySlug
+  if (!slug) return { summary: NO_COMPANY }
+  const link = await linkFor(ctx, slug, CUSTOMER_ENTITY, customerId)
+  // Customers reach Fiken with their first invoice; an edit before that is nothing yet.
+  if (!link) return { summary: 'not in Fiken yet' }
+  const c = await loadCustomerForAccounting(ctx.connection.organizationId, customerId)
+  if (!c) return { summary: 'customer gone' }
+  await ensureContact(ctx, slug, c)
+  return { summary: 'customer updated' }
+}
+
+/* ---------- pull ---------- */
+
+function dayBefore(day: string): string {
+  return new Date(Date.parse(`${day}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10)
+}
+
+/**
+ * Payments recorded in Fiken against sales this app sent are recorded here,
+ * and ones removed there are removed again. Fiken dates a change by the day,
+ * so every pull reads the sales changed since the day before the last one;
+ * what was already brought back is recognised and skipped.
+ */
+async function pullChanges(ctx: ConnectorContext): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.pullPayments) return { summary: 'pull switched off' }
+  const slug = settings.companySlug
+  if (!slug) return { summary: NO_COMPANY }
+  const today = new Date().toISOString().slice(0, 10)
+  const since = dayBefore(stateOf(ctx).lastPullDay ?? today)
+  const sales = await list<FikenSale>(ctx, companyPath(slug, '/sales'), { lastModifiedGe: since })
+  const known = await ctx.links.remoteIds(PAYMENT_ENTITY)
+  const orgId = ctx.connection.organizationId
+
+  let recorded = 0
+  let removed = 0
+  let unlinked = 0
+  for (const sale of sales) {
+    const saleId = String(sale.saleId)
+    const invoiceLink = await remoteLinkFor(ctx, slug, INVOICE_ENTITY, saleId)
+    if (!invoiceLink) continue
+    const number = String(invoiceLink.metadata?.saleNumber ?? saleId)
+    if (sale.deleted) {
+      await ctx.links.remove(INVOICE_ENTITY, invoiceLink.entityId)
+      await ctx.log(
+        'warn',
+        `Sale ${number} was deleted in Fiken; it will be entered again if the invoice is edited here`
+      )
+      unlinked++
+      continue
+    }
+
+    const there = new Set(
+      (sale.salePayments ?? []).filter((p) => p.paymentId).map((p) => String(p.paymentId))
+    )
+    let onSale = paymentIdsOn(invoiceLink)
+    for (const id of onSale.filter((x) => !there.has(x))) {
+      const link = await remoteLinkFor(ctx, slug, PAYMENT_ENTITY, id)
+      if (link?.metadata?.createdByUs === true) {
+        await ctx.log('warn', `A payment on sale ${number} was deleted in Fiken; it is kept here`)
+        await ctx.links.remove(PAYMENT_ENTITY, link.entityId)
+      } else if (link) {
+        if (await removePulledPayment(orgId, link.entityId, PROVIDER)) removed++
+        await ctx.links.remove(PAYMENT_ENTITY, link.entityId)
+      }
+      known.delete(id)
+    }
+    onSale = onSale.filter((x) => there.has(x))
+
+    for (const p of sale.salePayments ?? []) {
+      if (!p.paymentId || !(p.amount > 0)) continue
+      const id = String(p.paymentId)
+      if (known.has(id)) continue
+      const made = await recordPulledPayment(orgId, {
+        serviceRecordId: invoiceLink.entityId,
+        amount: fromCents(p.amount),
+        date: new Date(`${p.date}T12:00:00Z`),
+        method: localPaymentMethod(p.account),
+        provider: PROVIDER,
+        externalId: id,
+        note: 'Recorded in Fiken',
+      })
+      if (!made) continue
+      await ctx.links.set(PAYMENT_ENTITY, made.id, {
+        remoteId: id,
+        metadata: {
+          company: slug,
+          createdByUs: false,
+          serviceRecordId: invoiceLink.entityId,
+          saleId,
+        },
+      })
+      known.add(id)
+      onSale.push(id)
+      if (made.created) recorded++
+    }
+    await patchLink(ctx, INVOICE_ENTITY, invoiceLink.entityId, invoiceLink, { payments: onSale })
+  }
+
+  await ctx.saveState({ lastPullDay: today })
+  const parts = [
+    recorded ? `${recorded} payments recorded` : null,
+    removed ? `${removed} payments removed` : null,
+    unlinked ? `${unlinked} sales unlinked` : null,
+  ].filter(Boolean)
+  return { summary: parts.length ? parts.join(', ') : 'no changes' }
+}
+
+/* ---------- connector ---------- */
+
+function entityId(payload: Record<string, unknown>): string | null {
+  return typeof payload.entityId === 'string' ? payload.entityId : null
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+  /**
+   * The person who gave access. Which of their companies to write to is a
+   * setting, because Fiken names none on the way back from the consent page.
+   */
+  async identify(ctx) {
+    const user = await read<FikenUser>(ctx, '/user')
+    const who = user.email ?? user.name ?? ctx.connection.id
+    return {
+      id: `fiken-${createHash('sha256').update(who).digest('hex').slice(0, 16)}`,
+      name: user.name ?? user.email ?? 'Fiken',
+    }
+  },
+  async test(ctx) {
+    try {
+      await read<FikenUser>(ctx, '/user')
+      const slug = settingsOf(ctx).companySlug
+      if (!slug) return { ok: true }
+      const company = await read<FikenCompany>(ctx, companyPath(slug, ''))
+      if (company.hasApiAccess === false) {
+        return {
+          ok: false,
+          message: `The API module is not switched on for ${company.name ?? slug} in Fiken`,
+        }
+      }
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : String(err) }
+    }
+  },
+  remoteOptions: {
+    async companies(ctx) {
+      const companies = await list<FikenCompany>(ctx, '/companies')
+      return companies
+        .sort((a, b) => (a.name ?? a.slug).localeCompare(b.name ?? b.slug))
+        .map((c) => ({
+          value: c.slug,
+          label: [c.name ?? c.slug, c.organizationNumber].filter(Boolean).join(', '),
+        }))
+    },
+    /** Class 3 of the chart of accounts: sales income. */
+    async incomeAccounts(ctx) {
+      const slug = settingsOf(ctx).companySlug
+      if (!slug) return []
+      const accounts = await read<FikenAccount[]>(ctx, companyPath(slug, '/accounts'), {
+        fromAccount: '3000',
+        toAccount: '3999',
+      })
+      return accounts
+        .sort((a, b) => a.code.localeCompare(b.code))
+        .map((a) => ({ value: a.code, label: a.name ? `${a.code} ${a.name}` : a.code }))
+    },
+    async paymentAccounts(ctx) {
+      const slug = settingsOf(ctx).companySlug
+      if (!slug) return []
+      const accounts = await list<FikenBankAccount>(ctx, companyPath(slug, '/bankAccounts'), {
+        inactive: 'false',
+      })
+      return accounts.map((a) => ({
+        value: a.accountCode,
+        label: a.name ? `${a.name} (${a.accountCode})` : a.accountCode,
+      }))
+    },
+  },
+  jobs: {
+    'accounting.invoice': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no record id' }
+      return pushInvoice(ctx, id)
+    },
+    'accounting.payment': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no payment id' }
+      return pushPayment(ctx, id)
+    },
+    'accounting.customer': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no customer id' }
+      return pushCustomer(ctx, id)
+    },
+    'accounting.pull': pullChanges,
+  },
+  /** Hand the grant back so the person's Fiken account stops listing the app. */
+  async onDisconnect(ctx) {
+    try {
+      await ctx.http.fetch(REVOKE_URL, { method: 'POST' })
+    } catch (err) {
+      await ctx.log('warn', `Could not revoke the Fiken token: ${String(err)}`)
+    }
+  },
+}

+ 1 - 10
src/integrations/quickbooks/mapping.ts

@@ -417,13 +417,4 @@ export function localPaymentMethod(name: string | undefined): string {
   return 'other'
 }
 
-/** Stable hash of a body, so an unchanged record is not pushed twice. */
-export function checksumOf(body: unknown): string {
-  const input = JSON.stringify(body)
-  let h = 2166136261
-  for (let i = 0; i < input.length; i++) {
-    h ^= input.charCodeAt(i)
-    h = Math.imul(h, 16777619) >>> 0
-  }
-  return h.toString(16)
-}
+export { checksumOf } from '@/features/integrations/Lib/checksum'

+ 2 - 0
src/integrations/registry.ts

@@ -11,6 +11,7 @@ import type { ConnectorManifest, ConnectorServer } from '@/features/integrations
 import { isCloudInstance } from '@/lib/cloud-instance'
 import { manifest as amazonSes } from './amazon-ses/manifest'
 import { manifest as anthropic } from './anthropic/manifest'
+import { manifest as fiken } from './fiken/manifest'
 import { manifest as googleCalendar } from './google-calendar/manifest'
 import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
@@ -72,6 +73,7 @@ const ALL_ENTRIES: readonly RegistryEntry[] = [
   { manifest: vipps, load: () => import('./vipps/server') },
   { manifest: paypal, load: () => import('./paypal/server') },
   { manifest: quickbooks, load: () => import('./quickbooks/server') },
+  { manifest: fiken, load: () => import('./fiken/server') },
 ]
 
 /**