Просмотр исходного кода

Add Check Car Details for UK plate and VIN lookups (#471)

* Add Check Car Details for UK plate and VIN lookups

* Match registries on their chosen country, stop the MOT sync at the daily limit, keep small engines exact
Bernt Christian Egeland 6 дней назад
Родитель
Сommit
2ddef7190c
26 измененных файлов с 1271 добавлено и 39 удалено
  1. 16 1
      messages/de/integrations.json
  2. 16 1
      messages/en/integrations.json
  3. 16 1
      messages/es/integrations.json
  4. 16 1
      messages/fr/integrations.json
  5. 16 1
      messages/it/integrations.json
  6. 16 1
      messages/lt/integrations.json
  7. 16 1
      messages/nb/integrations.json
  8. 16 1
      messages/nl/integrations.json
  9. 16 1
      messages/pl/integrations.json
  10. 16 1
      messages/pt-BR/integrations.json
  11. 16 1
      messages/pt-PT/integrations.json
  12. 16 1
      messages/ru/integrations.json
  13. 16 1
      messages/tr/integrations.json
  14. 22 0
      public/images/integrations/checkcardetails.svg
  15. 324 0
      src/__tests__/features/integrations/checkcardetails.test.ts
  16. 66 0
      src/__tests__/features/integrations/fixtures/checkcardetails/ukvehicledata-sample.json
  17. 25 0
      src/__tests__/features/integrations/fixtures/checkcardetails/vehicleregistration-AB12CDE.json
  18. 25 0
      src/__tests__/features/integrations/fixtures/checkcardetails/vehicleregistration-EA65AMX.json
  19. 23 3
      src/__tests__/features/integrations/inspection-sync.test.ts
  20. 66 6
      src/__tests__/features/integrations/vin-lookup.test.ts
  21. 35 8
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  22. 13 1
      src/features/integrations/Lib/inspection-sync.ts
  23. 55 8
      src/features/integrations/Lib/vehicle-lookup.ts
  24. 32 0
      src/integrations/checkcardetails/manifest.ts
  25. 375 0
      src/integrations/checkcardetails/server.ts
  26. 2 0
      src/integrations/registry.ts

+ 16 - 1
messages/de/integrations.json

@@ -154,7 +154,11 @@
       "never": "Noch kein vollständiger Durchlauf",
       "inProgress": "Ein Durchlauf läuft",
       "off": "Einstellung einschalten und speichern; bis dahin prüft ein Durchlauf nichts.",
-      "syncNowHelp": "Jetzt synchronisieren startet sofort einen Durchlauf statt auf den stündlichen zu warten. Er fragt 25 Fahrzeuge auf einmal ab und macht von selbst weiter, bis alle fälligen Fahrzeuge erledigt sind."
+      "syncNowHelp": "Jetzt synchronisieren startet sofort einen Durchlauf statt auf den stündlichen zu warten. Er fragt 25 Fahrzeuge auf einmal ab und macht von selbst weiter, bis alle fälligen Fahrzeuge erledigt sind. Der erste Durchlauf fragt jedes Fahrzeug mit Kennzeichen ab.",
+      "confirmTitle": "Fahrzeuge jetzt abfragen?",
+      "confirmDescription": "{count} der {total} Fahrzeuge mit Kennzeichen werden jetzt bei {name} abgefragt, 25 auf einmal, und ihre Prüftermine gespeichert. Kürzlich geprüfte Fahrzeuge werden übersprungen. Berechnet {name} jede Abfrage, wird jedes gefundene Fahrzeug Ihrem Konto belastet.",
+      "confirmLabel": "{count} Fahrzeuge abfragen",
+      "nothingDue": "Alle Fahrzeuge mit Kennzeichen wurden kürzlich geprüft, es gibt noch nichts abzufragen."
     },
     "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt.",
     "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Das Motorradregister durchsuchen. Ist auch Pkw eingeschaltet, wird es nur gefragt, wenn kein Pkw passt, und ein Fehltreffer beim Pkw ist kostenlos. Für Portugal nicht verfügbar."
       }
     },
+    "checkcardetails": {
+      "description": "Füllen Sie ein Fahrzeug anhand seines britischen Kennzeichens oder der FIN aus: Marke, Modell, Baujahr, Farbe, Kraftstoff, Hubraum und der MOT-Termin, aus DVLA-Daten über Check Car Details. Autos und Motorräder kommen aus demselben Register. Ist UK Vehicle Data für Ihr Konto freigeschaltet, kommen auch FIN, Getriebe und Gewichte. Jede Abfrage wird dem eigenen Check-Car-Details-Konto der Werkstatt berechnet. Es werden keine Halterdaten übermittelt.",
+      "fields": {
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Legen Sie ein Konto auf api.checkcardetails.co.uk an und kopieren Sie unter API Keys den Live-Schlüssel. Vehicle Registration ist in jedem Konto enthalten. Um auch die FIN zu erhalten, klicken Sie unter Your Data Points bei UK Vehicle Data auf Request Access; Abfragen nutzen es, sobald es freigeschaltet ist. Ein Testschlüssel findet nur Kennzeichen, die den Buchstaben A enthalten."
+      },
+      "settings": {
+        "syncInspections": "MOT-Termine für alle Fahrzeuge dieser Werkstatt aktuell halten",
+        "syncInspectionsHelp": "Ist dies aktiv, wird jedes Fahrzeug der Werkstatt mit Kennzeichen einmal abgefragt und sein MOT-Termin gespeichert. Danach wird jedes Fahrzeug monatlich erneut geprüft, wöchentlich sobald der MOT in weniger als zwei Monaten fällig ist. Jedes gefundene Fahrzeug wird als eine Vehicle-Registration-Abfrage berechnet, daher kostet der erste Durchlauf am meisten; Kennzeichen, die das Register nicht kennt, sind kostenlos. Es werden nur Fahrzeuge dieser Werkstatt gesendet."
+      }
+    },
     "rdw": {
       "description": "Fahrzeug aus dem niederländischen Kennzeichen ausfüllen: Marke, Modell, Baujahr, Kraftstoff, Hubraum, Farbe, Gewichte und der nächste APK-Termin, direkt aus dem offenen Fahrzeugregister der RDW. Kostenlos, kein Schlüssel nötig. Halterdaten und FIN werden nicht veröffentlicht.",
       "fields": {

+ 16 - 1
messages/en/integrations.json

@@ -154,7 +154,11 @@
       "never": "No complete pass yet",
       "inProgress": "A pass is running",
       "off": "Turn the setting on and save; until then a pass checks nothing.",
-      "syncNowHelp": "Sync now starts a pass straight away instead of waiting for the hourly one. It looks up 25 vehicles at a time and continues on its own until every due vehicle is done."
+      "syncNowHelp": "Sync now starts a pass straight away instead of waiting for the hourly one. It looks up 25 vehicles at a time and continues on its own until every due vehicle is done. The first pass looks up every vehicle with a plate.",
+      "confirmTitle": "Look up vehicles now?",
+      "confirmDescription": "{count} of the {total} vehicles with a plate will be looked up in {name} now, 25 at a time, and their inspection due dates saved. Vehicles checked recently are skipped. If {name} charges per lookup, each vehicle found is billed to your account.",
+      "confirmLabel": "Look up {count} vehicles",
+      "nothingDue": "Every vehicle with a plate was checked recently, so there is nothing to look up yet."
     },
     "supersedes": "Connecting {next} disconnects {current}. Only one at a time can be in charge, so {current} stops being used the moment {next} goes live.",
     "batchRetried": "{count, plural, =0 {Nothing to retry} one {# job queued again} other {# jobs queued again}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Search the motorcycle registry. With cars on too, it is asked only when no car matches, and a miss on the car side is free. Not available for Portugal."
       }
     },
+    "checkcardetails": {
+      "description": "Fill in a vehicle from its British plate or VIN: make, model, year, colour, fuel, engine size and the MOT due date, from DVLA data through Check Car Details. Cars and motorcycles come from the same register. With UK Vehicle Data turned on for your account, the VIN, transmission and weights come too. Each lookup is billed to the workshop's own Check Car Details account. No owner data is returned.",
+      "fields": {
+        "apiKey": "API key",
+        "apiKeyHelp": "Create an account on api.checkcardetails.co.uk and copy the live key under API Keys. Vehicle Registration is included on every account. To get the VIN as well, click Request Access next to UK Vehicle Data under Your Data Points; lookups use it as soon as it is turned on. A test key only finds plates that contain the letter A."
+      },
+      "settings": {
+        "syncInspections": "Keep MOT dates up to date for every vehicle in this workshop",
+        "syncInspectionsHelp": "When this is on, every vehicle in the workshop that has a plate is looked up once, and its MOT due date is saved. After that, each vehicle is checked again monthly, and weekly once its MOT is due within two months. Every vehicle found is billed as one Vehicle Registration lookup, so the first pass costs the most; plates the register does not know are free. Only vehicles stored in this workshop are sent."
+      }
+    },
     "rdw": {
       "description": "Fill in a vehicle from its Dutch plate: make, model, year, fuel, engine size, colour, weights and the next APK date, straight from RDW's open vehicle register. Free, no key needed. No owner data and no VIN are published.",
       "fields": {

+ 16 - 1
messages/es/integrations.json

@@ -154,7 +154,11 @@
       "never": "Aún no hay una pasada completa",
       "inProgress": "Hay una pasada en curso",
       "off": "Activa el ajuste y guarda; hasta entonces una pasada no comprueba nada.",
-      "syncNowHelp": "Sincronizar ahora inicia una pasada de inmediato en vez de esperar a la horaria. Consulta 25 vehículos cada vez y continúa sola hasta terminar con todos los pendientes."
+      "syncNowHelp": "Sincronizar ahora inicia una pasada de inmediato en vez de esperar a la horaria. Consulta 25 vehículos cada vez y continúa sola hasta terminar con todos los pendientes. La primera pasada consulta todos los vehículos con matrícula.",
+      "confirmTitle": "¿Consultar vehículos ahora?",
+      "confirmDescription": "Se consultarán ahora {count} de los {total} vehículos con matrícula en {name}, 25 cada vez, y se guardarán sus fechas de inspección. Los vehículos comprobados hace poco se omiten. Si {name} cobra por consulta, cada vehículo encontrado se cobra a tu cuenta.",
+      "confirmLabel": "Consultar {count} vehículos",
+      "nothingDue": "Todos los vehículos con matrícula se comprobaron hace poco, así que aún no hay nada que consultar."
     },
     "supersedes": "Conectar {next} desconecta {current}. Solo uno puede estar activo a la vez, así que {current} deja de usarse en cuanto {next} entra en servicio.",
     "batchRetried": "{count, plural, =0 {Nada que reintentar} one {# tarea vuelta a la cola} other {# tareas vueltas a la cola}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Consulta el registro de motocicletas. Si los coches también están activados, solo se consulta cuando no coincide ningún coche, y un fallo en la búsqueda de coches es gratis. No disponible para Portugal."
       }
     },
+    "checkcardetails": {
+      "description": "Rellena un vehículo a partir de su matrícula británica o su VIN: marca, modelo, año, color, combustible, cilindrada y la fecha de la próxima MOT, con datos de la DVLA a través de Check Car Details. Coches y motos vienen del mismo registro. Si tu cuenta tiene activado UK Vehicle Data, también llegan el VIN, la transmisión y los pesos. Cada consulta se cobra a la cuenta de Check Car Details del propio taller. No se devuelven datos del propietario.",
+      "fields": {
+        "apiKey": "Clave de API",
+        "apiKeyHelp": "Crea una cuenta en api.checkcardetails.co.uk y copia la clave real en API Keys. Vehicle Registration está incluido en todas las cuentas. Para obtener también el VIN, pulsa Request Access junto a UK Vehicle Data en Your Data Points; las consultas lo usan en cuanto se activa. Una clave de prueba solo encuentra matrículas que contienen la letra A."
+      },
+      "settings": {
+        "syncInspections": "Mantener al día las fechas de MOT de todos los vehículos de este taller",
+        "syncInspectionsHelp": "Con esto activado, cada vehículo del taller que tenga matrícula se consulta una vez y se guarda la fecha de su próxima MOT. Después, cada vehículo se vuelve a comprobar cada mes, y cada semana cuando su MOT vence en menos de dos meses. Cada vehículo encontrado se cobra como una consulta de Vehicle Registration, así que la primera pasada es la más cara; las matrículas que el registro no conoce son gratis. Solo se envían vehículos guardados en este taller."
+      }
+    },
     "rdw": {
       "description": "Rellena un vehículo a partir de su matrícula neerlandesa: marca, modelo, año, combustible, cilindrada, color, pesos y la próxima fecha de APK, directamente del registro abierto de vehículos de la RDW. Gratis, sin clave. No se publican datos del propietario ni el VIN.",
       "fields": {

+ 16 - 1
messages/fr/integrations.json

@@ -154,7 +154,11 @@
       "never": "Aucun passage complet pour l'instant",
       "inProgress": "Un passage est en cours",
       "off": "Activez le réglage et enregistrez ; d'ici là, un passage ne vérifie rien.",
-      "syncNowHelp": "Synchroniser maintenant lance un passage immédiatement au lieu d'attendre celui de l'heure. Il recherche 25 véhicules à la fois et continue seul jusqu'à ce que tous les véhicules à vérifier soient traités."
+      "syncNowHelp": "Synchroniser maintenant lance un passage immédiatement au lieu d'attendre celui de l'heure. Il recherche 25 véhicules à la fois et continue seul jusqu'à ce que tous les véhicules à vérifier soient traités. Le premier passage recherche tous les véhicules qui ont une plaque.",
+      "confirmTitle": "Rechercher les véhicules maintenant ?",
+      "confirmDescription": "{count} des {total} véhicules qui ont une plaque vont être recherchés dans {name} maintenant, 25 à la fois, et leurs dates de contrôle enregistrées. Les véhicules vérifiés récemment sont ignorés. Si {name} facture chaque recherche, chaque véhicule trouvé est facturé à votre compte.",
+      "confirmLabel": "Rechercher {count} véhicules",
+      "nothingDue": "Tous les véhicules qui ont une plaque ont été vérifiés récemment, il n'y a encore rien à rechercher."
     },
     "supersedes": "Connecter {next} déconnecte {current}. Un seul peut être actif à la fois : {current} cesse d'être utilisé dès que {next} est en service.",
     "batchRetried": "{count, plural, =0 {Rien à réessayer} one {# tâche remise en file} other {# tâches remises en file}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Interroge le registre des motos. Si les voitures sont aussi activées, il n'est interrogé que lorsqu'aucune voiture ne correspond, et une recherche voiture sans résultat est gratuite. Indisponible pour le Portugal."
       }
     },
+    "checkcardetails": {
+      "description": "Remplissez un véhicule à partir de sa plaque britannique ou de son VIN : marque, modèle, année, couleur, carburant, cylindrée et la date du prochain MOT, à partir des données de la DVLA via Check Car Details. Voitures et motos viennent du même registre. Si UK Vehicle Data est activé sur votre compte, le VIN, la transmission et les poids arrivent aussi. Chaque recherche est facturée au compte Check Car Details de l'atelier. Aucune donnée sur le propriétaire n'est renvoyée.",
+      "fields": {
+        "apiKey": "Clé API",
+        "apiKeyHelp": "Créez un compte sur api.checkcardetails.co.uk et copiez la clé de production sous API Keys. Vehicle Registration est inclus dans tous les comptes. Pour obtenir aussi le VIN, cliquez sur Request Access à côté de UK Vehicle Data sous Your Data Points ; les recherches l'utilisent dès qu'il est activé. Une clé de test ne trouve que les plaques contenant la lettre A."
+      },
+      "settings": {
+        "syncInspections": "Tenir à jour les dates de MOT de tous les véhicules de cet atelier",
+        "syncInspectionsHelp": "Une fois activé, chaque véhicule de l'atelier qui a une plaque est recherché une fois, et la date de son prochain MOT est enregistrée. Ensuite, chaque véhicule est revérifié chaque mois, et chaque semaine lorsque son MOT arrive à échéance dans moins de deux mois. Chaque véhicule trouvé est facturé comme une recherche Vehicle Registration, le premier passage est donc le plus coûteux ; les plaques inconnues du registre sont gratuites. Seuls les véhicules de cet atelier sont envoyés."
+      }
+    },
     "rdw": {
       "description": "Remplissez un véhicule à partir de sa plaque néerlandaise : marque, modèle, année, carburant, cylindrée, couleur, masses et date du prochain APK, directement depuis le registre ouvert des véhicules de la RDW. Gratuit, sans clé. Ni les données du propriétaire ni le VIN ne sont publiés.",
       "fields": {

+ 16 - 1
messages/it/integrations.json

@@ -154,7 +154,11 @@
       "never": "Nessun passaggio completo finora",
       "inProgress": "Un passaggio è in corso",
       "off": "Attiva l'impostazione e salva; fino ad allora un passaggio non controlla nulla.",
-      "syncNowHelp": "Sincronizza ora avvia subito un passaggio invece di attendere quello orario. Cerca 25 veicoli alla volta e prosegue da solo finché tutti i veicoli da controllare sono stati fatti."
+      "syncNowHelp": "Sincronizza ora avvia subito un passaggio invece di attendere quello orario. Cerca 25 veicoli alla volta e prosegue da solo finché tutti i veicoli da controllare sono stati fatti. Il primo passaggio cerca tutti i veicoli con una targa.",
+      "confirmTitle": "Cercare i veicoli adesso?",
+      "confirmDescription": "{count} dei {total} veicoli con targa verranno cercati in {name} adesso, 25 alla volta, e verranno salvate le loro scadenze di revisione. I veicoli controllati di recente vengono saltati. Se {name} addebita ogni ricerca, ogni veicolo trovato viene addebitato al tuo account.",
+      "confirmLabel": "Cerca {count} veicoli",
+      "nothingDue": "Tutti i veicoli con targa sono stati controllati di recente, quindi non c'è ancora niente da cercare."
     },
     "supersedes": "Collegare {next} scollega {current}. Solo uno può essere attivo alla volta, quindi {current} smette di essere usato appena {next} entra in servizio.",
     "batchRetried": "{count, plural, =0 {Niente da riprovare} one {# attività rimessa in coda} other {# attività rimesse in coda}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Consulta il registro dei motocicli. Se sono attive anche le auto, viene consultato solo quando nessuna auto corrisponde, e una ricerca auto senza esito è gratuita. Non disponibile per il Portogallo."
       }
     },
+    "checkcardetails": {
+      "description": "Compila un veicolo dalla sua targa britannica o dal VIN: marca, modello, anno, colore, alimentazione, cilindrata e la scadenza della MOT, dai dati DVLA tramite Check Car Details. Auto e moto arrivano dallo stesso registro. Con UK Vehicle Data attivo sul tuo account arrivano anche VIN, cambio e pesi. Ogni ricerca è addebitata all'account Check Car Details dell'officina. Non vengono restituiti dati del proprietario.",
+      "fields": {
+        "apiKey": "Chiave API",
+        "apiKeyHelp": "Crea un account su api.checkcardetails.co.uk e copia la chiave di produzione in API Keys. Vehicle Registration è incluso in ogni account. Per avere anche il VIN, fai clic su Request Access accanto a UK Vehicle Data in Your Data Points; le ricerche lo usano appena è attivo. Una chiave di prova trova solo targhe che contengono la lettera A."
+      },
+      "settings": {
+        "syncInspections": "Mantieni aggiornate le scadenze MOT di tutti i veicoli di questa officina",
+        "syncInspectionsHelp": "Quando è attivo, ogni veicolo dell'officina con una targa viene cercato una volta e viene salvata la scadenza della MOT. Poi ogni veicolo viene ricontrollato ogni mese, e ogni settimana quando la MOT scade entro due mesi. Ogni veicolo trovato è addebitato come una ricerca Vehicle Registration, quindi il primo passaggio è il più costoso; le targhe che il registro non conosce sono gratuite. Vengono inviati solo i veicoli salvati in questa officina."
+      }
+    },
     "rdw": {
       "description": "Compila un veicolo dalla targa olandese: marca, modello, anno, carburante, cilindrata, colore, masse e data del prossimo APK, direttamente dal registro veicoli aperto della RDW. Gratuito, senza chiave. Non vengono pubblicati né i dati del proprietario né il VIN.",
       "fields": {

+ 16 - 1
messages/lt/integrations.json

@@ -154,7 +154,11 @@
       "never": "Pilno perėjimo dar nebuvo",
       "inProgress": "Perėjimas vyksta",
       "off": "Įjunkite nustatymą ir išsaugokite; iki tol perėjimas nieko netikrina.",
-      "syncNowHelp": "„Sinchronizuoti dabar“ iš karto pradeda perėjimą, nelaukdamas valandinio. Tikrinama po 25 transporto priemones ir tęsiama savaime, kol apdorojamos visos, kurioms atėjo laikas."
+      "syncNowHelp": "„Sinchronizuoti dabar“ iš karto pradeda perėjimą, nelaukdamas valandinio. Tikrinama po 25 transporto priemones ir tęsiama savaime, kol apdorojamos visos, kurioms atėjo laikas. Pirmas perėjimas patikrina kiekvieną transporto priemonę su numeriu.",
+      "confirmTitle": "Tikrinti transporto priemones dabar?",
+      "confirmDescription": "{count} iš {total} transporto priemonių su numeriu dabar bus patikrinta {name}, po 25, ir išsaugotos jų apžiūros datos. Neseniai tikrintos praleidžiamos. Jei {name} ima mokestį už paiešką, kiekviena rasta transporto priemonė apmokestinama jūsų paskyroje.",
+      "confirmLabel": "Tikrinti {count} transporto priemones",
+      "nothingDue": "Visos transporto priemonės su numeriu neseniai patikrintos, todėl kol kas nėra ką tikrinti."
     },
     "supersedes": "Prijungus {next}, {current} bus atjungtas. Vienu metu gali veikti tik vienas, todėl {current} nustos būti naudojamas, kai tik {next} pradės veikti.",
     "batchRetried": "{count, plural, =0 {Nėra ko kartoti} one {# užduotis vėl eilėje} few {# užduotys vėl eilėje} many {# užduoties vėl eilėje} other {# užduočių vėl eilėje}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Užklausiamas motociklų registras. Jei įjungti ir automobiliai, jis užklausiamas tik neradus automobilio, o nerastas automobilis nekainuoja. Portugalijai neprieinama."
       }
     },
+    "checkcardetails": {
+      "description": "Užpildykite transporto priemonę pagal jos britišką numerį arba VIN: markė, modelis, metai, spalva, kuras, variklio tūris ir MOT termino data, iš DVLA duomenų per Check Car Details. Automobiliai ir motociklai gaunami iš to paties registro. Jei jūsų paskyroje įjungta UK Vehicle Data, gaunami ir VIN, pavarų dėžė bei svoriai. Kiekviena paieška apmokestinama pačių dirbtuvių Check Car Details paskyroje. Savininko duomenys negrąžinami.",
+      "fields": {
+        "apiKey": "API raktas",
+        "apiKeyHelp": "Susikurkite paskyrą api.checkcardetails.co.uk ir skiltyje API Keys nukopijuokite tikrąjį raktą. Vehicle Registration įtraukta į kiekvieną paskyrą. Kad gautumėte ir VIN, skiltyje Your Data Points prie UK Vehicle Data spustelėkite Request Access; paieškos ją naudoja vos tik ji įjungiama. Bandomasis raktas randa tik numerius, kuriuose yra raidė A."
+      },
+      "settings": {
+        "syncInspections": "Atnaujinti MOT datas visoms šių dirbtuvių transporto priemonėms",
+        "syncInspectionsHelp": "Kai tai įjungta, kiekviena dirbtuvių transporto priemonė su numeriu patikrinama vieną kartą ir išsaugoma jos MOT termino data. Vėliau kiekviena transporto priemonė tikrinama kas mėnesį, o kai MOT terminas arčiau nei du mėnesiai, kas savaitę. Kiekviena rasta transporto priemonė apmokestinama kaip viena Vehicle Registration paieška, todėl pirmas perėjimas kainuoja daugiausia; registrui nežinomi numeriai nemokami. Siunčiamos tik šiose dirbtuvėse saugomos transporto priemonės."
+      }
+    },
     "rdw": {
       "description": "Užpildykite transporto priemonę pagal olandišką numerį: markė, modelis, metai, degalai, variklio tūris, spalva, masės ir kita APK data, tiesiai iš atviro RDW transporto priemonių registro. Nemokamai, be rakto. Savininko duomenys ir VIN neskelbiami.",
       "fields": {

+ 16 - 1
messages/nb/integrations.json

@@ -154,7 +154,11 @@
       "never": "Ingen fullstendig gjennomgang ennå",
       "inProgress": "En gjennomgang pågår",
       "off": "Slå på innstillingen og lagre; før det sjekker en gjennomgang ingenting.",
-      "syncNowHelp": "Synkroniser nå starter en gjennomgang med en gang i stedet for å vente på den timevise. Den slår opp 25 kjøretøy om gangen og fortsetter av seg selv til alle med forfalt sjekk er ferdige."
+      "syncNowHelp": "Synkroniser nå starter en gjennomgang med en gang i stedet for å vente på den timevise. Den slår opp 25 kjøretøy om gangen og fortsetter av seg selv til alle med forfalt sjekk er ferdige. Første gjennomgang slår opp alle kjøretøy med skilt.",
+      "confirmTitle": "Slå opp kjøretøy nå?",
+      "confirmDescription": "{count} av de {total} kjøretøyene med skilt slås opp i {name} nå, 25 om gangen, og kontrollfristene lagres. Kjøretøy som er sjekket nylig hoppes over. Tar {name} betalt per oppslag, faktureres hvert kjøretøy som blir funnet til kontoen din.",
+      "confirmLabel": "Slå opp {count} kjøretøy",
+      "nothingDue": "Alle kjøretøy med skilt er sjekket nylig, så det er ingenting å slå opp ennå."
     },
     "supersedes": "Å koble til {next} kobler fra {current}. Bare én kan være i bruk om gangen, så {current} tas ut av bruk i det {next} settes i gang.",
     "batchRetried": "{count, plural, =0 {Ingenting å prøve på nytt} one {# jobb lagt i kø igjen} other {# jobber lagt i kø igjen}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Søk i motorsykkelregisteret. Når biler også er på, spørres det bare når ingen bil passer, og et bomskudd på bilsiden er gratis. Ikke tilgjengelig for Portugal."
       }
     },
+    "checkcardetails": {
+      "description": "Fyll ut et kjøretøy fra britisk skilt eller VIN: merke, modell, år, farge, drivstoff, motorvolum og MOT-fristen, fra DVLA-data via Check Car Details. Biler og motorsykler kommer fra samme register. Med UK Vehicle Data slått på for kontoen din kommer også VIN, girkasse og vekter. Hvert oppslag faktureres verkstedets egen Check Car Details-konto. Ingen eierdata returneres.",
+      "fields": {
+        "apiKey": "API-nøkkel",
+        "apiKeyHelp": "Opprett en konto på api.checkcardetails.co.uk og kopier produksjonsnøkkelen under API Keys. Vehicle Registration følger med alle kontoer. For å få VIN også, klikk Request Access ved UK Vehicle Data under Your Data Points; oppslagene bruker det så snart det er slått på. En testnøkkel finner bare skilt som inneholder bokstaven A."
+      },
+      "settings": {
+        "syncInspections": "Hold MOT-fristen oppdatert for alle kjøretøy i dette verkstedet",
+        "syncInspectionsHelp": "Når dette er på, slås hvert kjøretøy i verkstedet som har skilt opp én gang, og MOT-fristen lagres. Etter det sjekkes hvert kjøretøy på nytt hver måned, og hver uke når MOT-fristen er under to måneder unna. Hvert kjøretøy som blir funnet faktureres som ett Vehicle Registration-oppslag, så første gjennomgang koster mest; skilt registeret ikke kjenner er gratis. Bare kjøretøy lagret i dette verkstedet sendes."
+      }
+    },
     "rdw": {
       "description": "Fyll ut et kjøretøy fra nederlandsk registreringsnummer: merke, modell, årsmodell, drivstoff, slagvolum, farge, vekter og frist for neste APK, rett fra RDWs åpne kjøretøyregister. Gratis, ingen nøkkel nødvendig. Eieropplysninger og understellsnummer publiseres ikke.",
       "fields": {

+ 16 - 1
messages/nl/integrations.json

@@ -154,7 +154,11 @@
       "never": "Nog geen volledige ronde",
       "inProgress": "Er loopt een ronde",
       "off": "Zet de instelling aan en sla op; tot die tijd controleert een ronde niets.",
-      "syncNowHelp": "Nu synchroniseren start meteen een ronde in plaats van op de uurlijkse te wachten. Het zoekt 25 voertuigen tegelijk op en gaat vanzelf door tot alle voertuigen aan de beurt zijn geweest."
+      "syncNowHelp": "Nu synchroniseren start meteen een ronde in plaats van op de uurlijkse te wachten. Het zoekt 25 voertuigen tegelijk op en gaat vanzelf door tot alle voertuigen aan de beurt zijn geweest. De eerste ronde zoekt elk voertuig met een kenteken op.",
+      "confirmTitle": "Voertuigen nu opzoeken?",
+      "confirmDescription": "{count} van de {total} voertuigen met een kenteken worden nu opgezocht bij {name}, 25 tegelijk, en hun keuringsdatums opgeslagen. Voertuigen die onlangs zijn gecontroleerd worden overgeslagen. Rekent {name} per opzoeking, dan wordt elk gevonden voertuig aan uw account gefactureerd.",
+      "confirmLabel": "{count} voertuigen opzoeken",
+      "nothingDue": "Alle voertuigen met een kenteken zijn onlangs gecontroleerd, er valt nog niets op te zoeken."
     },
     "supersedes": "Als u {next} verbindt, wordt {current} losgekoppeld. Er kan er maar één tegelijk actief zijn, dus {current} wordt niet meer gebruikt zodra {next} live gaat.",
     "batchRetried": "{count, plural, =0 {Niets om opnieuw te proberen} one {# taak opnieuw in de wachtrij} other {# taken opnieuw in de wachtrij}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Doorzoek het motorfietsregister. Staan auto's ook aan, dan wordt het alleen bevraagd als geen auto overeenkomt, en geen resultaat bij auto's is gratis. Niet beschikbaar voor Portugal."
       }
     },
+    "checkcardetails": {
+      "description": "Vul een voertuig in op basis van het Britse kenteken of het VIN: merk, model, bouwjaar, kleur, brandstof, cilinderinhoud en de MOT-vervaldatum, uit DVLA-gegevens via Check Car Details. Auto's en motoren komen uit hetzelfde register. Staat UK Vehicle Data aan voor uw account, dan komen ook het VIN, de transmissie en de gewichten mee. Elke opzoeking wordt gefactureerd aan het eigen Check Car Details-account van de werkplaats. Er worden geen eigenaarsgegevens teruggegeven.",
+      "fields": {
+        "apiKey": "API-sleutel",
+        "apiKeyHelp": "Maak een account aan op api.checkcardetails.co.uk en kopieer de live-sleutel onder API Keys. Vehicle Registration zit in elk account. Wilt u ook het VIN, klik dan op Request Access naast UK Vehicle Data onder Your Data Points; opzoekingen gebruiken het zodra het aanstaat. Een testsleutel vindt alleen kentekens met de letter A erin."
+      },
+      "settings": {
+        "syncInspections": "MOT-datums van alle voertuigen in deze werkplaats actueel houden",
+        "syncInspectionsHelp": "Staat dit aan, dan wordt elk voertuig in de werkplaats met een kenteken één keer opgezocht en wordt de MOT-vervaldatum opgeslagen. Daarna wordt elk voertuig maandelijks opnieuw gecontroleerd, en wekelijks zodra de MOT binnen twee maanden vervalt. Elk gevonden voertuig wordt gefactureerd als één Vehicle Registration-opzoeking, dus de eerste ronde kost het meest; kentekens die het register niet kent zijn gratis. Alleen voertuigen die in deze werkplaats zijn opgeslagen worden verstuurd."
+      }
+    },
     "rdw": {
       "description": "Vul een voertuig in vanuit het Nederlandse kenteken: merk, model, bouwjaar, brandstof, cilinderinhoud, kleur, massa's en de volgende APK-vervaldatum, rechtstreeks uit het open voertuigregister van de RDW. Gratis, geen sleutel nodig. Eigenaarsgegevens en VIN worden niet gepubliceerd.",
       "fields": {

+ 16 - 1
messages/pl/integrations.json

@@ -154,7 +154,11 @@
       "never": "Brak pełnego przebiegu",
       "inProgress": "Trwa przebieg",
       "off": "Włącz ustawienie i zapisz; do tego czasu przebieg niczego nie sprawdza.",
-      "syncNowHelp": "Synchronizuj teraz uruchamia przebieg od razu zamiast czekać na cogodzinny. Sprawdza po 25 pojazdów i kontynuuje sam, aż wszystkie oczekujące pojazdy zostaną obsłużone."
+      "syncNowHelp": "Synchronizuj teraz uruchamia przebieg od razu zamiast czekać na cogodzinny. Sprawdza po 25 pojazdów i kontynuuje sam, aż wszystkie oczekujące pojazdy zostaną obsłużone. Pierwszy przebieg sprawdza każdy pojazd z tablicą rejestracyjną.",
+      "confirmTitle": "Sprawdzić pojazdy teraz?",
+      "confirmDescription": "{count} z {total} pojazdów z tablicą zostanie teraz sprawdzonych w {name}, po 25 naraz, a ich terminy przeglądów zostaną zapisane. Pojazdy sprawdzone niedawno są pomijane. Jeśli {name} pobiera opłatę za wyszukanie, każdy znaleziony pojazd jest rozliczany na Twoim koncie.",
+      "confirmLabel": "Sprawdź {count} pojazdów",
+      "nothingDue": "Wszystkie pojazdy z tablicą zostały niedawno sprawdzone, więc na razie nie ma czego sprawdzać."
     },
     "supersedes": "Podłączenie {next} odłączy {current}. Naraz może działać tylko jedno, więc {current} przestanie być używane, gdy tylko {next} zacznie działać.",
     "batchRetried": "{count, plural, =0 {Nie ma czego ponawiać} one {# zadanie ponownie w kolejce} few {# zadania ponownie w kolejce} many {# zadań ponownie w kolejce} other {# zadania ponownie w kolejce}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Przeszukuje rejestr motocykli. Gdy samochody też są włączone, jest odpytywany tylko wtedy, gdy żaden samochód nie pasuje, a brak wyniku dla samochodu jest bezpłatny. Niedostępne dla Portugalii."
       }
     },
+    "checkcardetails": {
+      "description": "Uzupełnij pojazd na podstawie brytyjskiej tablicy rejestracyjnej lub numeru VIN: marka, model, rok, kolor, paliwo, pojemność silnika i termin MOT, z danych DVLA przez Check Car Details. Samochody i motocykle pochodzą z tego samego rejestru. Gdy na koncie włączone jest UK Vehicle Data, przychodzą też VIN, skrzynia biegów i masy. Każde wyszukanie jest rozliczane na własnym koncie Check Car Details warsztatu. Dane właściciela nie są zwracane.",
+      "fields": {
+        "apiKey": "Klucz API",
+        "apiKeyHelp": "Załóż konto na api.checkcardetails.co.uk i skopiuj klucz produkcyjny w API Keys. Vehicle Registration jest w każdym koncie. Aby dostawać też VIN, kliknij Request Access przy UK Vehicle Data w Your Data Points; wyszukiwania korzystają z niego, gdy tylko zostanie włączone. Klucz testowy znajduje tylko tablice zawierające literę A."
+      },
+      "settings": {
+        "syncInspections": "Aktualizuj terminy MOT wszystkich pojazdów w tym warsztacie",
+        "syncInspectionsHelp": "Gdy to jest włączone, każdy pojazd w warsztacie z tablicą rejestracyjną jest raz wyszukiwany i zapisywany jest jego termin MOT. Potem każdy pojazd jest sprawdzany co miesiąc, a co tydzień, gdy MOT upływa za mniej niż dwa miesiące. Każdy znaleziony pojazd jest rozliczany jako jedno wyszukanie Vehicle Registration, więc pierwszy przebieg kosztuje najwięcej; tablice nieznane rejestrowi są bezpłatne. Wysyłane są tylko pojazdy zapisane w tym warsztacie."
+      }
+    },
     "rdw": {
       "description": "Uzupełnij pojazd na podstawie holenderskiej tablicy: marka, model, rocznik, paliwo, pojemność silnika, kolor, masy i termin następnego APK, prosto z otwartego rejestru pojazdów RDW. Bezpłatnie, bez klucza. Dane właściciela i VIN nie są publikowane.",
       "fields": {

+ 16 - 1
messages/pt-BR/integrations.json

@@ -154,7 +154,11 @@
       "never": "Nenhuma passagem completa ainda",
       "inProgress": "Uma passagem está em andamento",
       "off": "Ative a opção e salve; até lá uma passagem não verifica nada.",
-      "syncNowHelp": "Sincronizar agora inicia uma passagem imediatamente em vez de esperar a de hora em hora. Consulta 25 veículos por vez e continua sozinha até terminar todos os veículos pendentes."
+      "syncNowHelp": "Sincronizar agora inicia uma passagem imediatamente em vez de esperar a de hora em hora. Consulta 25 veículos por vez e continua sozinha até terminar todos os veículos pendentes. A primeira passagem consulta todos os veículos com placa.",
+      "confirmTitle": "Consultar veículos agora?",
+      "confirmDescription": "{count} dos {total} veículos com placa serão consultados no {name} agora, 25 por vez, e as datas de inspeção salvas. Veículos verificados recentemente são pulados. Se o {name} cobra por consulta, cada veículo encontrado é cobrado na sua conta.",
+      "confirmLabel": "Consultar {count} veículos",
+      "nothingDue": "Todos os veículos com placa foram verificados recentemente, então ainda não há nada para consultar."
     },
     "supersedes": "Conectar a {next} desconecta a {current}. Só uma pode estar ativa por vez, então {current} deixa de ser usada assim que {next} entrar no ar.",
     "batchRetried": "{count, plural, =0 {Nada para tentar de novo} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Consulta o registro de motocicletas. Com carros também ativado, ele só é consultado quando nenhum carro corresponde, e uma consulta de carro sem resultado é gratuita. Indisponível para Portugal."
       }
     },
+    "checkcardetails": {
+      "description": "Preencha um veículo pela placa britânica ou pelo VIN: marca, modelo, ano, cor, combustível, cilindrada e o vencimento do MOT, com dados da DVLA via Check Car Details. Carros e motos vêm do mesmo registro. Com o UK Vehicle Data ativado na sua conta, vêm também o VIN, a transmissão e os pesos. Cada consulta é cobrada na conta Check Car Details da própria oficina. Nenhum dado do proprietário é retornado.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Crie uma conta em api.checkcardetails.co.uk e copie a chave de produção em API Keys. O Vehicle Registration está incluído em todas as contas. Para receber também o VIN, clique em Request Access ao lado de UK Vehicle Data em Your Data Points; as consultas passam a usá-lo assim que for ativado. Uma chave de teste só encontra placas que contêm a letra A."
+      },
+      "settings": {
+        "syncInspections": "Manter as datas de MOT atualizadas para todos os veículos desta oficina",
+        "syncInspectionsHelp": "Com isto ativado, cada veículo da oficina que tem placa é consultado uma vez e o vencimento do MOT é salvo. Depois, cada veículo é verificado de novo todo mês, e toda semana quando o MOT vence em menos de dois meses. Cada veículo encontrado é cobrado como uma consulta Vehicle Registration, então a primeira passagem é a mais cara; placas que o registro não conhece são grátis. Só são enviados veículos salvos nesta oficina."
+      }
+    },
     "rdw": {
       "description": "Preencha um veículo a partir da placa holandesa: marca, modelo, ano, combustível, cilindrada, cor, pesos e a próxima data de APK, direto do registro aberto de veículos da RDW. Gratuito, sem chave. Dados do proprietário e VIN não são publicados.",
       "fields": {

+ 16 - 1
messages/pt-PT/integrations.json

@@ -154,7 +154,11 @@
       "never": "Ainda não houve nenhuma passagem completa",
       "inProgress": "Está uma passagem em curso",
       "off": "Ative a opção e guarde; até lá, uma passagem não verifica nada.",
-      "syncNowHelp": "Sincronizar agora inicia uma passagem de imediato, em vez de esperar pela passagem horária. Consulta 25 viaturas de cada vez e continua sozinha até terminar todas as viaturas pendentes."
+      "syncNowHelp": "Sincronizar agora inicia uma passagem de imediato, em vez de esperar pela passagem horária. Consulta 25 viaturas de cada vez e continua sozinha até terminar todas as viaturas pendentes. A primeira passagem consulta todas as viaturas com matrícula.",
+      "confirmTitle": "Consultar viaturas agora?",
+      "confirmDescription": "{count} das {total} viaturas com matrícula vão ser consultadas no {name} agora, 25 de cada vez, e as datas de inspeção guardadas. As viaturas verificadas recentemente são ignoradas. Se o {name} cobrar por consulta, cada viatura encontrada é cobrada na sua conta.",
+      "confirmLabel": "Consultar {count} viaturas",
+      "nothingDue": "Todas as viaturas com matrícula foram verificadas recentemente, por isso ainda não há nada para consultar."
     },
     "supersedes": "Ligar {next} desliga {current}. Só pode estar uma ativa de cada vez, por isso {current} deixa de ser utilizada assim que {next} entrar em funcionamento.",
     "batchRetried": "{count, plural, =0 {Nada para tentar novamente} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Consulta o registo de motociclos. Com os automóveis também ativos, só é consultado quando nenhum automóvel corresponde, e uma consulta de automóvel sem resultado é gratuita. Indisponível para Portugal."
       }
     },
+    "checkcardetails": {
+      "description": "Preencha um veículo a partir da matrícula britânica ou do VIN: marca, modelo, ano, cor, combustível, cilindrada e a data do próximo MOT, com dados da DVLA através do Check Car Details. Automóveis e motociclos vêm do mesmo registo. Com o UK Vehicle Data ativado na sua conta, chegam também o VIN, a caixa de velocidades e os pesos. Cada consulta é cobrada na conta Check Car Details da própria oficina. Não são devolvidos dados do proprietário.",
+      "fields": {
+        "apiKey": "Chave de API",
+        "apiKeyHelp": "Crie uma conta em api.checkcardetails.co.uk e copie a chave de produção em API Keys. O Vehicle Registration está incluído em todas as contas. Para obter também o VIN, clique em Request Access junto a UK Vehicle Data em Your Data Points; as consultas passam a usá-lo assim que for ativado. Uma chave de teste só encontra matrículas que contêm a letra A."
+      },
+      "settings": {
+        "syncInspections": "Manter as datas de MOT atualizadas para todos os veículos desta oficina",
+        "syncInspectionsHelp": "Com isto ativado, cada viatura da oficina que tem matrícula é consultada uma vez e a data do próximo MOT é guardada. Depois, cada viatura é verificada de novo todos os meses, e todas as semanas quando o MOT vence em menos de dois meses. Cada viatura encontrada é cobrada como uma consulta Vehicle Registration, por isso a primeira passagem é a mais cara; as matrículas que o registo não conhece são gratuitas. Só são enviadas viaturas guardadas nesta oficina."
+      }
+    },
     "rdw": {
       "description": "Preencha uma viatura a partir da matrícula neerlandesa: marca, modelo, ano, combustível, cilindrada, cor, pesos e a próxima data de APK, diretamente do registo aberto de veículos da RDW. Gratuito, sem chave. Os dados do proprietário e o VIN não são publicados.",
       "fields": {

+ 16 - 1
messages/ru/integrations.json

@@ -154,7 +154,11 @@
       "never": "Полного прохода ещё не было",
       "inProgress": "Проход выполняется",
       "off": "Включите настройку и сохраните; до этого проход ничего не проверяет.",
-      "syncNowHelp": "«Синхронизировать сейчас» запускает проход немедленно, не дожидаясь ежечасного. Он проверяет по 25 автомобилей и продолжает сам, пока не обработает все ожидающие."
+      "syncNowHelp": "«Синхронизировать сейчас» запускает проход немедленно, не дожидаясь ежечасного. Он проверяет по 25 автомобилей и продолжает сам, пока не обработает все ожидающие. Первый проход проверяет каждый автомобиль с номером.",
+      "confirmTitle": "Проверить автомобили сейчас?",
+      "confirmDescription": "Сейчас {count} из {total} автомобилей с номером будут запрошены в {name}, по 25 за раз, и сохранятся сроки их техосмотра. Недавно проверенные автомобили пропускаются. Если {name} берёт плату за запрос, каждый найденный автомобиль оплачивается с вашего аккаунта.",
+      "confirmLabel": "Проверить автомобилей: {count}",
+      "nothingDue": "Все автомобили с номером недавно проверялись, проверять пока нечего."
     },
     "supersedes": "Подключение {next} отключит {current}. Одновременно работает только один, поэтому {current} перестанет использоваться, как только заработает {next}.",
     "batchRetried": "{count, plural, =0 {Нечего повторять} one {# задача снова в очереди} few {# задачи снова в очереди} many {# задач снова в очереди} other {# задачи снова в очереди}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Запрашивает реестр мотоциклов. Если автомобили тоже включены, он запрашивается только когда автомобиль не найден, а запрос по автомобилю без результата бесплатен. Недоступно для Португалии."
       }
     },
+    "checkcardetails": {
+      "description": "Заполняйте данные автомобиля по британскому номеру или VIN: марка, модель, год, цвет, топливо, объём двигателя и срок следующего MOT, из данных DVLA через Check Car Details. Автомобили и мотоциклы берутся из одного реестра. Если для вашего аккаунта включена UK Vehicle Data, приходят также VIN, коробка передач и массы. Каждый запрос оплачивается с собственного аккаунта Check Car Details мастерской. Данные владельца не возвращаются.",
+      "fields": {
+        "apiKey": "API-ключ",
+        "apiKeyHelp": "Создайте аккаунт на api.checkcardetails.co.uk и скопируйте рабочий ключ в разделе API Keys. Vehicle Registration входит в каждый аккаунт. Чтобы получать и VIN, нажмите Request Access рядом с UK Vehicle Data в разделе Your Data Points; запросы начнут его использовать, как только он будет включён. Тестовый ключ находит только номера, в которых есть буква A."
+      },
+      "settings": {
+        "syncInspections": "Поддерживать сроки MOT актуальными для всех автомобилей этой мастерской",
+        "syncInspectionsHelp": "Когда это включено, каждый автомобиль мастерской с номером один раз запрашивается, и сохраняется срок его следующего MOT. Затем каждый автомобиль перепроверяется ежемесячно, а когда до MOT меньше двух месяцев, еженедельно. Каждый найденный автомобиль оплачивается как один запрос Vehicle Registration, поэтому первый проход обходится дороже всего; номера, которых нет в реестре, бесплатны. Отправляются только автомобили, сохранённые в этой мастерской."
+      }
+    },
     "rdw": {
       "description": "Заполните данные автомобиля по нидерландскому номеру: марка, модель, год, топливо, объём двигателя, цвет, массы и дата следующего APK, напрямую из открытого реестра транспортных средств RDW. Бесплатно, ключ не нужен. Данные о владельце и VIN не публикуются.",
       "fields": {

+ 16 - 1
messages/tr/integrations.json

@@ -154,7 +154,11 @@
       "never": "Henüz tam tarama yok",
       "inProgress": "Bir tarama sürüyor",
       "off": "Ayarı açıp kaydedin; o zamana kadar tarama hiçbir şeyi kontrol etmez.",
-      "syncNowHelp": "Şimdi eşitle, saatlik taramayı beklemek yerine hemen bir tarama başlatır. Her seferinde 25 aracı sorgular ve sırası gelen tüm araçlar bitene kadar kendi kendine devam eder."
+      "syncNowHelp": "Şimdi eşitle, saatlik taramayı beklemek yerine hemen bir tarama başlatır. Her seferinde 25 aracı sorgular ve sırası gelen tüm araçlar bitene kadar kendi kendine devam eder. İlk tarama plakası olan her aracı sorgular.",
+      "confirmTitle": "Araçlar şimdi sorgulansın mı?",
+      "confirmDescription": "Plakası olan {total} aracın {count} tanesi şimdi {name} üzerinde, her seferinde 25 araç olmak üzere sorgulanacak ve muayene tarihleri kaydedilecek. Yakın zamanda kontrol edilen araçlar atlanır. {name} sorgu başına ücret alıyorsa bulunan her araç hesabınıza faturalanır.",
+      "confirmLabel": "{count} aracı sorgula",
+      "nothingDue": "Plakası olan tüm araçlar yakın zamanda kontrol edildi, şimdilik sorgulanacak bir şey yok."
     },
     "supersedes": "{next} bağlandığında {current} bağlantısı kesilir. Aynı anda yalnızca biri geçerli olabilir; {next} devreye girdiği anda {current} kullanılmayı bırakır.",
     "batchRetried": "{count, plural, =0 {Yeniden denenecek bir şey yok} one {# iş yeniden kuyruğa alındı} other {# iş yeniden kuyruğa alındı}}",
@@ -435,6 +439,17 @@
         "bikesHelp": "Motosiklet sicilini sorgular. Otomobiller de açıksa yalnızca hiçbir otomobil eşleşmediğinde sorgulanır ve otomobil tarafında sonuçsuz sorgu ücretsizdir. Portekiz için kullanılamaz."
       }
     },
+    "checkcardetails": {
+      "description": "Bir aracı İngiliz plakasından veya VIN'inden doldurun: marka, model, yıl, renk, yakıt, motor hacmi ve MOT bitiş tarihi, Check Car Details üzerinden DVLA verileriyle. Otomobiller ve motosikletler aynı sicilden gelir. Hesabınızda UK Vehicle Data açıksa VIN, şanzıman ve ağırlıklar da gelir. Her sorgu atölyenin kendi Check Car Details hesabına faturalanır. Sahip bilgisi döndürülmez.",
+      "fields": {
+        "apiKey": "API anahtarı",
+        "apiKeyHelp": "api.checkcardetails.co.uk üzerinde bir hesap açın ve API Keys altındaki canlı anahtarı kopyalayın. Vehicle Registration her hesapta vardır. VIN'i de almak için Your Data Points altında UK Vehicle Data yanındaki Request Access'e tıklayın; sorgular açılır açılmaz onu kullanır. Test anahtarı yalnızca A harfi içeren plakaları bulur."
+      },
+      "settings": {
+        "syncInspections": "Bu atölyedeki tüm araçların MOT tarihlerini güncel tut",
+        "syncInspectionsHelp": "Bu açıkken atölyedeki plakası olan her araç bir kez sorgulanır ve MOT bitiş tarihi kaydedilir. Ardından her araç ayda bir, MOT'una iki aydan az kaldığında ise haftada bir yeniden kontrol edilir. Bulunan her araç bir Vehicle Registration sorgusu olarak faturalanır, bu yüzden ilk tarama en pahalısıdır; sicilin tanımadığı plakalar ücretsizdir. Yalnızca bu atölyede kayıtlı araçlar gönderilir."
+      }
+    },
     "rdw": {
       "description": "Hollanda plakasından aracı doldurun: marka, model, yıl, yakıt, motor hacmi, renk, ağırlıklar ve bir sonraki APK tarihi, doğrudan RDW'nin açık araç sicilinden. Ücretsiz, anahtar gerekmez. Araç sahibi bilgisi ve VIN yayımlanmaz.",
       "fields": {

+ 22 - 0
public/images/integrations/checkcardetails.svg

@@ -0,0 +1,22 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Check Car Details">
+  <title>Check Car Details</title>
+  <defs>
+    <linearGradient id="ccd-tile" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#0ed1d3"/>
+      <stop offset="1" stop-color="#1b9fdd"/>
+    </linearGradient>
+  </defs>
+  <rect width="48" height="48" rx="11" fill="url(#ccd-tile)"/>
+  <path d="M24 6.5c4.6 2.6 9.6 3.9 14.5 4v12.2c0 8.6-5.8 15.3-14.5 19-8.7-3.7-14.5-10.4-14.5-19V10.5c4.9-.1 9.9-1.4 14.5-4z" fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linejoin="round"/>
+  <g fill="#ffffff">
+    <path d="M17.2 17.4h9.6v2.2h-8.4l-1.4 3.4h17.9v-.8h2.1v1.2l1 .9v6.5H14v-6.5l1-.9zM15.6 26.4v2h3.4v-2zm13.4 0v2h3.4v-2zm-8 1.6v1.4h6V28z" fill-rule="evenodd"/>
+    <path d="M15.4 32.1h3.6v2.6h-3.6zM29 32.1h3.6v2.6H29z"/>
+    <circle cx="31.6" cy="17.6" r="5.2"/>
+  </g>
+  <path d="M29.3 17.6l1.6 1.6 3-3.1" fill="none" stroke="#14bcd9" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
+  <g fill="#ffffff">
+    <circle cx="20.6" cy="37.6" r="1"/>
+    <circle cx="24" cy="37.6" r="1"/>
+    <circle cx="27.4" cy="37.6" r="1"/>
+  </g>
+</svg>

+ 324 - 0
src/__tests__/features/integrations/checkcardetails.test.ts

@@ -0,0 +1,324 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import { RegistryQuotaError } from '@/features/integrations/Lib/inspection-sync'
+import type { ConnectorContext } from '@/features/integrations/Lib/types'
+import {
+  connector,
+  engineSize,
+  type FullRecord,
+  fuelType,
+  lookupRegistration,
+  mapFull,
+  mapRegistration,
+  merge,
+  normalisePlate,
+  type RegistrationRecord,
+  vehicleClass,
+} from '@/integrations/checkcardetails/server'
+
+/**
+ * Vehicle Registration answers recorded from the live service on 28 October
+ * 2026 with a test key, which answers real DVLA records for plates that
+ * contain an A. UK Vehicle Data is the vendor's documented sample: the test
+ * account was not given that data point, and the live service refuses it
+ * with the 403 recorded below.
+ */
+const FIXTURES = path.join(__dirname, 'fixtures/checkcardetails')
+function fixture<T>(name: string): T {
+  return JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8'))
+}
+
+/** The vendor's refusals, word for word as the live service sent them. */
+const NOT_FOUND = { message: 'Vehicle with the provided parameters was not found.' }
+const VIN_NOT_FOUND = { message: 'Vehicle with the provided parameters was not found' }
+const NOT_AUTHORISED = {
+  message:
+    'You are not authorised to access this data point. Please request access on https://api.checkcardetails.co.uk/support/premiumdatarequest or contact support. https://api.checkcardetails.co.uk/support',
+}
+const BAD_KEY = { message: 'The api key is missing or invalid' }
+const TEST_KEY_LIMIT = {
+  message: "Test API key limitation: Key value for VRM must contain the letter 'A'",
+}
+const DAILY_LIMIT = {
+  message:
+    "You have reached your daily limit. Please contact support to increase your account's daily limit. https://api.checkcardetails.co.uk/support",
+}
+
+function json(body: unknown, status = 200): Response {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { 'Content-Type': 'application/json' },
+  })
+}
+
+/** A vendor that answers each data point from a table, by the data point named in the path. */
+function vendor(answers: Record<string, () => Response>) {
+  return async (url: string) => {
+    const point = new URL(url).pathname.split('/').pop() as string
+    const answer = answers[point]
+    if (!answer) throw new Error(`unexpected data point ${point}`)
+    return answer()
+  }
+}
+
+function context(fetchImpl: (url: string) => Promise<Response>, apiKey = 'k3y') {
+  const log = vi.fn(async () => undefined)
+  const ctx = {
+    connection: {
+      id: 'c1',
+      organizationId: 'org',
+      connectorId: 'checkcardetails',
+      settings: {},
+      state: {},
+      externalAccountId: null,
+    },
+    credentials: { apiKey },
+    http: { fetch: vi.fn(fetchImpl), json: vi.fn() },
+    links: { get: vi.fn(), set: vi.fn(), remove: vi.fn(), remoteIds: vi.fn() },
+    log,
+    saveState: vi.fn(),
+    timezone: 'Europe/London',
+    appUrl: 'https://app.test',
+  } as unknown as ConnectorContext
+  return { ctx, log, fetch: ctx.http.fetch as ReturnType<typeof vi.fn> }
+}
+
+const registration = fixture<RegistrationRecord>('vehicleregistration-EA65AMX')
+const full = fixture<FullRecord>('ukvehicledata-sample')
+
+describe('checkcardetails mapping', () => {
+  it('reads Vehicle Registration, MOT date included', () => {
+    expect(mapRegistration(registration)).toEqual({
+      make: 'Audi',
+      model: 'A7',
+      year: 2015,
+      licensePlate: 'EA65AMX',
+      color: 'Black',
+      fuelType: 'diesel',
+      engineSize: '3.0 L',
+      vehicleClass: 'Car',
+      inspectionDue: '2027-01-26',
+    })
+  })
+
+  it('reads UK Vehicle Data: VIN, transmission, first registration and weights', () => {
+    expect(mapFull(full)).toEqual({
+      make: 'Mini',
+      model: 'COOPER S AUTO',
+      year: 2007,
+      vin: 'WMWMF72060TL55555',
+      licensePlate: 'SAMPLE',
+      color: 'Black',
+      fuelType: 'gasoline',
+      transmission: 'automatic',
+      engineSize: '1.6 L',
+      vehicleClass: 'Car',
+      firstRegistered: '2007-05-14',
+      weights: { kerb: 1155, grossMax: 1605 },
+    })
+  })
+
+  it('keeps the registration record in front and takes the VIN from the full one', () => {
+    const merged = merge(mapRegistration(registration), mapFull(full))
+    expect(merged).toMatchObject({
+      make: 'Audi',
+      licensePlate: 'EA65AMX',
+      inspectionDue: '2027-01-26',
+      vin: 'WMWMF72060TL55555',
+      transmission: 'automatic',
+      firstRegistered: '2007-05-14',
+    })
+  })
+
+  it('marks a scrapped or exported vehicle as no longer registered', () => {
+    const scrapped = { VehicleRegistration: { ...full.VehicleRegistration, Scrapped: true } }
+    expect(mapFull(scrapped).registered).toBe(false)
+    expect(mapFull(full).registered).toBeUndefined()
+  })
+
+  it('maps the DVLA fuel words onto the form options', () => {
+    expect(fuelType('PETROL')).toBe('gasoline')
+    expect(fuelType('DIESEL')).toBe('diesel')
+    expect(fuelType('ELECTRICITY')).toBe('electric')
+    expect(fuelType('HYBRID ELECTRIC')).toBe('hybrid')
+    expect(fuelType('ELECTRIC DIESEL')).toBe('hybrid')
+    expect(fuelType('GAS BI-FUEL')).toBeUndefined()
+  })
+
+  it('tells motorcycles from cars by type approval', () => {
+    expect(vehicleClass('M1')).toBe('Car')
+    expect(vehicleClass('L3e')).toBe('Motorcycle')
+    expect(vehicleClass('L1e')).toBe('Moped')
+    expect(vehicleClass('N1')).toBe('Light goods vehicle')
+    expect(vehicleClass('XYZ')).toBe('XYZ')
+  })
+
+  it('keeps small engines exact instead of rounding them to zero', () => {
+    expect(engineSize(49)).toBe('0.049 L')
+    expect(engineSize(125)).toBe('0.125 L')
+    expect(engineSize('649')).toBe('0.649 L')
+    expect(engineSize(2993)).toBe('3.0 L')
+    expect(engineSize(0)).toBeUndefined()
+  })
+
+  it('sends plates without spaces', () => {
+    expect(normalisePlate('ea65 amx')).toBe('EA65AMX')
+  })
+})
+
+describe('checkcardetails lookup', () => {
+  it('asks both data points with the plate and the key, and merges the answers', async () => {
+    const { ctx, fetch } = context(
+      vendor({
+        vehicleregistration: () => json(registration),
+        ukvehicledata: () => json(full),
+      })
+    )
+    const result = await connector.lookupVehicle?.(ctx, { plate: 'ea65 amx' })
+    expect(result).toMatchObject({ make: 'Audi', vin: 'WMWMF72060TL55555' })
+    const urls = fetch.mock.calls.map(([url]) => new URL(url as string))
+    expect(urls.map((u) => u.pathname).sort()).toEqual([
+      '/vehicledata/ukvehicledata',
+      '/vehicledata/vehicleregistration',
+    ])
+    for (const u of urls) {
+      expect(u.searchParams.get('vrm')).toBe('EA65AMX')
+      expect(u.searchParams.get('apikey')).toBe('k3y')
+    }
+  })
+
+  it('answers from Vehicle Registration alone when UK Vehicle Data is not on the account', async () => {
+    const { ctx, log } = context(
+      vendor({
+        vehicleregistration: () => json(registration),
+        ukvehicledata: () => json(NOT_AUTHORISED, 403),
+      })
+    )
+    const result = await connector.lookupVehicle?.(ctx, { plate: 'EA65AMX' })
+    expect(result?.make).toBe('Audi')
+    expect(result?.vin).toBeUndefined()
+    expect(log).toHaveBeenCalledWith('info', 'Vehicle lookup: match', {
+      by: 'plate',
+      fullData: 'not enabled',
+    })
+  })
+
+  it('still answers when UK Vehicle Data fails for another reason', async () => {
+    const { ctx } = context(
+      vendor({
+        vehicleregistration: () => json(registration),
+        ukvehicledata: () => new Response('Internal Server Error', { status: 500 }),
+      })
+    )
+    expect((await connector.lookupVehicle?.(ctx, { plate: 'EA65AMX' }))?.make).toBe('Audi')
+  })
+
+  it('looks up by VIN when there is no plate, and a VIN it does not know is no match', async () => {
+    const { ctx, fetch } = context(
+      vendor({
+        vehicleregistration: () => json(VIN_NOT_FOUND, 400),
+        ukvehicledata: () => json(VIN_NOT_FOUND, 400),
+      })
+    )
+    expect(await connector.lookupVehicle?.(ctx, { vin: 'wauzzz4g1fn000000' })).toBeNull()
+    const u = new URL(fetch.mock.calls[0][0] as string)
+    expect(u.searchParams.get('vin')).toBe('WAUZZZ4G1FN000000')
+    expect(u.searchParams.get('vrm')).toBeNull()
+  })
+
+  it('reports a plate the register does not know as no match', async () => {
+    const { ctx } = context(
+      vendor({
+        vehicleregistration: () => json(NOT_FOUND, 404),
+        ukvehicledata: () => json(NOT_AUTHORISED, 403),
+      })
+    )
+    expect(await connector.lookupVehicle?.(ctx, { plate: 'AA00AAA' })).toBeNull()
+  })
+
+  it('says what a test key cannot do', async () => {
+    const { ctx } = context(
+      vendor({
+        vehicleregistration: () => json(TEST_KEY_LIMIT, 400),
+        ukvehicledata: () => json(NOT_AUTHORISED, 403),
+      })
+    )
+    await expect(connector.lookupVehicle?.(ctx, { plate: 'BN12XYZ' })).rejects.toThrow(
+      /test key only looks up plates and VINs that contain the letter A/
+    )
+  })
+
+  it('says when the key is wrong and when the daily limit is reached', async () => {
+    const bad = context(
+      vendor({
+        vehicleregistration: () => json(BAD_KEY, 403),
+        ukvehicledata: () => json(BAD_KEY, 403),
+      })
+    )
+    await expect(connector.lookupVehicle?.(bad.ctx, { plate: 'EA65AMX' })).rejects.toThrow(
+      'Check Car Details rejected the API key'
+    )
+    const limited = context(
+      vendor({
+        vehicleregistration: () => json(DAILY_LIMIT, 403),
+        ukvehicledata: () => json(NOT_AUTHORISED, 403),
+      })
+    )
+    // A typed error, so the MOT sync stops instead of marking the rest checked.
+    await expect(connector.lookupVehicle?.(limited.ctx, { plate: 'EA65AMX' })).rejects.toThrow(
+      RegistryQuotaError
+    )
+  })
+
+  it('never writes the plate or the key into the log', async () => {
+    const { ctx, log } = context(
+      vendor({
+        vehicleregistration: () => json(registration),
+        ukvehicledata: () => json(NOT_AUTHORISED, 403),
+      })
+    )
+    await connector.lookupVehicle?.(ctx, { plate: 'EA65AMX' })
+    expect(JSON.stringify(log.mock.calls)).not.toMatch(/EA65AMX|k3y/)
+  })
+})
+
+describe('checkcardetails MOT sync', () => {
+  it('asks Vehicle Registration only, which is all the MOT date needs', async () => {
+    const { ctx, fetch } = context(vendor({ vehicleregistration: () => json(registration) }))
+    const result = await lookupRegistration(ctx, { plate: 'EA65AMX' })
+    expect(result?.inspectionDue).toBe('2027-01-26')
+    expect(fetch).toHaveBeenCalledTimes(1)
+    expect(new URL(fetch.mock.calls[0][0] as string).pathname).toBe(
+      '/vehicledata/vehicleregistration'
+    )
+  })
+
+  it('offers the MOT sync as an inspection connector', () => {
+    expect(connector.manifest.capabilities).toContain('vehicle.inspection')
+    expect(Object.keys(connector.jobs)).toHaveLength(1)
+  })
+})
+
+describe('checkcardetails connection test', () => {
+  it('proves a key on a plate that was never issued, free of charge', async () => {
+    const { ctx, fetch } = context(vendor({ vehicleregistration: () => json(NOT_FOUND, 404) }))
+    expect(await connector.test(ctx)).toEqual({ ok: true })
+    const u = new URL(fetch.mock.calls[0][0] as string)
+    expect(u.pathname).toBe('/vehicledata/vehicleregistration')
+    expect(u.searchParams.get('vrm')).toBe('AA00AAA')
+  })
+
+  it('refuses a bad key and an empty one', async () => {
+    const bad = context(vendor({ vehicleregistration: () => json(BAD_KEY, 403) }))
+    expect(await connector.test(bad.ctx)).toEqual({
+      ok: false,
+      message: 'Check Car Details rejected the API key',
+    })
+    const empty = context(vendor({}), '')
+    expect(await connector.test(empty.ctx)).toEqual({
+      ok: false,
+      message: 'Check Car Details: an API key is required',
+    })
+  })
+})

+ 66 - 0
src/__tests__/features/integrations/fixtures/checkcardetails/ukvehicledata-sample.json

@@ -0,0 +1,66 @@
+{
+  "VehicleRegistration": {
+    "DateOfLastUpdate": "2023-02-10T14:42:24",
+    "Colour": "BLACK",
+    "VehicleClass": "Car",
+    "CertificateOfDestructionIssued": false,
+    "EngineNumber": "A448H555",
+    "EngineCapacity": "1598",
+    "TransmissionCode": "A",
+    "Exported": false,
+    "YearOfManufacture": "2007",
+    "WheelPlan": "2 AXLE RIGID BODY",
+    "DateExported": null,
+    "Scrapped": false,
+    "Transmission": "AUTO 6 GEARS",
+    "DateFirstRegisteredUk": "2007-05-14T00:00:00",
+    "Model": "COOPER S AUTO",
+    "GearCount": 6,
+    "ImportNonEu": false,
+    "PreviousVrmGb": null,
+    "GrossWeight": 1605,
+    "DoorPlanLiteral": "3 DOOR HATCHBACK",
+    "MvrisModelCode": "ACN",
+    "Vin": "WMWMF72060TL55555",
+    "Vrm": "SAMPLE",
+    "DateFirstRegistered": "2007-05-14T00:00:00",
+    "DateScrapped": null,
+    "DoorPlan": "13",
+    "YearMonthFirstRegistered": "2007-05",
+    "VinLast5": "39282",
+    "VehicleUsedBeforeFirstRegistration": false,
+    "MaxPermissibleMass": 1605,
+    "Make": "MINI",
+    "MakeModel": "MINI COOPER S AUTO",
+    "TransmissionType": "Automatic",
+    "SeatingCapacity": 4,
+    "FuelType": "PETROL",
+    "Co2Emissions": 182,
+    "Imported": false,
+    "MvrisMakeCode": "C1",
+    "PreviousVrmNi": null,
+    "VinConfirmationFlag": null
+  },
+  "Dimensions": {
+    "UnladenWeight": 1155,
+    "RigidArtic": "RIGID",
+    "BodyShape": "NA",
+    "Height": 1407,
+    "NumberOfDoors": 3,
+    "NumberOfSeats": 4,
+    "KerbWeight": 1155,
+    "FuelTankCapacity": 50,
+    "WheelBase": 2467,
+    "CarLength": 3714,
+    "Width": 1683,
+    "NumberOfAxles": 2,
+    "GrossVehicleWeight": 1605,
+    "GrossCombinedWeight": null
+  },
+  "General": {
+    "TypeApprovalCategory": "M1",
+    "DriverPosition": "R",
+    "DrivingAxle": "FWD",
+    "EuroStatus": "4"
+  }
+}

+ 25 - 0
src/__tests__/features/integrations/fixtures/checkcardetails/vehicleregistration-AB12CDE.json

@@ -0,0 +1,25 @@
+{
+  "registrationNumber": "AB12CDE",
+  "make": "VAUXHALL",
+  "model": "ASTRA",
+  "colour": "WHITE",
+  "fuelType": "PETROL",
+  "engineCapacity": 1399,
+  "yearOfManufacture": 2017,
+  "vehicleAge": "9 years 4 months",
+  "wheelplan": "2 AXLE RIGID BODY",
+  "dateOfLastV5CIssued": "2025-04-25",
+  "typeApproval": "M1",
+  "co2Emissions": 128,
+  "registrationPlace": "Peterborough",
+  "tax": {
+    "taxStatus": "Taxed",
+    "taxDueDate": "2026-12-01",
+    "days": "35"
+  },
+  "mot": {
+    "motStatus": "Valid",
+    "motDueDate": "2027-06-11",
+    "days": 227
+  }
+}

+ 25 - 0
src/__tests__/features/integrations/fixtures/checkcardetails/vehicleregistration-EA65AMX.json

@@ -0,0 +1,25 @@
+{
+  "registrationNumber": "EA65AMX",
+  "make": "AUDI",
+  "model": "A7",
+  "colour": "BLACK",
+  "fuelType": "DIESEL",
+  "engineCapacity": 2967,
+  "yearOfManufacture": 2015,
+  "vehicleAge": "11 years",
+  "wheelplan": "2 AXLE RIGID BODY",
+  "dateOfLastV5CIssued": "2024-09-25",
+  "typeApproval": "M1",
+  "co2Emissions": 142,
+  "registrationPlace": "Chelmsford",
+  "tax": {
+    "taxStatus": "Taxed",
+    "taxDueDate": "2027-08-01",
+    "days": "278"
+  },
+  "mot": {
+    "motStatus": "Valid",
+    "motDueDate": "2027-01-26",
+    "days": 91
+  }
+}

+ 23 - 3
src/__tests__/features/integrations/inspection-sync.test.ts

@@ -5,9 +5,13 @@ const vehicle = { findMany: vi.fn() }
 const vehicleInspectionStatus = { upsert: vi.fn(), count: vi.fn() }
 vi.mock('@/lib/db', () => ({ db: { vehicle, vehicleInspectionStatus } }))
 
-const { BATCH_SIZE, recordRegistryAnswer, refreshInspections, vehiclesDueForCheck } = await import(
-  '@/features/integrations/Lib/inspection-sync'
-)
+const {
+  BATCH_SIZE,
+  RegistryQuotaError,
+  recordRegistryAnswer,
+  refreshInspections,
+  vehiclesDueForCheck,
+} = await import('@/features/integrations/Lib/inspection-sync')
 
 function context(settings: Record<string, unknown>, state: Record<string, unknown> = {}) {
   const log = vi.fn(
@@ -113,6 +117,22 @@ describe('inspection refresh job', () => {
     expect(lookup).toHaveBeenCalledTimes(1)
   })
 
+  it('stops the pass when the registry allowance is spent, leaving the rest unchecked', async () => {
+    vehicle.findMany.mockResolvedValueOnce([
+      { id: 'v1', licensePlate: 'EA65AMX' },
+      { id: 'v2', licensePlate: 'MA19ZXC' },
+    ])
+    const lookup = vi
+      .fn()
+      .mockRejectedValue(
+        new RegistryQuotaError('Check Car Details: the daily lookup limit is reached')
+      )
+    const { ctx } = context({ syncInspections: true })
+    await expect(refreshInspections(ctx, lookup)).rejects.toThrow(RegistryQuotaError)
+    expect(lookup).toHaveBeenCalledTimes(1)
+    expect(vehicleInspectionStatus.upsert).not.toHaveBeenCalled()
+  })
+
   it('notes a one-off failure and moves on', async () => {
     vehicle.findMany.mockResolvedValueOnce([{ id: 'v1', licensePlate: 'EV11223' }])
     const lookup = vi.fn().mockRejectedValue(new Error('HTTP 502'))

+ 66 - 6
src/__tests__/features/integrations/vin-lookup.test.ts

@@ -27,7 +27,11 @@ import {
   lookupVehicle,
 } from '@/features/integrations/Actions/vehicleLookupActions'
 import { recordRegistryAnswer } from '@/features/integrations/Lib/inspection-sync'
-import { askRegistry, findLookupConnection } from '@/features/integrations/Lib/vehicle-lookup'
+import {
+  askRegistry,
+  countriesForDialCode,
+  findLookupConnection,
+} from '@/features/integrations/Lib/vehicle-lookup'
 import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 import { getFeatures } from '@/lib/features'
@@ -36,7 +40,11 @@ const VIN = '1HGCM82633A004352'
 
 function connected(...connectorIds: string[]) {
   vi.mocked(db.integrationConnection.findMany).mockResolvedValue(
-    connectorIds.map((connectorId) => ({ id: `conn-${connectorId}`, connectorId })) as never
+    connectorIds.map((connectorId) => ({
+      id: `conn-${connectorId}`,
+      connectorId,
+      settings: {},
+    })) as never
   )
 }
 
@@ -57,7 +65,7 @@ beforeEach(() => {
   } as never)
   vi.mocked(getFeatures).mockResolvedValue({ integrations: true } as never)
   vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: 'v1' } as never)
-  country('US')
+  country('+1')
 })
 
 describe('findLookupConnection', () => {
@@ -74,14 +82,66 @@ describe('findLookupConnection', () => {
   })
 
   it('treats a registry that answers to both as either', async () => {
-    country('NO')
+    country('+47')
     connected('vegvesen')
     expect(await findLookupConnection('org1', 'vin')).toMatchObject({ connectorId: 'vegvesen' })
     expect(await findLookupConnection('org1', 'plate')).toMatchObject({ connectorId: 'vegvesen' })
   })
 
+  it("prefers the registry for the workshop's country, read from its phone prefix", async () => {
+    connected('vegvesen', 'checkcardetails')
+    country('+44')
+    expect(await findLookupConnection('org1', 'plate')).toMatchObject({
+      connectorId: 'checkcardetails',
+    })
+    country('+47')
+    expect(await findLookupConnection('org1', 'plate')).toMatchObject({ connectorId: 'vegvesen' })
+  })
+
+  it('takes the newest of two registries for the same country', async () => {
+    country('+44')
+    // The query asks for the newest first; the mock returns rows in that order.
+    connected('checkcardetails', 'openapi-automotive')
+    expect(await findLookupConnection('org1', 'plate')).toMatchObject({
+      connectorId: 'checkcardetails',
+    })
+    expect(db.integrationConnection.findMany).toHaveBeenCalledWith(
+      expect.objectContaining({ orderBy: { createdAt: 'desc' } })
+    )
+  })
+
+  it('matches a multi-country registry on the country chosen in its settings', async () => {
+    country('+44')
+    // Openapi was connected last, but for France: a British plate belongs to Check Car Details.
+    vi.mocked(db.integrationConnection.findMany).mockResolvedValue([
+      { id: 'conn-openapi', connectorId: 'openapi-automotive', settings: { country: 'FR' } },
+      { id: 'conn-ccd', connectorId: 'checkcardetails', settings: {} },
+    ] as never)
+    expect(await findLookupConnection('org1', 'plate')).toEqual({
+      id: 'conn-ccd',
+      connectorId: 'checkcardetails',
+    })
+    // Set to the United Kingdom, the newer Openapi connection wins the tie.
+    vi.mocked(db.integrationConnection.findMany).mockResolvedValue([
+      { id: 'conn-openapi', connectorId: 'openapi-automotive', settings: { country: 'GB' } },
+      { id: 'conn-ccd', connectorId: 'checkcardetails', settings: {} },
+    ] as never)
+    expect(await findLookupConnection('org1', 'plate')).toEqual({
+      id: 'conn-openapi',
+      connectorId: 'openapi-automotive',
+    })
+  })
+
+  it('reads the phone prefix in the forms people type it', () => {
+    expect(countriesForDialCode('+44')).toEqual(['GB'])
+    expect(countriesForDialCode('0047')).toEqual(['NO'])
+    expect(countriesForDialCode('47')).toEqual(['NO'])
+    expect(countriesForDialCode('+999')).toEqual([])
+    expect(countriesForDialCode(null)).toEqual([])
+  })
+
   it('offers no VIN lookup for plate-only registries', async () => {
-    country('NL')
+    country('+31')
     connected('rdw', 'openapi-automotive')
     expect(await findLookupConnection('org1', 'vin')).toBeNull()
   })
@@ -121,7 +181,7 @@ describe('lookupVehicle by VIN', () => {
   })
 
   it('still records what a registry says when it was asked by VIN', async () => {
-    country('NO')
+    country('+47')
     connected('vegvesen')
     vi.mocked(askRegistry).mockResolvedValue({
       result: { make: 'Toyota', vin: VIN, inspectionDue: '2027-04-26' },

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

@@ -196,6 +196,38 @@ export function ConnectionSettings({
 
   const firstSchedule = manifest.schedules?.[0]?.job
   const hasSync = Boolean(firstSchedule)
+  // An inspection pass does nothing while its setting is off, so the button
+  // waits for it rather than looking like it worked.
+  const inspectionSyncOff =
+    Boolean(view.inspectionSync) && connection?.settings.syncInspections !== true
+
+  async function syncNow() {
+    if (!firstSchedule) return
+    // A registry pass looks up every vehicle that is due, and a paid
+    // registry bills each one found, so it says how many before it starts.
+    const sync = view.inspectionSync
+    if (sync) {
+      if (sync.dueForCheck === 0) {
+        toast.info(t('connection.inspectionSync.nothingDue'))
+        return
+      }
+      const ok = await confirm({
+        title: t('connection.inspectionSync.confirmTitle'),
+        description: t('connection.inspectionSync.confirmDescription', {
+          count: sync.dueForCheck,
+          total: sync.vehiclesWithPlate,
+          name: manifest.name,
+        }),
+        confirmLabel: t('connection.inspectionSync.confirmLabel', { count: sync.dueForCheck }),
+      })
+      if (!ok) return
+    }
+    await run(
+      'sync',
+      () => runIntegrationJob(manifest.id, firstSchedule),
+      isAccounting ? t('connection.pullQueued') : t('connection.syncQueued')
+    )
+  }
   const isCalendar = manifest.category === 'calendar'
   const isAccounting = manifest.category === 'accounting'
   // Mail vendors can prove themselves by delivering a message to the person
@@ -489,14 +521,9 @@ export function ConnectionSettings({
               <Button
                 variant="outline"
                 size="sm"
-                onClick={() =>
-                  run(
-                    'sync',
-                    () => runIntegrationJob(manifest.id, firstSchedule),
-                    isAccounting ? t('connection.pullQueued') : t('connection.syncQueued')
-                  )
-                }
-                disabled={busy !== null || setupMissing}
+                onClick={syncNow}
+                disabled={busy !== null || setupMissing || inspectionSyncOff}
+                title={inspectionSyncOff ? t('connection.inspectionSync.off') : undefined}
               >
                 {busy === 'sync' ? (
                   <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />

+ 13 - 1
src/features/integrations/Lib/inspection-sync.ts

@@ -55,6 +55,18 @@ export function inspectionJobs(lookup: VehicleLookup): Record<string, JobHandler
   return { [INSPECTION_JOB]: (ctx) => refreshInspections(ctx, lookup) }
 }
 
+/**
+ * Thrown by a registry whose allowance is spent for now, such as a daily
+ * limit. A pass stops on it instead of marking every remaining vehicle as
+ * checked, so they are retried once the allowance comes back.
+ */
+export class RegistryQuotaError extends Error {
+  constructor(message: string) {
+    super(message)
+    this.name = 'RegistryQuotaError'
+  }
+}
+
 /** Deadlines closer than this are "soon" and rechecked more often. */
 export const SOON_DAYS = 60
 export const RECHECK_SOON_DAYS = 7
@@ -263,7 +275,7 @@ export async function refreshInspections(
       const message = err instanceof Error ? err.message : String(err)
       // A rejected key or a spent quota will fail every vehicle in turn, so
       // stop here and let the job's own retry pick the pass up later.
-      if (/rejected the API key|quota/i.test(message)) {
+      if (err instanceof RegistryQuotaError || /rejected the API key|quota/i.test(message)) {
         await ctx.saveState({ inspectionRun: tally })
         throw err
       }

+ 55 - 8
src/features/integrations/Lib/vehicle-lookup.ts

@@ -38,9 +38,43 @@ export function lookupKeys(connectorId: string): VehicleLookupKey[] {
   return getManifest(connectorId)?.lookupBy ?? ['plate']
 }
 
+/**
+ * Calling codes of the countries a registry serves. The workshop's country
+ * is only known from its default phone prefix ("+44"), while registries list
+ * the countries they serve as ISO codes ("GB"), so this joins the two.
+ */
+const DIAL_CODES: Record<string, string[]> = {
+  '1': ['US'],
+  '31': ['NL'],
+  '33': ['FR'],
+  '34': ['ES'],
+  '36': ['HU'],
+  '39': ['IT'],
+  '44': ['GB'],
+  '45': ['DK'],
+  '46': ['SE'],
+  '47': ['NO'],
+  '61': ['AU'],
+  '64': ['NZ'],
+  '351': ['PT'],
+  '353': ['IE'],
+  '358': ['FI'],
+  '372': ['EE'],
+  '385': ['HR'],
+  '420': ['CZ'],
+  '421': ['SK'],
+}
+
+/** The countries a "+44", "44" or "0044" prefix belongs to; none when unset or unknown. */
+export function countriesForDialCode(value: string | null | undefined): string[] {
+  const digits = value?.replace(/\D/g, '').replace(/^00/, '') ?? ''
+  return DIAL_CODES[digits] ?? []
+}
+
 /**
  * The active connection that can answer a plate or a VIN, preferring one for
- * the workshop's own country when more than one registry is connected.
+ * the workshop's own country when more than one registry is connected, and
+ * the newest when two serve the same country.
  */
 export async function findLookupConnection(
   organizationId: string,
@@ -49,7 +83,10 @@ export async function findLookupConnection(
   const [rows, countrySetting] = await Promise.all([
     db.integrationConnection.findMany({
       where: { organizationId, status: 'active' },
-      select: { id: true, connectorId: true },
+      select: { id: true, connectorId: true, settings: true },
+      // Newest first: a workshop that connects a second registry for the
+      // same country is switching to it.
+      orderBy: { createdAt: 'desc' },
     }),
     db.appSetting.findUnique({
       where: {
@@ -58,18 +95,28 @@ export async function findLookupConnection(
       select: { value: true },
     }),
   ])
-  const country = countrySetting?.value?.toUpperCase() ?? null
+  const countries = countriesForDialCode(countrySetting?.value)
   const candidates = rows.filter(
     (r) =>
       getManifest(r.connectorId)?.capabilities.includes(LOOKUP_CAPABILITY) &&
       lookupKeys(r.connectorId).includes(by)
   )
   if (candidates.length === 0) return null
-  const local = candidates.find((r) => {
-    const countries = getManifest(r.connectorId)?.countries
-    return country && Array.isArray(countries) && countries.includes(country)
-  })
-  return local ?? candidates[0]
+  const local = candidates.find((r) => servedCountries(r).some((c) => countries.includes(c)))
+  const pick = local ?? candidates[0]
+  return { id: pick.id, connectorId: pick.connectorId }
+}
+
+/**
+ * The countries one connection actually searches. A registry that covers
+ * several countries, such as Openapi or RegCheck, searches only the one
+ * chosen on its integration page; the rest serve what their manifest lists.
+ */
+function servedCountries(row: { connectorId: string; settings: unknown }): string[] {
+  const settings = (row.settings ?? {}) as Record<string, unknown>
+  if (typeof settings.country === 'string' && settings.country) return [settings.country]
+  const served = getManifest(row.connectorId)?.countries
+  return Array.isArray(served) ? served : []
 }
 
 export interface RegistryAnswer {

+ 32 - 0
src/integrations/checkcardetails/manifest.ts

@@ -0,0 +1,32 @@
+import { INSPECTION_MANIFEST } from '@/features/integrations/Lib/inspection-sync'
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * Check Car Details: DVLA and DVSA data for a British plate or VIN, cars and
+ * motorcycles from one register, through the workshop's own API key.
+ *
+ * Two of the vendor's data points are used. Vehicle Registration is on for
+ * every account and gives make, model, colour, fuel, engine capacity and the
+ * MOT due date. UK Vehicle Data adds the VIN, transmission and weights, but
+ * an account has to ask the vendor to turn it on. There is no switch for it
+ * here: every lookup asks for both, and an account without UK Vehicle Data
+ * is refused that half at no charge. No owner data comes back from either.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'checkcardetails',
+  name: 'Check Car Details',
+  category: 'registry',
+  countries: ['GB'],
+  logo: '/images/integrations/checkcardetails.svg',
+  docs: '/docs/integrations/checkcardetails',
+  auth: {
+    type: 'api-key',
+    fields: [
+      { key: 'apiKey', label: 'apiKey', type: 'password', required: true, help: 'apiKeyHelp' },
+    ],
+  },
+  capabilities: ['vehicle.lookup', INSPECTION_MANIFEST.capability],
+  lookupBy: ['plate', 'vin'],
+  settings: [INSPECTION_MANIFEST.setting],
+  schedules: [INSPECTION_MANIFEST.schedule],
+}

+ 375 - 0
src/integrations/checkcardetails/server.ts

@@ -0,0 +1,375 @@
+import { inspectionJobs, RegistryQuotaError } from '@/features/integrations/Lib/inspection-sync'
+import { makeCase } from '@/features/integrations/Lib/make-case'
+import type {
+  ConnectorContext,
+  ConnectorServer,
+  VehicleLookupQuery,
+  VehicleLookupResult,
+} from '@/features/integrations/Lib/types'
+import { manifest } from './manifest'
+
+/**
+ * Check Car Details: GET /vehicledata/<data point>?apikey=..&vrm=.. (or
+ * &vin=..), answered with the record as bare JSON. Only a successful lookup
+ * is billed.
+ *
+ * Verified against the live service on 28 October 2026:
+ * - A plate the register does not know is 404; a VIN it does not know is
+ *   400, both with "Vehicle with the provided parameters was not found".
+ * - A bad key is 403 "The api key is missing or invalid", no key 400.
+ * - A data point the account has not been given is 403 "You are not
+ *   authorised to access this data point", in a fifth of a second.
+ * - A test key only answers plates and VINs that contain the letter A, and
+ *   refuses the rest with 400 "Test API key limitation".
+ * - Plates are matched without regard to spaces or case.
+ *
+ * Plates are personal data, so nothing here writes one into the connection
+ * log; the log records outcomes only.
+ */
+const BASE_URL = 'https://api.checkcardetails.co.uk/vehicledata'
+
+/** On every account: make, model, colour, fuel, engine capacity, tax and MOT. */
+const REGISTRATION = 'vehicleregistration'
+/** Turned on by the vendor on request: adds the VIN, transmission and weights. */
+const FULL = 'ukvehicledata'
+
+/** A plate in the shape of a current one that has never been issued, for proving a key free of charge. */
+const PROBE_PLATE = 'AA00AAA'
+
+/** Vehicle Registration, as the live service sends it. */
+export interface RegistrationRecord {
+  registrationNumber?: string
+  make?: string
+  model?: string
+  colour?: string
+  fuelType?: string
+  engineCapacity?: number | string
+  yearOfManufacture?: number | string
+  wheelplan?: string
+  typeApproval?: string
+  tax?: { taxStatus?: string; taxDueDate?: string }
+  mot?: { motStatus?: string; motDueDate?: string }
+}
+
+/** The parts of UK Vehicle Data this app stores. */
+export interface FullRecord {
+  VehicleRegistration?: {
+    Vrm?: string
+    Vin?: string
+    Make?: string
+    Model?: string
+    Colour?: string
+    FuelType?: string
+    EngineCapacity?: string | number
+    YearOfManufacture?: string | number
+    DateFirstRegistered?: string
+    Transmission?: string
+    TransmissionType?: string
+    VehicleClass?: string
+    GrossWeight?: number
+    Scrapped?: boolean
+    Exported?: boolean
+  }
+  Dimensions?: {
+    KerbWeight?: number | null
+    GrossVehicleWeight?: number | null
+  }
+}
+
+function text(value: unknown): string | undefined {
+  if (typeof value === 'number') return String(value)
+  if (typeof value !== 'string') return undefined
+  const trimmed = value.trim()
+  return trimmed ? trimmed : undefined
+}
+
+/** The DVLA's fuel words onto the form's options. Gas and steam have no option and are left alone. */
+export function fuelType(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  const v = value.toLowerCase()
+  const electric = /electri/.test(v)
+  const diesel = /diesel/.test(v)
+  const petrol = /petrol/.test(v)
+  if (/hybrid/.test(v) || (electric && (diesel || petrol))) return 'hybrid'
+  if (electric) return 'electric'
+  if (diesel) return 'diesel'
+  if (petrol) return 'gasoline'
+  return undefined
+}
+
+export function transmission(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  const v = value.toLowerCase()
+  if (/cvt|variable|continu/.test(v)) return 'cvt'
+  if (/auto/.test(v)) return 'automatic'
+  if (/manual/.test(v)) return 'manual'
+  return undefined
+}
+
+/**
+ * Displacement as the form shows it, from cubic centimetres. Cars round to a
+ * tenth of a litre as the badge does; motorcycles and mopeds keep every
+ * cubic centimetre, so a 49 cc moped is 0.049 L and never rounds to zero.
+ */
+export function engineSize(value: string | number | undefined): string | undefined {
+  const cc = Number(text(value))
+  if (!Number.isFinite(cc) || cc < 30) return undefined
+  const litres = cc < 1000 ? Number((cc / 1000).toFixed(3)) : (cc / 1000).toFixed(1)
+  return `${litres} L`
+}
+
+/** The date part of 2027-01-26 or 2007-05-14T00:00:00. */
+export function isoDate(value: string | undefined): string | undefined {
+  const m = value ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim()) : null
+  return m ? `${m[1]}-${m[2]}-${m[3]}` : undefined
+}
+
+/**
+ * What the type approval says the vehicle is. The register files cars and
+ * motorcycles together, so this is the one place a lookup tells them apart
+ * when the account has Vehicle Registration alone.
+ */
+export function vehicleClass(typeApproval: string | undefined): string | undefined {
+  const code = text(typeApproval)?.toUpperCase()
+  if (!code) return undefined
+  if (code === 'M1') return 'Car'
+  if (code === 'M2' || code === 'M3') return 'Bus'
+  if (code === 'N1') return 'Light goods vehicle'
+  if (code === 'N2' || code === 'N3') return 'Goods vehicle'
+  if (/^L[12]E?/.test(code)) return 'Moped'
+  if (/^L[34]E?/.test(code)) return 'Motorcycle'
+  if (/^L5E?/.test(code)) return 'Tricycle'
+  if (/^L[67]E?/.test(code)) return 'Quadricycle'
+  return code
+}
+
+function year(value: unknown): number | undefined {
+  const n = Number(text(value))
+  return Number.isFinite(n) && n > 1800 ? n : undefined
+}
+
+function kilograms(value: unknown): number | undefined {
+  const n = Number(value)
+  return Number.isFinite(n) && n > 0 ? n : undefined
+}
+
+function validVin(value: string | undefined): string | undefined {
+  const v = value?.trim().toUpperCase()
+  return v && /^[A-HJ-NPR-Z0-9]{11,17}$/.test(v) ? v : undefined
+}
+
+function withoutEmpty(result: VehicleLookupResult): VehicleLookupResult {
+  // Drop the keys that came back empty so a form's fill-if-empty sees only values.
+  for (const key of Object.keys(result) as (keyof VehicleLookupResult)[]) {
+    if (result[key] === undefined) delete result[key]
+  }
+  return result
+}
+
+/** Vehicle Registration in the app's vocabulary. Exported for the contract test. */
+export function mapRegistration(record: RegistrationRecord): VehicleLookupResult {
+  const make = text(record.make)
+  const colour = text(record.colour)
+  return withoutEmpty({
+    make: make ? makeCase(make) : undefined,
+    model: text(record.model),
+    year: year(record.yearOfManufacture),
+    licensePlate: text(record.registrationNumber),
+    color: colour ? makeCase(colour) : undefined,
+    fuelType: fuelType(text(record.fuelType)),
+    engineSize: engineSize(record.engineCapacity),
+    vehicleClass: vehicleClass(record.typeApproval),
+    inspectionDue: isoDate(text(record.mot?.motDueDate)),
+  })
+}
+
+/** UK Vehicle Data in the app's vocabulary. Exported for the contract test. */
+export function mapFull(record: FullRecord): VehicleLookupResult {
+  const reg = record.VehicleRegistration ?? {}
+  const make = text(reg.Make)
+  const colour = text(reg.Colour)
+  const kerb = kilograms(record.Dimensions?.KerbWeight)
+  const grossMax = kilograms(record.Dimensions?.GrossVehicleWeight) ?? kilograms(reg.GrossWeight)
+  return withoutEmpty({
+    make: make ? makeCase(make) : undefined,
+    model: text(reg.Model),
+    year: year(reg.YearOfManufacture),
+    vin: validVin(text(reg.Vin)),
+    licensePlate: text(reg.Vrm),
+    color: colour ? makeCase(colour) : undefined,
+    fuelType: fuelType(text(reg.FuelType)),
+    transmission: transmission(text(reg.TransmissionType) ?? text(reg.Transmission)),
+    engineSize: engineSize(reg.EngineCapacity),
+    vehicleClass: text(reg.VehicleClass),
+    firstRegistered: isoDate(text(reg.DateFirstRegistered)),
+    weights: kerb || grossMax ? { kerb, grossMax } : undefined,
+    registered: reg.Scrapped === true || reg.Exported === true ? false : undefined,
+  })
+}
+
+/**
+ * One vehicle from both answers. Vehicle Registration is the DVLA's current
+ * record and has the MOT date, so it leads; UK Vehicle Data fills what it
+ * lacks, the VIN above all.
+ */
+export function merge(
+  registration: VehicleLookupResult | null,
+  full: VehicleLookupResult | null
+): VehicleLookupResult | null {
+  if (!registration) return full
+  if (!full) return registration
+  return withoutEmpty({ ...full, ...registration, vin: full.vin ?? registration.vin })
+}
+
+/** Plates are letters and digits; people type them with spaces. */
+export function normalisePlate(plate: string): string {
+  return plate.replace(/[\s-]+/g, '').toUpperCase()
+}
+
+function apiKey(ctx: ConnectorContext): string {
+  return typeof ctx.credentials.apiKey === 'string' ? ctx.credentials.apiKey.trim() : ''
+}
+
+function url(ctx: ConnectorContext, dataPoint: string, query: { vrm?: string; vin?: string }) {
+  const params = new URLSearchParams({ apikey: apiKey(ctx) })
+  if (query.vrm) params.set('vrm', query.vrm)
+  if (query.vin) params.set('vin', query.vin)
+  return `${BASE_URL}/${dataPoint}?${params}`
+}
+
+/** The vendor's own words, from a JSON body or a plain one. */
+async function messageOf(res: Response): Promise<string> {
+  const body = await res.text().catch(() => '')
+  try {
+    const parsed = JSON.parse(body) as { message?: unknown }
+    if (typeof parsed.message === 'string') return parsed.message
+  } catch {
+    // A plain body, such as "Invalid Request".
+  }
+  return body.trim()
+}
+
+/** How one data point answered: a record, no such vehicle, or not on this account. */
+type Answer<T> = { kind: 'record'; record: T } | { kind: 'none' } | { kind: 'not-enabled' }
+
+/**
+ * One data point, with every refusal the documentation lists put in words
+ * the person at the form can act on. A data point the account has not been
+ * given comes back as 'not-enabled' for the caller to decide about.
+ */
+async function ask<T>(
+  ctx: ConnectorContext,
+  dataPoint: string,
+  query: { vrm?: string; vin?: string }
+): Promise<Answer<T>> {
+  const res = await ctx.http.fetch(url(ctx, dataPoint, query), {
+    headers: { Accept: 'application/json' },
+  })
+  if (res.ok) return { kind: 'record', record: (await res.json()) as T }
+  const message = await messageOf(res)
+  const said = message.toLowerCase()
+  if (res.status === 404 || said.includes('not found')) return { kind: 'none' }
+  if (said.includes('not authorised to access this data point')) return { kind: 'not-enabled' }
+  if (said.includes('test api key limitation')) {
+    await ctx.log('info', 'Vehicle lookup: refused by the test key', { dataPoint })
+    throw new Error(
+      'Check Car Details: a test key only looks up plates and VINs that contain the letter A'
+    )
+  }
+  if (said.includes('daily limit')) {
+    await ctx.log('warn', 'Vehicle lookup: daily limit reached', { dataPoint })
+    throw new RegistryQuotaError(
+      'Check Car Details: the daily lookup limit is reached, ask Check Car Details support to raise it'
+    )
+  }
+  if (res.status === 400 || res.status === 403) {
+    await ctx.log('error', 'Vehicle lookup: API key rejected', { status: res.status, dataPoint })
+    throw new Error('Check Car Details rejected the API key')
+  }
+  await ctx.log('error', 'Vehicle lookup failed', { status: res.status, dataPoint })
+  throw new Error(`Check Car Details answered HTTP ${res.status}`)
+}
+
+/** The query as the vendor takes it: a plate when there is one, the VIN otherwise. */
+function vendorQuery(query: VehicleLookupQuery): { vrm?: string; vin?: string } | null {
+  const plate = query.plate?.trim() ? normalisePlate(query.plate) : ''
+  if (plate) return { vrm: plate }
+  const vin = query.vin?.trim().toUpperCase()
+  return vin ? { vin } : null
+}
+
+/**
+ * Vehicle Registration alone. Enough for the MOT date, which is all the
+ * inspection sync needs, at a fifth of the price of the full record.
+ */
+export async function lookupRegistration(
+  ctx: ConnectorContext,
+  query: VehicleLookupQuery
+): Promise<VehicleLookupResult | null> {
+  const q = vendorQuery(query)
+  if (!q) return null
+  const answer = await ask<RegistrationRecord>(ctx, REGISTRATION, q)
+  if (answer.kind === 'not-enabled') {
+    throw new Error('Check Car Details: Vehicle Registration is not enabled on this account')
+  }
+  return answer.kind === 'record' ? mapRegistration(answer.record) : null
+}
+
+/**
+ * One vehicle by plate or VIN. Both data points are asked side by side, so
+ * the full record costs no waiting; an account without UK Vehicle Data is
+ * refused that half free of charge and gets the registration record alone.
+ */
+async function lookupVehicle(
+  ctx: ConnectorContext,
+  query: VehicleLookupQuery
+): Promise<VehicleLookupResult | null> {
+  const q = vendorQuery(query)
+  if (!q) return null
+  const [registration, full] = await Promise.all([
+    ask<RegistrationRecord>(ctx, REGISTRATION, q),
+    // The full record is extra: when it fails for any other reason, the
+    // registration record still fills the form, and its own failure says why.
+    ask<FullRecord>(ctx, FULL, q).catch(async (err): Promise<Answer<FullRecord>> => {
+      await ctx.log('warn', 'Vehicle lookup: UK Vehicle Data failed', {
+        error: err instanceof Error ? err.message : String(err),
+      })
+      return { kind: 'none' }
+    }),
+  ])
+  if (registration.kind === 'not-enabled' && full.kind !== 'record') {
+    throw new Error('Check Car Details: Vehicle Registration is not enabled on this account')
+  }
+  const result = merge(
+    registration.kind === 'record' ? mapRegistration(registration.record) : null,
+    full.kind === 'record' ? mapFull(full.record) : null
+  )
+  await ctx.log('info', result ? 'Vehicle lookup: match' : 'Vehicle lookup: no match', {
+    by: q.vrm ? 'plate' : 'vin',
+    fullData: full.kind === 'record' ? 'yes' : full.kind === 'not-enabled' ? 'not enabled' : 'no',
+  })
+  if (result && q.vrm && !result.licensePlate) result.licensePlate = q.vrm
+  return result
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    if (!apiKey(ctx)) return { ok: false, message: 'Check Car Details: an API key is required' }
+    // Every successful lookup is billed, so the key is proved on a plate
+    // that was never issued: a good key is told "not found", a bad one is
+    // turned away. The plate has an A in it, so a test key passes too.
+    try {
+      await ask(ctx, REGISTRATION, { vrm: PROBE_PLATE })
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : 'Connection test failed' }
+    }
+  },
+
+  lookupVehicle,
+
+  // The sync only needs the MOT date, which the cheaper data point has.
+  jobs: inspectionJobs(lookupRegistration),
+}

+ 2 - 0
src/integrations/registry.ts

@@ -12,6 +12,7 @@ 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 checkCarDetails } from './checkcardetails/manifest'
 import { manifest as googleCalendar } from './google-calendar/manifest'
 import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
@@ -54,6 +55,7 @@ const ALL_ENTRIES: readonly RegistryEntry[] = [
   { manifest: zoom, load: () => import('./zoom/server') },
   { manifest: vegvesen, load: () => import('./vegvesen/server') },
   { manifest: openapiAutomotive, load: () => import('./openapi-automotive/server') },
+  { manifest: checkCarDetails, load: () => import('./checkcardetails/server') },
   { manifest: rdw, load: () => import('./rdw/server') },
   { manifest: regcheck, load: () => import('./regcheck/server') },
   { manifest: nhtsa, load: () => import('./nhtsa/server') },