Browse Source

RDW Open Data and RegCheck plate lookups (#311)

* RDW Open Data plate lookup for the Netherlands

Free, keyless lookup against RDW's open vehicle register, with an optional
Socrata app token for the throttle, APK dates feeding the inspection sync,
and fuel read from the fuel dataset. The vocabularies the mapper reads were
taken from the datasets' own value counts, and the tests run against rows
recorded from the live API.

* RegCheck plate lookup for Australia, New Zealand, the US and eight more countries

One connector on Infinite Loop's RegCheck web service, with the workshop's
own username and credits, a country setting and a state setting for the two
countries that register per state. The mapper reads the shape each country
actually sends, recorded live from the vendor's free test plates, and the
connection test uses the free credit balance so it never spends a lookup.
Make casing moves into a shared helper so Kia stays Kia and BMW stays BMW.

* RegCheck: Norway as a fallback beside Statens vegvesen

The register block carries VIN, colour, fuel code, tyre and rim sizes per
axle, weights and the EU-kontroll dates, all recorded live from the vendor's
Norwegian test plate.

* Vehicle form: clearing a VIN, plate, colour or engine field sticks on save

The form sent an emptied field as undefined, which the update action reads
as untouched, so the old value came back. On edit an empty field now goes
as '' and is cleared; on create it is still left out.
Bernt Christian Egeland 4 weeks ago
parent
commit
9b2ea5d490
51 changed files with 2779 additions and 24 deletions
  1. 26 0
      messages/de/integrations.json
  2. 26 0
      messages/en/integrations.json
  3. 26 0
      messages/es/integrations.json
  4. 26 0
      messages/fr/integrations.json
  5. 26 0
      messages/it/integrations.json
  6. 26 0
      messages/lt/integrations.json
  7. 26 0
      messages/nb/integrations.json
  8. 26 0
      messages/nl/integrations.json
  9. 26 0
      messages/pl/integrations.json
  10. 26 0
      messages/pt-BR/integrations.json
  11. 26 0
      messages/ru/integrations.json
  12. 26 0
      messages/tr/integrations.json
  13. 10 0
      public/images/integrations/rdw.svg
  14. 11 0
      public/images/integrations/regcheck.svg
  15. 17 0
      src/__tests__/features/integrations/fixtures/rdw/G317PG-fuel.json
  16. 58 0
      src/__tests__/features/integrations/fixtures/rdw/G317PG-main.json
  17. 27 0
      src/__tests__/features/integrations/fixtures/rdw/SK209X-fuel.json
  18. 57 0
      src/__tests__/features/integrations/fixtures/rdw/SK209X-main.json
  19. 18 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-ACT.json
  20. 43 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-NSW.json
  21. 57 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-NSW.xml
  22. 26 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-NT.json
  23. 15 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-QLD.json
  24. 19 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-SA.json
  25. 27 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-TAS.json
  26. 26 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-VIC.json
  27. 42 0
      src/__tests__/features/integrations/fixtures/regcheck/AU-WA.json
  28. 26 0
      src/__tests__/features/integrations/fixtures/regcheck/CZ.json
  29. 122 0
      src/__tests__/features/integrations/fixtures/regcheck/DK.json
  30. 23 0
      src/__tests__/features/integrations/fixtures/regcheck/EE.json
  31. 39 0
      src/__tests__/features/integrations/fixtures/regcheck/FI.json
  32. 21 0
      src/__tests__/features/integrations/fixtures/regcheck/HR.json
  33. 29 0
      src/__tests__/features/integrations/fixtures/regcheck/HU.json
  34. 39 0
      src/__tests__/features/integrations/fixtures/regcheck/IE.json
  35. 80 0
      src/__tests__/features/integrations/fixtures/regcheck/NO.json
  36. 44 0
      src/__tests__/features/integrations/fixtures/regcheck/NZ.json
  37. 41 0
      src/__tests__/features/integrations/fixtures/regcheck/SE.json
  38. 24 0
      src/__tests__/features/integrations/fixtures/regcheck/SK.json
  39. 21 0
      src/__tests__/features/integrations/fixtures/regcheck/UK-live.xml
  40. 25 0
      src/__tests__/features/integrations/fixtures/regcheck/US.json
  41. 7 5
      src/__tests__/features/integrations/openapi-automotive.test.ts
  42. 214 0
      src/__tests__/features/integrations/rdw.test.ts
  43. 409 0
      src/__tests__/features/integrations/regcheck.test.ts
  44. 19 0
      src/features/integrations/Lib/make-case.ts
  45. 13 5
      src/features/vehicles/Components/VehicleForm.tsx
  46. 3 14
      src/integrations/openapi-automotive/server.ts
  47. 28 0
      src/integrations/rdw/manifest.ts
  48. 222 0
      src/integrations/rdw/server.ts
  49. 74 0
      src/integrations/regcheck/manifest.ts
  50. 487 0
      src/integrations/regcheck/server.ts
  51. 4 0
      src/integrations/registry.ts

+ 26 - 0
messages/de/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Auch Motorräder abfragen",
         "bikesHelp": "Ist ein Kennzeichen kein Pkw, wird zusätzlich das Motorradregister gefragt. Ein Fehltreffer beim Pkw ist kostenlos, es entstehen also nur Kosten, wenn ein Motorrad gefunden wird. Für Portugal nicht verfügbar."
       }
+    },
+    "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": {
+        "appToken": "App-Token (optional)",
+        "appTokenHelp": "Die offenen Daten der RDW funktionieren ohne Schlüssel, anonyme Anfragen teilen sich aber ein gedrosseltes Kontingent. Für die Prüfterminsynchronisation legen Sie auf opendata.rdw.nl ein kostenloses Konto an, öffnen die Entwicklereinstellungen Ihres Profils und erstellen ein App-Token."
+      },
+      "settings": {
+        "syncInspections": "APK-Termine für alle Fahrzeuge dieser Werkstatt aktuell halten",
+        "syncInspectionsHelp": "Einmal pro Stunde werden Fahrzeuge, die länger nicht geprüft wurden, per Kennzeichen abgefragt und der nächste APK-Termin gespeichert. Es werden nur Fahrzeuge dieser Werkstatt gesendet. Ein Fahrzeug wird monatlich geprüft, wöchentlich sobald die Frist unter zwei Monaten liegt."
+      }
+    },
+    "regcheck": {
+      "description": "Fahrzeug aus dem Kennzeichen in Australien, Neuseeland, den USA, Irland, Norwegen, Schweden, Dänemark, Finnland, Estland, Tschechien, der Slowakei, Ungarn oder Kroatien ausfüllen: Marke, Modell, Baujahr, FIN, Kraftstoff und Hubraum, soweit das Land sie veröffentlicht, über RegCheck. Jede erfolgreiche Abfrage verbraucht ein Guthaben aus dem eigenen RegCheck-Konto der Werkstatt. Halterdaten werden nicht geliefert.",
+      "fields": {
+        "username": "RegCheck-Benutzername",
+        "usernameHelp": "Registrieren Sie sich auf regcheck.org.uk; das Konto enthält 10 kostenlose Abfragen, weitere werden über das Dashboard in Blöcken von 100 gekauft. Hier wird nur der Benutzername benötigt, nie das Passwort. Ein nicht gefundenes Kennzeichen kostet nichts."
+      },
+      "settings": {
+        "country": "Land",
+        "countryHelp": "Das zu durchsuchende Register. Jedes Land hat einen eigenen Dienst, die Verbindung durchsucht daher eines.",
+        "auState": "Bundesstaat oder Territorium",
+        "auStateHelp": "Australien lässt Fahrzeuge je Bundesstaat zu, und dem Register muss gesagt werden, welcher durchsucht wird. Wählen Sie den Staat, in dem die meisten Fahrzeuge Ihrer Kunden zugelassen sind.",
+        "usState": "Bundesstaat",
+        "usStateHelp": "Der zweibuchstabige Code des Bundesstaats, in dem die Fahrzeuge zugelassen sind, etwa CA oder TX. Amerikanische Register werden je Bundesstaat durchsucht."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/en/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Also look up motorcycles",
         "bikesHelp": "When a plate is not a car, ask the motorcycle registry too. A miss on the car side is free, so this costs nothing extra unless a motorcycle is found. Not available for Portugal."
       }
+    },
+    "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": {
+        "appToken": "App token (optional)",
+        "appTokenHelp": "RDW's open data works without a key, but anonymous requests share a throttled pool. For the inspection sync, create a free account on opendata.rdw.nl, open your profile's developer settings and create an app token."
+      },
+      "settings": {
+        "syncInspections": "Keep APK dates up to date for every vehicle in this workshop",
+        "syncInspectionsHelp": "Once an hour, vehicles that have not been checked recently are looked up by plate and their next APK date is saved. Only vehicles stored in this workshop are ever sent. A vehicle is rechecked monthly, and weekly once its deadline is within two months."
+      }
+    },
+    "regcheck": {
+      "description": "Fill in a vehicle from its plate in Australia, New Zealand, the United States, Ireland, Norway, Sweden, Denmark, Finland, Estonia, Czechia, Slovakia, Hungary or Croatia: make, model, year, VIN, fuel and engine size where the country publishes them, through RegCheck. Each successful lookup spends one credit from the workshop's own RegCheck account. No owner data is returned.",
+      "fields": {
+        "username": "RegCheck username",
+        "usernameHelp": "Register on regcheck.org.uk; the account comes with 10 free lookups and more are bought in blocks of 100 through the dashboard. Only the username is needed here, never the password. A plate that is not found costs nothing."
+      },
+      "settings": {
+        "country": "Country",
+        "countryHelp": "The registry to search. Each country has its own service, so the connection searches one.",
+        "auState": "State or territory",
+        "auStateHelp": "Australia registers vehicles per state, and the registry must be told which one to search. Choose where most of your customers' vehicles are registered.",
+        "usState": "State",
+        "usStateHelp": "The two-letter code of the state the vehicles are registered in, such as CA or TX. American registries are searched per state."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/es/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Buscar también motocicletas",
         "bikesHelp": "Cuando una matrícula no es de un coche, se consulta también el registro de motocicletas. Un fallo en la búsqueda de coches es gratis, así que no cuesta nada más salvo que se encuentre una motocicleta. No disponible para Portugal."
       }
+    },
+    "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": {
+        "appToken": "Token de aplicación (opcional)",
+        "appTokenHelp": "Los datos abiertos de la RDW funcionan sin clave, pero las peticiones anónimas comparten un cupo limitado. Para la sincronización de inspecciones, crea una cuenta gratuita en opendata.rdw.nl, abre los ajustes de desarrollador de tu perfil y crea un token de aplicación."
+      },
+      "settings": {
+        "syncInspections": "Mantener actualizadas las fechas de APK de todos los vehículos de este taller",
+        "syncInspectionsHelp": "Una vez por hora se consultan por matrícula los vehículos que no se han comprobado hace poco y se guarda la próxima fecha de APK. Solo se envían vehículos almacenados en este taller. Un vehículo se revisa cada mes, y cada semana cuando faltan menos de dos meses."
+      }
+    },
+    "regcheck": {
+      "description": "Rellena un vehículo a partir de su matrícula en Australia, Nueva Zelanda, Estados Unidos, Irlanda, Noruega, Suecia, Dinamarca, Finlandia, Estonia, Chequia, Eslovaquia, Hungría o Croacia: marca, modelo, año, VIN, combustible y cilindrada cuando el país los publica, a través de RegCheck. Cada consulta con resultado gasta un crédito de la cuenta de RegCheck del propio taller. No se devuelven datos del propietario.",
+      "fields": {
+        "username": "Usuario de RegCheck",
+        "usernameHelp": "Regístrate en regcheck.org.uk; la cuenta incluye 10 consultas gratuitas y las demás se compran en bloques de 100 desde el panel. Aquí solo hace falta el usuario, nunca la contraseña. Una matrícula no encontrada no cuesta nada."
+      },
+      "settings": {
+        "country": "País",
+        "countryHelp": "El registro donde buscar. Cada país tiene su propio servicio, así que la conexión busca en uno.",
+        "auState": "Estado o territorio",
+        "auStateHelp": "Australia matricula los vehículos por estado y hay que indicar al registro cuál buscar. Elige donde están matriculados la mayoría de los vehículos de tus clientes.",
+        "usState": "Estado",
+        "usStateHelp": "El código de dos letras del estado donde están matriculados los vehículos, como CA o TX. Los registros estadounidenses se consultan por estado."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/fr/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Rechercher aussi les motos",
         "bikesHelp": "Quand une plaque n'est pas celle d'une voiture, le registre des motos est interrogé à son tour. Une recherche voiture sans résultat est gratuite, cela ne coûte donc rien de plus sauf si une moto est trouvée. Indisponible pour le Portugal."
       }
+    },
+    "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": {
+        "appToken": "Jeton d'application (facultatif)",
+        "appTokenHelp": "Les données ouvertes de la RDW fonctionnent sans clé, mais les requêtes anonymes partagent un quota limité. Pour la synchronisation des contrôles, créez un compte gratuit sur opendata.rdw.nl, ouvrez les paramètres développeur de votre profil et créez un jeton d'application."
+      },
+      "settings": {
+        "syncInspections": "Tenir à jour les dates d'APK de tous les véhicules de cet atelier",
+        "syncInspectionsHelp": "Une fois par heure, les véhicules non vérifiés récemment sont recherchés par plaque et la date du prochain APK est enregistrée. Seuls les véhicules de cet atelier sont envoyés. Un véhicule est revérifié chaque mois, et chaque semaine lorsque l'échéance est à moins de deux mois."
+      }
+    },
+    "regcheck": {
+      "description": "Remplissez un véhicule à partir de sa plaque en Australie, Nouvelle-Zélande, aux États-Unis, en Irlande, Norvège, Suède, Danemark, Finlande, Estonie, Tchéquie, Slovaquie, Hongrie ou Croatie : marque, modèle, année, VIN, carburant et cylindrée lorsque le pays les publie, via RegCheck. Chaque recherche aboutie consomme un crédit du compte RegCheck de l'atelier. Aucune donnée sur le propriétaire n'est renvoyée.",
+      "fields": {
+        "username": "Nom d'utilisateur RegCheck",
+        "usernameHelp": "Inscrivez-vous sur regcheck.org.uk ; le compte comprend 10 recherches gratuites, les suivantes s'achètent par blocs de 100 depuis le tableau de bord. Seul le nom d'utilisateur est nécessaire ici, jamais le mot de passe. Une plaque introuvable ne coûte rien."
+      },
+      "settings": {
+        "country": "Pays",
+        "countryHelp": "Le registre à interroger. Chaque pays a son propre service, la connexion n'en interroge donc qu'un.",
+        "auState": "État ou territoire",
+        "auStateHelp": "L'Australie immatricule les véhicules par État et le registre doit savoir lequel interroger. Choisissez celui où la plupart des véhicules de vos clients sont immatriculés.",
+        "usState": "État",
+        "usStateHelp": "Le code à deux lettres de l'État où les véhicules sont immatriculés, comme CA ou TX. Les registres américains sont interrogés par État."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/it/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Cerca anche i motocicli",
         "bikesHelp": "Quando una targa non è di un'auto, viene consultato anche il registro dei motocicli. Una ricerca auto senza esito è gratuita, quindi non costa nulla in più a meno che non venga trovato un motociclo. Non disponibile per il Portogallo."
       }
+    },
+    "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": {
+        "appToken": "Token applicazione (facoltativo)",
+        "appTokenHelp": "I dati aperti della RDW funzionano senza chiave, ma le richieste anonime condividono una quota limitata. Per la sincronizzazione delle revisioni, crea un account gratuito su opendata.rdw.nl, apri le impostazioni sviluppatore del tuo profilo e crea un token applicazione."
+      },
+      "settings": {
+        "syncInspections": "Tieni aggiornate le date APK di tutti i veicoli di questa officina",
+        "syncInspectionsHelp": "Una volta all'ora i veicoli non controllati di recente vengono cercati per targa e la data del prossimo APK viene salvata. Vengono inviati solo i veicoli di questa officina. Un veicolo è ricontrollato ogni mese, e ogni settimana quando la scadenza è a meno di due mesi."
+      }
+    },
+    "regcheck": {
+      "description": "Compila un veicolo dalla targa in Australia, Nuova Zelanda, Stati Uniti, Irlanda, Norvegia, Svezia, Danimarca, Finlandia, Estonia, Cechia, Slovacchia, Ungheria o Croazia: marca, modello, anno, VIN, carburante e cilindrata quando il paese li pubblica, tramite RegCheck. Ogni ricerca con esito consuma un credito dall'account RegCheck dell'officina. Non vengono restituiti dati sul proprietario.",
+      "fields": {
+        "username": "Nome utente RegCheck",
+        "usernameHelp": "Registrati su regcheck.org.uk; l'account include 10 ricerche gratuite e le altre si acquistano in blocchi da 100 dal pannello. Qui serve solo il nome utente, mai la password. Una targa non trovata non costa nulla."
+      },
+      "settings": {
+        "country": "Paese",
+        "countryHelp": "Il registro da consultare. Ogni paese ha un proprio servizio, quindi la connessione ne consulta uno.",
+        "auState": "Stato o territorio",
+        "auStateHelp": "L'Australia immatricola i veicoli per stato e al registro va detto quale consultare. Scegli quello in cui è immatricolata la maggior parte dei veicoli dei tuoi clienti.",
+        "usState": "Stato",
+        "usStateHelp": "Il codice di due lettere dello stato in cui sono immatricolati i veicoli, come CA o TX. I registri americani si consultano per stato."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/lt/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Ieškoti ir motociklų",
         "bikesHelp": "Kai numeris nėra automobilio, papildomai užklausiamas motociklų registras. Nerastas automobilis nekainuoja, todėl papildomų išlaidų nėra, nebent randamas motociklas. Portugalijai neprieinama."
       }
+    },
+    "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": {
+        "appToken": "Programos žetonas (neprivaloma)",
+        "appTokenHelp": "RDW atviri duomenys veikia be rakto, bet anoniminės užklausos dalijasi ribotą kvotą. Apžiūrų sinchronizavimui susikurkite nemokamą paskyrą opendata.rdw.nl, atverkite profilio kūrėjo nustatymus ir sukurkite programos žetoną."
+      },
+      "settings": {
+        "syncInspections": "Atnaujinti visų šių dirbtuvių transporto priemonių APK datas",
+        "syncInspectionsHelp": "Kartą per valandą neseniai netikrintos transporto priemonės ieškomos pagal numerį ir išsaugoma kita APK data. Siunčiamos tik šiose dirbtuvėse saugomos transporto priemonės. Tikrinama kas mėnesį, o kai iki termino lieka mažiau nei du mėnesiai, kas savaitę."
+      }
+    },
+    "regcheck": {
+      "description": "Užpildykite transporto priemonę pagal numerį Australijoje, Naujojoje Zelandijoje, JAV, Airijoje, Norvegijoje, Švedijoje, Danijoje, Suomijoje, Estijoje, Čekijoje, Slovakijoje, Vengrijoje arba Kroatijoje: markė, modelis, metai, VIN, degalai ir variklio tūris, kai šalis juos skelbia, per RegCheck. Kiekviena sėkminga užklausa išleidžia vieną kreditą iš pačių dirbtuvių RegCheck paskyros. Savininko duomenys negrąžinami.",
+      "fields": {
+        "username": "RegCheck naudotojo vardas",
+        "usernameHelp": "Užsiregistruokite regcheck.org.uk; paskyroje yra 10 nemokamų užklausų, daugiau perkama po 100 valdymo skydelyje. Čia reikia tik naudotojo vardo, niekada slaptažodžio. Nerastas numeris nieko nekainuoja."
+      },
+      "settings": {
+        "country": "Šalis",
+        "countryHelp": "Registras, kuriame ieškoti. Kiekviena šalis turi savo paslaugą, todėl ryšys ieško vienoje.",
+        "auState": "Valstija arba teritorija",
+        "auStateHelp": "Australija registruoja transporto priemones pagal valstijas, ir registrui reikia nurodyti, kurioje ieškoti. Pasirinkite tą, kurioje registruota dauguma jūsų klientų transporto priemonių.",
+        "usState": "Valstija",
+        "usStateHelp": "Dviejų raidžių valstijos, kurioje registruotos transporto priemonės, kodas, pvz., CA arba TX. JAV registrai užklausiami pagal valstiją."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/nb/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Slå også opp motorsykler",
         "bikesHelp": "Når et skilt ikke er en bil, spørres motorsykkelregisteret også. Et bomskudd på bilsiden er gratis, så dette koster ikke noe ekstra med mindre en motorsykkel blir funnet. Ikke tilgjengelig for Portugal."
       }
+    },
+    "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": {
+        "appToken": "App-token (valgfritt)",
+        "appTokenHelp": "RDWs åpne data fungerer uten nøkkel, men anonyme forespørsler deler en begrenset kvote. For kontrollsynkroniseringen: opprett en gratis konto på opendata.rdw.nl, åpne utviklerinnstillingene i profilen og lag et app-token."
+      },
+      "settings": {
+        "syncInspections": "Hold APK-fristen oppdatert for alle kjøretøy i dette verkstedet",
+        "syncInspectionsHelp": "Én gang i timen slås kjøretøy som ikke er sjekket nylig opp på skilt, og fristen for neste APK lagres. Bare kjøretøy lagret i dette verkstedet sendes. Et kjøretøy sjekkes månedlig, og ukentlig når fristen er under to måneder unna."
+      }
+    },
+    "regcheck": {
+      "description": "Fyll ut et kjøretøy fra skiltet i Australia, New Zealand, USA, Irland, Norge, Sverige, Danmark, Finland, Estland, Tsjekkia, Slovakia, Ungarn eller Kroatia: merke, modell, årsmodell, understellsnummer, drivstoff og slagvolum der landet publiserer dem, via RegCheck. Hvert vellykkede oppslag bruker én kreditt fra verkstedets egen RegCheck-konto. Eieropplysninger leveres ikke.",
+      "fields": {
+        "username": "RegCheck-brukernavn",
+        "usernameHelp": "Registrer deg på regcheck.org.uk; kontoen kommer med 10 gratis oppslag, og flere kjøpes i blokker på 100 fra dashbordet. Bare brukernavnet trengs her, aldri passordet. Et skilt som ikke blir funnet koster ingenting."
+      },
+      "settings": {
+        "country": "Land",
+        "countryHelp": "Registeret som skal søkes i. Hvert land har sin egen tjeneste, så tilkoblingen søker i ett.",
+        "auState": "Delstat eller territorium",
+        "auStateHelp": "Australia registrerer kjøretøy per delstat, og registeret må få vite hvilken som skal søkes. Velg der de fleste av kundenes kjøretøy er registrert.",
+        "usState": "Delstat",
+        "usStateHelp": "Tobokstavskoden for delstaten kjøretøyene er registrert i, som CA eller TX. Amerikanske registre søkes per delstat."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/nl/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Ook motorfietsen opzoeken",
         "bikesHelp": "Als een kenteken geen auto is, wordt ook het motorfietsregister bevraagd. Geen resultaat bij auto's is gratis, dus dit kost niets extra tenzij een motorfiets wordt gevonden. Niet beschikbaar voor Portugal."
       }
+    },
+    "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": {
+        "appToken": "App-token (optioneel)",
+        "appTokenHelp": "De open data van de RDW werkt zonder sleutel, maar anonieme verzoeken delen een beperkt quotum. Voor de APK-synchronisatie: maak een gratis account op opendata.rdw.nl, open de ontwikkelaarsinstellingen van je profiel en maak een app-token aan."
+      },
+      "settings": {
+        "syncInspections": "APK-vervaldatums van alle voertuigen in deze werkplaats bijhouden",
+        "syncInspectionsHelp": "Eén keer per uur worden voertuigen die niet recent zijn gecontroleerd op kenteken opgezocht en wordt de volgende APK-vervaldatum opgeslagen. Alleen voertuigen van deze werkplaats worden verstuurd. Een voertuig wordt maandelijks gecontroleerd, en wekelijks zodra de datum binnen twee maanden valt."
+      }
+    },
+    "regcheck": {
+      "description": "Vul een voertuig in vanuit het kenteken in Australië, Nieuw-Zeeland, de Verenigde Staten, Ierland, Noorwegen, Zweden, Denemarken, Finland, Estland, Tsjechië, Slowakije, Hongarije of Kroatië: merk, model, bouwjaar, VIN, brandstof en cilinderinhoud waar het land ze publiceert, via RegCheck. Elke geslaagde opzoeking kost één credit van het eigen RegCheck-account van de werkplaats. Er worden geen eigenaarsgegevens teruggegeven.",
+      "fields": {
+        "username": "RegCheck-gebruikersnaam",
+        "usernameHelp": "Registreer op regcheck.org.uk; het account komt met 10 gratis opzoekingen en meer koop je in blokken van 100 via het dashboard. Hier is alleen de gebruikersnaam nodig, nooit het wachtwoord. Een kenteken dat niet wordt gevonden kost niets."
+      },
+      "settings": {
+        "country": "Land",
+        "countryHelp": "Het register om in te zoeken. Elk land heeft zijn eigen dienst, dus de verbinding zoekt in één.",
+        "auState": "Staat of territorium",
+        "auStateHelp": "Australië registreert voertuigen per staat en het register moet weten welke te doorzoeken. Kies waar de meeste voertuigen van je klanten geregistreerd zijn.",
+        "usState": "Staat",
+        "usStateHelp": "De tweeletterige code van de staat waar de voertuigen geregistreerd zijn, zoals CA of TX. Amerikaanse registers worden per staat doorzocht."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/pl/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Wyszukuj także motocykle",
         "bikesHelp": "Gdy tablica nie należy do samochodu, odpytywany jest również rejestr motocykli. Brak wyniku dla samochodu jest bezpłatny, więc nie kosztuje to nic więcej, chyba że zostanie znaleziony motocykl. Niedostępne dla Portugalii."
       }
+    },
+    "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": {
+        "appToken": "Token aplikacji (opcjonalny)",
+        "appTokenHelp": "Otwarte dane RDW działają bez klucza, ale anonimowe zapytania dzielą ograniczoną pulę. Do synchronizacji przeglądów załóż bezpłatne konto na opendata.rdw.nl, otwórz ustawienia programisty w profilu i utwórz token aplikacji."
+      },
+      "settings": {
+        "syncInspections": "Aktualizuj terminy APK wszystkich pojazdów w tym warsztacie",
+        "syncInspectionsHelp": "Raz na godzinę pojazdy, które nie były ostatnio sprawdzane, są wyszukiwane po tablicy, a termin następnego APK jest zapisywany. Wysyłane są tylko pojazdy tego warsztatu. Pojazd jest sprawdzany co miesiąc, a co tydzień, gdy do terminu zostały mniej niż dwa miesiące."
+      }
+    },
+    "regcheck": {
+      "description": "Uzupełnij pojazd na podstawie tablicy w Australii, Nowej Zelandii, USA, Irlandii, Norwegii, Szwecji, Danii, Finlandii, Estonii, Czechach, Słowacji, na Węgrzech lub w Chorwacji: marka, model, rocznik, VIN, paliwo i pojemność silnika, o ile kraj je publikuje, przez RegCheck. Każde udane zapytanie zużywa jeden kredyt z własnego konta RegCheck warsztatu. Dane właściciela nie są zwracane.",
+      "fields": {
+        "username": "Nazwa użytkownika RegCheck",
+        "usernameHelp": "Zarejestruj się na regcheck.org.uk; konto zawiera 10 bezpłatnych zapytań, a kolejne kupuje się w pakietach po 100 w panelu. Tutaj potrzebna jest tylko nazwa użytkownika, nigdy hasło. Nieznaleziona tablica nic nie kosztuje."
+      },
+      "settings": {
+        "country": "Kraj",
+        "countryHelp": "Rejestr, w którym szukać. Każdy kraj ma własną usługę, więc połączenie przeszukuje jeden.",
+        "auState": "Stan lub terytorium",
+        "auStateHelp": "Australia rejestruje pojazdy według stanów, a rejestrowi trzeba wskazać, który przeszukać. Wybierz ten, w którym zarejestrowana jest większość pojazdów Twoich klientów.",
+        "usState": "Stan",
+        "usStateHelp": "Dwuliterowy kod stanu, w którym zarejestrowane są pojazdy, np. CA lub TX. Amerykańskie rejestry są przeszukiwane według stanu."
+      }
     }
   },
   "jobKinds": {

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

@@ -371,6 +371,32 @@
         "bikes": "Consultar também motocicletas",
         "bikesHelp": "Quando uma placa não é de carro, o registro de motocicletas também é consultado. Uma consulta de carro sem resultado é gratuita, então não custa nada a mais a menos que uma motocicleta seja encontrada. Indisponível para Portugal."
       }
+    },
+    "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": {
+        "appToken": "Token de aplicativo (opcional)",
+        "appTokenHelp": "Os dados abertos da RDW funcionam sem chave, mas as requisições anônimas compartilham uma cota limitada. Para a sincronização de inspeções, crie uma conta gratuita em opendata.rdw.nl, abra as configurações de desenvolvedor do seu perfil e crie um token de aplicativo."
+      },
+      "settings": {
+        "syncInspections": "Manter as datas de APK de todos os veículos desta oficina atualizadas",
+        "syncInspectionsHelp": "Uma vez por hora, veículos não verificados recentemente são consultados pela placa e a próxima data de APK é salva. Só veículos desta oficina são enviados. Um veículo é verificado mensalmente, e semanalmente quando o prazo está a menos de dois meses."
+      }
+    },
+    "regcheck": {
+      "description": "Preencha um veículo a partir da placa na Austrália, Nova Zelândia, Estados Unidos, Irlanda, Noruega, Suécia, Dinamarca, Finlândia, Estônia, Tchéquia, Eslováquia, Hungria ou Croácia: marca, modelo, ano, VIN, combustível e cilindrada quando o país os publica, via RegCheck. Cada consulta bem-sucedida gasta um crédito da conta RegCheck da própria oficina. Nenhum dado do proprietário é retornado.",
+      "fields": {
+        "username": "Usuário RegCheck",
+        "usernameHelp": "Cadastre-se em regcheck.org.uk; a conta vem com 10 consultas gratuitas e as demais são compradas em blocos de 100 pelo painel. Aqui só é preciso o usuário, nunca a senha. Uma placa não encontrada não custa nada."
+      },
+      "settings": {
+        "country": "País",
+        "countryHelp": "O registro a consultar. Cada país tem seu próprio serviço, então a conexão consulta um.",
+        "auState": "Estado ou território",
+        "auStateHelp": "A Austrália registra veículos por estado e o registro precisa saber qual consultar. Escolha onde a maioria dos veículos dos seus clientes está registrada.",
+        "usState": "Estado",
+        "usStateHelp": "O código de duas letras do estado em que os veículos estão registrados, como CA ou TX. Os registros americanos são consultados por estado."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/ru/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Искать также мотоциклы",
         "bikesHelp": "Если номер не принадлежит автомобилю, запрашивается и реестр мотоциклов. Запрос по автомобилю без результата бесплатен, поэтому дополнительных расходов нет, если мотоцикл не найден. Недоступно для Португалии."
       }
+    },
+    "rdw": {
+      "description": "Заполните данные автомобиля по нидерландскому номеру: марка, модель, год, топливо, объём двигателя, цвет, массы и дата следующего APK, напрямую из открытого реестра транспортных средств RDW. Бесплатно, ключ не нужен. Данные о владельце и VIN не публикуются.",
+      "fields": {
+        "appToken": "Токен приложения (необязательно)",
+        "appTokenHelp": "Открытые данные RDW работают без ключа, но анонимные запросы делят ограниченную квоту. Для синхронизации техосмотров создайте бесплатный аккаунт на opendata.rdw.nl, откройте настройки разработчика в профиле и создайте токен приложения."
+      },
+      "settings": {
+        "syncInspections": "Обновлять даты APK для всех автомобилей этой мастерской",
+        "syncInspectionsHelp": "Раз в час автомобили, которые давно не проверялись, ищутся по номеру, и сохраняется дата следующего APK. Отправляются только автомобили этой мастерской. Автомобиль проверяется ежемесячно, а когда до срока меньше двух месяцев, еженедельно."
+      }
+    },
+    "regcheck": {
+      "description": "Заполните данные автомобиля по номеру в Австралии, Новой Зеландии, США, Ирландии, Норвегии, Швеции, Дании, Финляндии, Эстонии, Чехии, Словакии, Венгрии или Хорватии: марка, модель, год, VIN, топливо и объём двигателя, если страна их публикует, через RegCheck. Каждый успешный запрос списывает один кредит со счёта RegCheck самой мастерской. Данные о владельце не передаются.",
+      "fields": {
+        "username": "Имя пользователя RegCheck",
+        "usernameHelp": "Зарегистрируйтесь на regcheck.org.uk; аккаунт включает 10 бесплатных запросов, остальные покупаются блоками по 100 в панели. Здесь нужно только имя пользователя, никогда пароль. Ненайденный номер ничего не стоит."
+      },
+      "settings": {
+        "country": "Страна",
+        "countryHelp": "Реестр, в котором искать. У каждой страны своя служба, поэтому подключение ищет в одной.",
+        "auState": "Штат или территория",
+        "auStateHelp": "Австралия регистрирует автомобили по штатам, и реестру нужно указать, в каком искать. Выберите тот, где зарегистрировано большинство автомобилей ваших клиентов.",
+        "usState": "Штат",
+        "usStateHelp": "Двухбуквенный код штата, в котором зарегистрированы автомобили, например CA или TX. Американские реестры запрашиваются по штатам."
+      }
     }
   },
   "jobKinds": {

+ 26 - 0
messages/tr/integrations.json

@@ -371,6 +371,32 @@
         "bikes": "Motosikletleri de sorgula",
         "bikesHelp": "Bir plaka otomobil değilse motosiklet sicili de sorgulanır. Otomobil tarafında sonuçsuz sorgu ücretsizdir, bu nedenle bir motosiklet bulunmadıkça ek maliyet oluşmaz. Portekiz için kullanılamaz."
       }
+    },
+    "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": {
+        "appToken": "Uygulama belirteci (isteğe bağlı)",
+        "appTokenHelp": "RDW açık verileri anahtarsız çalışır, ancak anonim istekler sınırlı bir kotayı paylaşır. Muayene eşitlemesi için opendata.rdw.nl'de ücretsiz bir hesap açın, profilinizin geliştirici ayarlarını açın ve bir uygulama belirteci oluşturun."
+      },
+      "settings": {
+        "syncInspections": "Bu atölyedeki tüm araçların APK tarihlerini güncel tut",
+        "syncInspectionsHelp": "Saatte bir, yakın zamanda kontrol edilmemiş araçlar plakayla sorgulanır ve bir sonraki APK tarihi kaydedilir. Yalnızca bu atölyede kayıtlı araçlar gönderilir. Bir araç ayda bir, süresi iki aydan az kaldığında haftada bir yeniden kontrol edilir."
+      }
+    },
+    "regcheck": {
+      "description": "Avustralya, Yeni Zelanda, ABD, İrlanda, Norveç, İsveç, Danimarka, Finlandiya, Estonya, Çekya, Slovakya, Macaristan veya Hırvatistan plakasından aracı doldurun: ülke yayımlıyorsa marka, model, yıl, VIN, yakıt ve motor hacmi, RegCheck aracılığıyla. Her başarılı sorgu atölyenin kendi RegCheck hesabından bir kredi harcar. Araç sahibi bilgisi döndürülmez.",
+      "fields": {
+        "username": "RegCheck kullanıcı adı",
+        "usernameHelp": "regcheck.org.uk'de kaydolun; hesap 10 ücretsiz sorguyla gelir, fazlası panelden 100'lük bloklar halinde satın alınır. Burada yalnızca kullanıcı adı gerekir, şifre asla. Bulunamayan plaka ücret oluşturmaz."
+      },
+      "settings": {
+        "country": "Ülke",
+        "countryHelp": "Aranacak sicil. Her ülkenin kendi hizmeti vardır, bu yüzden bağlantı tek birinde arar.",
+        "auState": "Eyalet veya bölge",
+        "auStateHelp": "Avustralya araçları eyalet bazında tescil eder ve sicile hangisinde aranacağı söylenmelidir. Müşterilerinizin araçlarının çoğunun tescilli olduğu eyaleti seçin.",
+        "usState": "Eyalet",
+        "usStateHelp": "Araçların tescilli olduğu eyaletin iki harfli kodu, örneğin CA veya TX. ABD sicilleri eyalet bazında sorgulanır."
+      }
     }
   },
   "jobKinds": {

+ 10 - 0
public/images/integrations/rdw.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="RDW">
+  <title>RDW</title>
+  <rect width="48" height="48" rx="11" fill="#ffffff"/>
+  <g transform="translate(3 17) scale(0.2234)">
+    <path fill="#c30" d="m88.7 21.954 1.9.8c-3.3 1.3-6.2 2.7-8.8 4.2l-2.7-.4c2.5-1.4 6-3.1 9.6-4.6m6.6 1.1-17.3-8.6c-1.3.2-2.7.4-3.9.6l11.7 5.1c-.2.2-.4.2-.7.3l-12.5-5.1c-2.4.4-4.8 1-6.8 1.5l11.2 3.6c-.5.2-.7.3-1.2.6l-12.1-3.6c-3.6 1-7.3 2.2-12.2 4.2l22.9 4c-.6.3-1 .6-1.7.9l-24.3-3.6c-5.8 2.5-12.5 6.3-17.9 9.8h23.9c-1.1.7-2 1.3-3.3 2.2l-25 .8c-4.6 3.3-9.3 7.2-12 9.6-2.7 2.3-10.5 10.2-14.1 17.3l29.2-7.6c-1.3 1.9-2.4 4-3 5.8l31.7-10.1c-.3-7.4 6.9-14 11.7-18h4c-6.2 5.3-9.1 11.2-8.3 15.7l9.9-3.1c-2.3-6.2 4.2-17.1 20.1-22.3"/>
+    <path fill="#121845" d="M140.7 2.5v18.2h4.8c2.1 0 3.8-.7 5.1-2.1 1.2-1.4 1.8-3.2 1.8-5.4V10c0-2.2-.6-4-1.8-5.4-1.2-1.4-2.9-2.1-5.1-2.1h-4.8zm4.7-2.5c3 0 5.4.9 7.3 2.8s2.8 4.3 2.8 7.2v3.2c0 2.9-.9 5.3-2.8 7.2-1.8 1.8-4.3 2.8-7.3 2.8H135v-2l2.6-.4V2.5L135 2V0h10.4z"/>
+    <path fill="#121845" d="m163.6 2.054-2.3.3 3 12.8.4 2.8.1.1.6-2.9 4.3-15.1h2.7l4.3 15.1.6 2.9h.1l.5-2.9 2.9-12.8-2.4-.3v-2h7.3v2l-1.8.2-5 20.8h-2.8l-4.6-16-.4-2h-.1l-.4 2-4.6 16h-2.8l-5-20.8-1.9-.2v-2h7.3z"/>
+    <path fill="#121845" d="M117.9 11.354v-8.9h4.9c1.3 0 2.3.3 3.1.9.2.1.3.2.5.4.6.6.9 1.3 1.1 2.2.1.3.1.7.1 1 0 1.3-.4 2.3-1.2 3.2-.7.7-1.7 1.1-3 1.2h-5.5zm13 9.4-5.7-7.1c1.4-.3 2.5-.8 3.4-1.7 1.4-1.3 2.1-2.9 2.1-5 0-1.4-.3-2.7-1-3.7-.3-.5-.6-.9-1-1.3-.9-.9-2.1-1.4-3.5-1.7-.7-.1-1.4-.2-2.2-.2h-10.6v2l2.6.4v18.2l-2.6.4v2h8.3v-2l-2.6-.4v-6.8h3.6l5.7 7.1-1.8.2v2h7.4v-2l-2.1-.4z"/>
+  </g>
+</svg>

+ 11 - 0
public/images/integrations/regcheck.svg

@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="RegCheck">
+  <title>RegCheck</title>
+  <rect width="48" height="48" rx="11" fill="#1d7fd6"/>
+  <g fill="#ffffff">
+    <circle cx="30.5" cy="17.5" r="8.5"/>
+    <circle cx="32.5" cy="15.5" r="2.6" fill="#1d7fd6"/>
+    <path d="M24.2 22.6l3.2 3.2-13.6 13.6H9.4v-4.4z"/>
+    <path d="M13.2 33.4l3.4-3.4 3.2 3.2-3.4 3.4z" fill="#1d7fd6"/>
+    <path d="M17.6 29l3.4-3.4 3.2 3.2-3.4 3.4z" fill="#1d7fd6"/>
+  </g>
+</svg>

+ 17 - 0
src/__tests__/features/integrations/fixtures/rdw/G317PG-fuel.json

@@ -0,0 +1,17 @@
+[
+  {
+    "kenteken": "G317PG",
+    "brandstof_volgnummer": "1",
+    "brandstof_omschrijving": "Benzine",
+    "brandstofverbruik_gecombineerd": "5.30",
+    "co2_uitstoot_gecombineerd": "124",
+    "geluidsniveau_rijdend": "71",
+    "geluidsniveau_stationair": "79",
+    "emissiecode_omschrijving": "5",
+    "milieuklasse_eg_goedkeuring_licht": "715/2007*692/2008A",
+    "uitstoot_deeltjes_licht": "0.00178",
+    "nettomaximumvermogen": "77.00",
+    "toerental_geluidsniveau": "3750",
+    "uitlaatemissieniveau": "EURO 5 A"
+  }
+]

+ 58 - 0
src/__tests__/features/integrations/fixtures/rdw/G317PG-main.json

@@ -0,0 +1,58 @@
+[
+  {
+    "kenteken": "G317PG",
+    "voertuigsoort": "Personenauto",
+    "merk": "VOLKSWAGEN",
+    "handelsbenaming": "POLO",
+    "vervaldatum_apk": "20270930",
+    "datum_tenaamstelling": "20260731",
+    "bruto_bpm": "350",
+    "inrichting": "hatchback",
+    "aantal_zitplaatsen": "5",
+    "eerste_kleur": "ZWART",
+    "tweede_kleur": "Niet geregistreerd",
+    "aantal_cilinders": "4",
+    "cilinderinhoud": "1197",
+    "massa_ledig_voertuig": "988",
+    "toegestane_maximum_massa_voertuig": "1570",
+    "massa_rijklaar": "1088",
+    "maximum_massa_trekken_ongeremd": "540",
+    "maximum_trekken_massa_geremd": "1200",
+    "datum_eerste_toelating": "20100506",
+    "datum_eerste_tenaamstelling_in_nederland": "20200920",
+    "wacht_op_keuren": "Geen verstrekking in Open Data",
+    "catalogusprijs": "23549",
+    "wam_verzekerd": "Ja",
+    "maximale_constructiesnelheid": "190",
+    "aantal_deuren": "5",
+    "aantal_wielen": "4",
+    "breedte": "168",
+    "europese_voertuigcategorie": "M1",
+    "technische_max_massa_voertuig": "1570",
+    "type": "6R",
+    "typegoedkeuringsnummer": "e1*2001/116*0510*05",
+    "variant": "ABCBZB",
+    "uitvoering": "FM6FM62U008LLNVR07MG",
+    "volgnummer_wijziging_eu_typegoedkeuring": "0",
+    "vermogen_massarijklaar": "0.07",
+    "wielbasis": "246",
+    "export_indicator": "Nee",
+    "openstaande_terugroepactie_indicator": "Nee",
+    "taxi_indicator": "Nee",
+    "maximum_massa_samenstelling": "2805",
+    "jaar_laatste_registratie_tellerstand": "2026",
+    "tellerstandoordeel": "Geen oordeel",
+    "code_toelichting_tellerstandoordeel": "05",
+    "tenaamstellen_mogelijk": "Ja",
+    "vervaldatum_apk_dt": "2027-09-30T00:00:00.000",
+    "datum_tenaamstelling_dt": "2026-07-31T00:00:00.000",
+    "datum_eerste_toelating_dt": "2010-05-06T00:00:00.000",
+    "datum_eerste_tenaamstelling_in_nederland_dt": "2020-09-20T00:00:00.000",
+    "zuinigheidsclassificatie": "B",
+    "api_gekentekende_voertuigen_assen": "https://opendata.rdw.nl/resource/3huj-srit.json",
+    "api_gekentekende_voertuigen_brandstof": "https://opendata.rdw.nl/resource/8ys7-d773.json",
+    "api_gekentekende_voertuigen_carrosserie": "https://opendata.rdw.nl/resource/vezc-m2t6.json",
+    "api_gekentekende_voertuigen_carrosserie_specifiek": "https://opendata.rdw.nl/resource/jhie-znh9.json",
+    "api_gekentekende_voertuigen_voertuigklasse": "https://opendata.rdw.nl/resource/kmfi-hrps.json"
+  }
+]

+ 27 - 0
src/__tests__/features/integrations/fixtures/rdw/SK209X-fuel.json

@@ -0,0 +1,27 @@
+[
+  {
+    "kenteken": "SK209X",
+    "brandstof_volgnummer": "1",
+    "brandstof_omschrijving": "Elektriciteit",
+    "emissiecode_omschrijving": "Z",
+    "nominaal_continu_maximumvermogen": "18.00",
+    "netto_max_vermogen_elektrisch": "60.00",
+    "klasse_hybride_elektrisch_voertuig": "NOVC-HEV",
+    "uitlaatemissieniveau": "EURO 6"
+  },
+  {
+    "kenteken": "SK209X",
+    "brandstof_volgnummer": "2",
+    "brandstof_omschrijving": "Benzine",
+    "brandstofverbruik_gecombineerd": "4.40",
+    "co2_uitstoot_gecombineerd": "101",
+    "geluidsniveau_rijdend": "68",
+    "geluidsniveau_stationair": "75",
+    "emissiecode_omschrijving": "6",
+    "milieuklasse_eg_goedkeuring_licht": "715/2007*2016/427W",
+    "nettomaximumvermogen": "73.00",
+    "toerental_geluidsniveau": "2500",
+    "klasse_hybride_elektrisch_voertuig": "NOVC-HEV",
+    "uitlaatemissieniveau": "EURO 6 W"
+  }
+]

+ 57 - 0
src/__tests__/features/integrations/fixtures/rdw/SK209X-main.json

@@ -0,0 +1,57 @@
+[
+  {
+    "kenteken": "SK209X",
+    "voertuigsoort": "Personenauto",
+    "merk": "TOYOTA",
+    "handelsbenaming": "TOYOTA PRIUS PLUS",
+    "vervaldatum_apk": "20270426",
+    "datum_tenaamstelling": "20211113",
+    "bruto_bpm": "2494",
+    "inrichting": "MPV",
+    "aantal_zitplaatsen": "7",
+    "eerste_kleur": "ZWART",
+    "tweede_kleur": "Niet geregistreerd",
+    "aantal_cilinders": "4",
+    "cilinderinhoud": "1798",
+    "massa_ledig_voertuig": "1475",
+    "toegestane_maximum_massa_voertuig": "2115",
+    "massa_rijklaar": "1575",
+    "datum_eerste_toelating": "20180426",
+    "datum_eerste_tenaamstelling_in_nederland": "20180426",
+    "wacht_op_keuren": "Geen verstrekking in Open Data",
+    "catalogusprijs": "36130",
+    "wam_verzekerd": "Ja",
+    "aantal_deuren": "4",
+    "aantal_wielen": "4",
+    "lengte": "465",
+    "breedte": "178",
+    "europese_voertuigcategorie": "M1",
+    "technische_max_massa_voertuig": "2115",
+    "type": "XW3(A)",
+    "typegoedkeuringsnummer": "e11*2001/116*0264*12",
+    "variant": "ZVW40(W)",
+    "uitvoering": "ZVW40L-AWXEBW(2N)",
+    "volgnummer_wijziging_eu_typegoedkeuring": "0",
+    "vermogen_massarijklaar": "0.05",
+    "wielbasis": "278",
+    "export_indicator": "Nee",
+    "openstaande_terugroepactie_indicator": "Nee",
+    "taxi_indicator": "Nee",
+    "maximum_massa_samenstelling": "2115",
+    "jaar_laatste_registratie_tellerstand": "2026",
+    "tellerstandoordeel": "Logisch",
+    "code_toelichting_tellerstandoordeel": "00",
+    "tenaamstellen_mogelijk": "Ja",
+    "vervaldatum_apk_dt": "2027-04-26T00:00:00.000",
+    "datum_tenaamstelling_dt": "2021-11-13T00:00:00.000",
+    "datum_eerste_toelating_dt": "2018-04-26T00:00:00.000",
+    "datum_eerste_tenaamstelling_in_nederland_dt": "2018-04-26T00:00:00.000",
+    "hoogte_voertuig": "158",
+    "zuinigheidsclassificatie": "A",
+    "api_gekentekende_voertuigen_assen": "https://opendata.rdw.nl/resource/3huj-srit.json",
+    "api_gekentekende_voertuigen_brandstof": "https://opendata.rdw.nl/resource/8ys7-d773.json",
+    "api_gekentekende_voertuigen_carrosserie": "https://opendata.rdw.nl/resource/vezc-m2t6.json",
+    "api_gekentekende_voertuigen_carrosserie_specifiek": "https://opendata.rdw.nl/resource/jhie-znh9.json",
+    "api_gekentekende_voertuigen_voertuigklasse": "https://opendata.rdw.nl/resource/kmfi-hrps.json"
+  }
+]

+ 18 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-ACT.json

@@ -0,0 +1,18 @@
+{
+  "Description": "Maroon FORD ED FALCON",
+  "CarMake": {
+    "CurrentTextValue": "FORD"
+  },
+  "CarModel": {
+    "CurrentTextValue": "ED FALCON"
+  },
+  "RegistrationYear": 1994,
+  "Colour": "Maroon",
+  "Expiry": "01/10/2026",
+  "EngineNumber": "3915",
+  "Stolen": "N",
+  "NetWeight": "1514",
+  "InsuranceCompany": "AAMI",
+  "Image": "http://www.carregistrationapi.com/image.aspx/@Rk9SRCBFRCBGQUxDT04=",
+  "State": "ACT"
+}

+ 43 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-NSW.json

@@ -0,0 +1,43 @@
+{
+  "Description": "FORD Fiesta 5D Hatchback LX",
+  "RegistrationYear": "2007",
+  "CarMake": {
+    "CurrentTextValue": "FORD"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "FORD"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "Fiesta 5D Hatchback"
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "Lx 5-Speed Manual"
+  },
+  "Colour": "",
+  "VechileIdentificationNumber": "FORFIELXWQ16FHS2007A",
+  "Engine": "1.6 litre, 4 cyl, WQ",
+  "extended": {
+    "nvic": "FHS07A",
+    "code": "FORFIELXWQ16FHS2007A",
+    "year": "2007",
+    "make": "FORD",
+    "model": "Fiesta 5D Hatchback",
+    "bodyType": "Lx 5-Speed Manual",
+    "engine": {
+      "capacity": {
+        "value": "1.6",
+        "unit": "L"
+      },
+      "cylinders": "4",
+      "description": "1.6 litre, 4 cyl, WQ"
+    },
+    "transmissionType": "Manual",
+    "driveType": "5D HATCHBACK",
+    "family": "FIESTA",
+    "variant": "LX",
+    "series": "WQ"
+  },
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@Rk9SRCBGaWVzdGEgNUQgSGF0Y2hiYWNr",
+  "Expiry": "",
+  "State": "NSW"
+}

+ 57 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-NSW.xml

@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Vehicle xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://regcheck.org.uk">
+  <vehicleJson>{
+  "Description": "FORD Fiesta 5D Hatchback LX",
+  "RegistrationYear": "2007",
+  "CarMake": {
+    "CurrentTextValue": "FORD"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "FORD"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "Fiesta 5D Hatchback"
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "Lx 5-Speed Manual"
+  },
+  "Colour": "",
+  "VechileIdentificationNumber": "FORFIELXWQ16FHS2007A",
+  "Engine": "1.6 litre, 4 cyl, WQ",
+  "extended": {
+    "nvic": "FHS07A",
+    "code": "FORFIELXWQ16FHS2007A",
+    "year": "2007",
+    "make": "FORD",
+    "model": "Fiesta 5D Hatchback",
+    "bodyType": "Lx 5-Speed Manual",
+    "engine": {
+      "capacity": {
+        "value": "1.6",
+        "unit": "L"
+      },
+      "cylinders": "4",
+      "description": "1.6 litre, 4 cyl, WQ"
+    },
+    "transmissionType": "Manual",
+    "driveType": "5D HATCHBACK",
+    "family": "FIESTA",
+    "variant": "LX",
+    "series": "WQ"
+  },
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@Rk9SRCBGaWVzdGEgNUQgSGF0Y2hiYWNr",
+  "Expiry": "",
+  "State": "NSW"
+}</vehicleJson>
+  <vehicleData>
+    <Description>FORD Fiesta 5D Hatchback LX</Description>
+    <RegistrationYear>2007</RegistrationYear>
+    <CarMake>
+      <CurrentTextValue>FORD</CurrentTextValue>
+    </CarMake>
+    <CarModel>Fiesta 5D Hatchback</CarModel>
+    <BodyStyle>
+      <CurrentTextValue>Lx 5-Speed Manual</CurrentTextValue>
+    </BodyStyle>
+  </vehicleData>
+</Vehicle>

+ 26 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-NT.json

@@ -0,0 +1,26 @@
+{
+  "Description": "BLACK TOYOTA FJCRUISER 2012",
+  "CarMake": {
+    "CurrentTextValue": "TOYOTA"
+  },
+  "CarModel": {
+    "CurrentTextValue": "FJCRUISER"
+  },
+  "RegistrationYear": 2012,
+  "Colour": "BLACK",
+  "RegistrationPlate": {
+    "can_register": true,
+    "plate": "CB78MH",
+    "can_inspect": true,
+    "insurance_class": "A",
+    "insurance_class_desc": "Vehicle not exceeding 4.5 tonne GVM or bus used for private or business.",
+    "date_inspection": 1493510400000,
+    "plate_type": "C",
+    "class_code": "",
+    "can_remind": true,
+    "status": "REGISTERED",
+    "date_expired": 1493510400000
+  },
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@VE9ZT1RBIEZKQ1JVSVNFUg==",
+  "State": "NT"
+}

+ 15 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-QLD.json

@@ -0,0 +1,15 @@
+{
+  "Description": "2011 HYUNDAI ACCENT HATCHBACK",
+  "CarMake": {
+    "CurrentTextValue": "HYUNDAI"
+  },
+  "CarModel": {
+    "CurrentTextValue": "ACCENT HATCHBACK"
+  },
+  "RegistrationYear": "2011",
+  "VechileIdentificationNumber": "KMHCT51DLCU021130",
+  "InsuranceExpiry": "01/11/2019",
+  "Purpose": "PRIVATE",
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@MjAxMSBIWVVOREFJIEFDQ0VOVCBIQVRDSEJBQ0s=",
+  "State": "QLD"
+}

+ 19 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-SA.json

@@ -0,0 +1,19 @@
+{
+  "Description": "WHITE MITSUBISHI STATION WAGON",
+  "CarMake": {
+    "CurrentTextValue": "MITSUBISHI"
+  },
+  "CarModel": {
+    "CurrentTextValue": ""
+  },
+  "RegistrationYear": "",
+  "BodyStyle": {
+    "CurrentTextValue": "STATION WAGON"
+  },
+  "Colour": "WHITE",
+  "Expiry": "05/12/2026",
+  "VechileIdentificationNumber": "",
+  "InsuranceCompany": "AAMI",
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@TUlUU1VCSVNISSA=",
+  "State": "SA"
+}

+ 27 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-TAS.json

@@ -0,0 +1,27 @@
+{
+  "Description": "VW AMAROK 2.0 BiTDI 4motion",
+  "RegistrationYear": "2011",
+  "CarMake": {
+    "CurrentTextValue": "VW"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "VW"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "AMAROK"
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "Ute"
+  },
+  "Fuel": "Diesel",
+  "Colour": "",
+  "VechileIdentificationNumber": "WV1ZZZ2HZEA044303",
+  "Engine": "1968",
+  "extended": null,
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@VlcgQU1BUk9L",
+  "Expiry": "",
+  "Cylinders": "4",
+  "EngineCode": "CNEA|CSHA",
+  "Drive": "AWD",
+  "State": "TAS"
+}

+ 26 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-VIC.json

@@ -0,0 +1,26 @@
+{
+  "Description": "TOYOTA Hiace",
+  "RegistrationYear": "2014",
+  "CarMake": {
+    "CurrentTextValue": "TOYOTA"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "TOYOTA"
+  },
+  "CarModel": {
+    "CurrentTextValue": "Hiace"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "Hiace"
+  },
+  "Colour": "WHITE",
+  "VechileIdentificationNumber": "JTFHT02P700145015",
+  "Engine": "1KD2418294",
+  "Stolen": "",
+  "GoodsCarryingVehicle": "Yes",
+  "RegistrationSerialNumber": "7168554",
+  "ComplianceDate": "07/2014",
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@VE9ZT1RBIEhpYWNl",
+  "Expiry": "08/08/2023",
+  "State": "VIC"
+}

+ 42 - 0
src/__tests__/features/integrations/fixtures/regcheck/AU-WA.json

@@ -0,0 +1,42 @@
+{
+  "Description": "MAZDA  Mazda6 4D Sedan CLASSIC",
+  "RegistrationYear": "2008",
+  "CarMake": {
+    "CurrentTextValue": "MAZDA"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "MAZDA"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "Mazda6 4D Sedan"
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "Classic 6-Speed Manual"
+  },
+  "Colour": "",
+  "VechileIdentificationNumber": "MAZ--6CLGH25HV92008B",
+  "Engine": "2.5 litre, 4 cyl, GH",
+  "extended": {
+    "nvic": "HV908B",
+    "driveType": "4D SEDAN",
+    "family": "MAZDA6",
+    "variant": "CLASSIC",
+    "model": "Mazda6 4D Sedan",
+    "colour": "",
+    "code": "MAZ--6CLGH25HV92008B",
+    "make": "MAZDA",
+    "fuelType": "",
+    "capacityValue": "2.5",
+    "series": "GH",
+    "engineDescription": "2.5 litre, 4 cyl, GH",
+    "bodyDescription": "",
+    "bodyType": "Classic 6-Speed Manual",
+    "cylinders": "4",
+    "year": "2008",
+    "capacityUnit": "L",
+    "transmissionType": "Manual"
+  },
+  "ImageUrl": "http://www.carregistrationapi.com/image.aspx/@TUFaREEgTWF6ZGE2IDREIFNlZGFu",
+  "Expiry": "16/12/2017",
+  "State": "WA"
+}

+ 26 - 0
src/__tests__/features/integrations/fixtures/regcheck/CZ.json

@@ -0,0 +1,26 @@
+{
+  "Description": "ŠKODA OCTAVIA",
+  "CarMake": {
+    "CurrentTextValue": "ŠKODA"
+  },
+  "CarModel": {
+    "CurrentTextValue": "OCTAVIA"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "ŠKODA"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "OCTAVIA"
+  },
+  "Date": "04/10/2005",
+  "EngineSize": "1896",
+  "RegistrationYear": 2005,
+  "ImageUrl": "http://www.spzapi.com/image.aspx/@xaBLT0RBIE9DVEFWSUE=",
+  "FuelType": "D",
+  "BodyStyle": "sedan/limuzína",
+  "VIN": "TMBCS21Z262149586",
+  "GrossWeight": "1970",
+  "NetWeight": "1 385 - 1 575",
+  "Power": "77",
+  "Region": "Hlavní město Praha"
+}

+ 122 - 0
src/__tests__/features/integrations/fixtures/regcheck/DK.json

@@ -0,0 +1,122 @@
+{
+  "ABICode": "",
+  "Description": "FERRARI 348 TS",
+  "RegistrationYear": "1993",
+  "CarMake": {
+    "CurrentTextValue": "FERRARI"
+  },
+  "CarModel": {
+    "CurrentTextValue": "348"
+  },
+  "EngineSize": {
+    "CurrentTextValue": "3405.0"
+  },
+  "FuelType": {
+    "CurrentTextValue": "Benzin"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "FERRARI"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "348"
+  },
+  "Immobiliser": {
+    "CurrentTextValue": ""
+  },
+  "NumberOfSeats": {
+    "CurrentTextValue": ""
+  },
+  "DriverSide": {
+    "CurrentTextValue": ""
+  },
+  "BodyStyle": {
+    "CurrentTextValue": ""
+  },
+  "VechileIdentificationNumber": "ZFFKA36B000096243",
+  "ImageUrl": "http://www.registreringsnummerapi.dk/image.aspx/@RkVSUkFSSSAzNDg=",
+  "ExtendedInformation": {
+    "@xmlns": "http://skat.dk/dmr/2007/05/31/",
+    "KoeretoejIdent": "1007801199310004",
+    "KoeretoejArtNummer": "1",
+    "KoeretoejArtNavn": "Personbil",
+    "KoeretoejAnvendelseStruktur": {
+      "KoeretoejAnvendelseNummer": "1",
+      "KoeretoejAnvendelseNavn": "Privat personkørsel"
+    },
+    "RegistreringNummerNummer": "ZU47209",
+    "KoeretoejOplysningGrundStruktur": {
+      "KoeretoejOplysningOprettetUdFra": "Registreringssyn",
+      "KoeretoejOplysningStatus": "Registreret",
+      "KoeretoejOplysningStatusDato": "2009-04-20T23:00:00+01:00",
+      "KoeretoejOplysningFoersteRegistreringDato": "1993-05-03+02:00",
+      "KoeretoejOplysningStelNummer": "ZFFKA36B000096243",
+      "KoeretoejOplysningTotalVaegt": "1680",
+      "KoeretoejOplysningEgenVaegt": "1440",
+      "KoeretoejOplysningKoereklarVaegtMinimum": "1565",
+      "KoeretoejOplysningTekniskTotalVaegt": "1680",
+      "KoeretoejOplysningAkselAntal": "2",
+      "KoeretoejOplysningSiddepladserMinimum": "2",
+      "KoeretoejOplysningTilkoblingMulighed": "false",
+      "KoeretoejOplysningKommentar": "DMR Konvertering",
+      "KoeretoejOplysningAntalGear": "5",
+      "KoeretoejBetegnelseStruktur": {
+        "KoeretoejMaerkeTypeNummer": "10078",
+        "KoeretoejMaerkeTypeNavn": "FERRARI",
+        "Model": {
+          "KoeretoejModelTypeNummer": "10078008",
+          "KoeretoejModelTypeNavn": "348"
+        },
+        "Variant": {
+          "KoeretoejVariantTypeNummer": "1007800851",
+          "KoeretoejVariantTypeNavn": "TS"
+        },
+        "Type": {
+          "KoeretoejTypeTypeNummer": "10078008510000000",
+          "KoeretoejTypeTypeNavn": "F119AS"
+        }
+      },
+      "KoeretoejFarveStruktur": {
+        "FarveTypeStruktur": {
+          "FarveTypeNummer": "1",
+          "FarveTypeNavn": "Ukendt"
+        }
+      },
+      "KoeretoejNormStruktur": {
+        "NormTypeStruktur": {
+          "NormTypeNummer": "1",
+          "NormTypeNavn": "Ingen Norm"
+        }
+      },
+      "KoeretoejMiljoeOplysningStruktur": {
+        "KoeretoejMiljoeOplysningPartikelFilter": "false"
+      },
+      "KoeretoejMotorStruktur": {
+        "KoeretoejMotorCylinderAntal": "8",
+        "KoeretoejMotorSlagVolumen": "3405.0",
+        "KoeretoejMotorStoersteEffekt": "217.0",
+        "KoeretoejMotorKilometerstand": "122",
+        "KoeretoejDrivmiddelSamlingStruktur": {
+          "KoeretoejDrivmiddelSamling": {
+            "DrivmiddelStruktur": {
+              "DrivkraftTypeStruktur": {
+                "DrivkraftTypeNummer": "1",
+                "DrivkraftTypeNavn": "Benzin"
+              },
+              "KoeretoejMotorDrivmiddelPrimaer": "true"
+            }
+          }
+        }
+      }
+    },
+    "SynResultatStruktur": {
+      "SynResultatSynsType": "PeriodiskSyn",
+      "SynResultatSynsDato": "2025-04-10+02:00",
+      "SynResultatSynsResultat": "Godkendt",
+      "SynResultatSynStatus": "Aktiv",
+      "SynResultatSynStatusDato": "2025-04-10+02:00",
+      "KoeretoejMotorKilometerstand": "122"
+    },
+    "KoeretoejRegistreringStatus": "Registreret",
+    "KoeretoejRegistreringStatusDato": "2009-04-20T23:00:00+01:00"
+  }
+}

+ 23 - 0
src/__tests__/features/integrations/fixtures/regcheck/EE.json

@@ -0,0 +1,23 @@
+{
+  "Description": "FORD GALAXY",
+  "RegistrationYear": "2011",
+  "CarMake": {
+    "CurrentTextValue": "FORD"
+  },
+  "CarModel": {
+    "CurrentTextValue": "GALAXY"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "FORD"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "GALAXY"
+  },
+  "NumberOfSeats": {
+    "CurrentTextValue": ""
+  },
+  "Power": "103",
+  "GrossWeight": "2400",
+  "VIN": "WF0MXXGBWMAG34779",
+  "ImageUrl": "http://www.sõiduk.com/image.aspx/@Rk9SRCBHQUxBWFk="
+}

+ 39 - 0
src/__tests__/features/integrations/fixtures/regcheck/FI.json

@@ -0,0 +1,39 @@
+{
+  "Description": "Volkswagen GOLF",
+  "RegistrationYear": "2010",
+  "CarMake": {
+    "CurrentTextValue": "Volkswagen"
+  },
+  "CarModel": {
+    "CurrentTextValue": "GOLF"
+  },
+  "EngineSize": {
+    "CurrentTextValue": "1598"
+  },
+  "FuelType": {
+    "CurrentTextValue": "Diesel"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "Volkswagen"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "GOLF"
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "Car"
+  },
+  "InsuranceCompany": "LähiTapiola",
+  "VechileIdentificationNumber": "WVWZZZ1KZBW072586",
+  "TowBar": "True",
+  "RegistrationDate": "21/09/2010",
+  "Axles": "",
+  "Power": "77",
+  "Turbo": "No",
+  "NetWeight": "1340",
+  "EngineCode": "CAYC",
+  "Transmission": "Automaattinen",
+  "BodyType": "Kombi-sedan",
+  "ModelYear": "2011",
+  "Variant": "VW GOLF VI 09-13",
+  "ImageUrl": "http://www.rekisterinumeroapi.com/image.aspx/@Vm9sa3N3YWdlbiBHT0xG"
+}

+ 21 - 0
src/__tests__/features/integrations/fixtures/regcheck/HR.json

@@ -0,0 +1,21 @@
+{
+  "Description": "BMW  SERIJA 3, 316I",
+  "CarMake": {
+    "CurrentTextValue": "BMW"
+  },
+  "CarModel": {
+    "CurrentTextValue": " SERIJA 3, 316I"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "BMW"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": " SERIJA 3, 316I"
+  },
+  "VechileIdentificationNumber": "WBAAY31080KP02047",
+  "InsuranceCompany": "SAVA OSIGURANJE D.D. PodruĹžnica Hrvatska(VELEBIT OSIGURANJE D.D. --> SAVA OSIG.)",
+  "InsuranceCompanyUrl": "http://www.sava-osiguranje.hr",
+  "InsuranceCompanyNumber": "370222400",
+  "City": "Rijeka",
+  "ImageUrl": "http://www.provjeraregistracije.com/image.aspx/@Qk1XICBTRVJJSkEgMywgMzE2SQ=="
+}

+ 29 - 0
src/__tests__/features/integrations/fixtures/regcheck/HU.json

@@ -0,0 +1,29 @@
+{
+  "Description": "SUZUKI GS500",
+  "CarMake": {
+    "CurrentTextValue": "SUZUKI"
+  },
+  "CarModel": {
+    "CurrentTextValue": "GS500"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "SUZUKI"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "GS500"
+  },
+  "EngineSize": {
+    "CurrentTextValue": "487"
+  },
+  "FuelType": {
+    "CurrentTextValue": "benzin"
+  },
+  "RegistrationYear": 2006,
+  "ProductionYear": "2006",
+  "Power": "35",
+  "GrossWeight": "380",
+  "NetWeight": "380",
+  "RegistrationDate": "2006.11.23",
+  "VechileIdentificationNumber": "VTTBK232100104138",
+  "ImageUrl": "http://rendszám.com/image.aspx/@U1VaVUtJIEdTNTAw"
+}

+ 39 - 0
src/__tests__/features/integrations/fixtures/regcheck/IE.json

@@ -0,0 +1,39 @@
+{
+  "ABICode": "",
+  "Description": "AUDI A6 1.9 TDI SE 130BHP 4DR A",
+  "RegistrationYear": "2004",
+  "CarMake": {
+    "CurrentTextValue": "AUDI"
+  },
+  "CarModel": {
+    "CurrentTextValue": "A6"
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "SALOON"
+  },
+  "Transmission": {
+    "CurrentTextValue": ""
+  },
+  "FuelType": {
+    "CurrentTextValue": "DIESEL"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "AUDI"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "A6"
+  },
+  "NumberOfSeats": {
+    "CurrentTextValue": "0"
+  },
+  "NumberOfDoors": {
+    "CurrentTextValue": "4"
+  },
+  "EngineSize": {
+    "CurrentTextValue": "1896"
+  },
+  "VIN": "WAUZZZ4BX4N093080",
+  "Engine": "AVF596706",
+  "ImageUrl": "http://www.carregistrationapi.ie/image.aspx/@QVVESSBBNg==",
+  "County": "Meath"
+}

+ 80 - 0
src/__tests__/features/integrations/fixtures/regcheck/NO.json

@@ -0,0 +1,80 @@
+{
+  "Description": "CAPRON T 68/T 447/T 742",
+  "RegistrationYear": 2014,
+  "CarMake": {
+    "CurrentTextValue": "CAPRON"
+  },
+  "CarModel": {
+    "CurrentTextValue": "T 68/T 447/T 742"
+  },
+  "EngineSize": {
+    "CurrentTextValue": "2287"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "CAPRON"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "T 68/T 447/T 742"
+  },
+  "IndicativeValue": {
+    "CurrentTextValue": "0"
+  },
+  "Power": 96,
+  "Region": "Alta\tFinnmark",
+  "ExtendedInformation": {
+    "kjm": "ZT49510",
+    "unr": "ZFA25000002498708",
+    "reg-aar": "2014",
+    "f-g-n": "20140819",
+    "merke": "4140",
+    "modell": "T 68/T 447/T 742",
+    "type": "EG/CF002",
+    "kjtgrp": "316",
+    "motorytelse": "0009600",
+    "slagvolum": "002287",
+    "drivst": "02",
+    "lengde": "0743",
+    "bredde": "233",
+    "dekk-f": "215/70 R 15C",
+    "dekk-b": "215/70 R 15C",
+    "felg-f": "6JX15H2",
+    "felg-b": "6JX15H2",
+    "mili-f": "105",
+    "mili-b": "108",
+    "hast-f": "N",
+    "hast-b": "",
+    "innp-f": "0068",
+    "innp-b": "0068",
+    "spor-f": "1810",
+    "spor-b": "1980",
+    "totvekt": "003495",
+    "egenvekt": "02855",
+    "for-last": "01850",
+    "bak-last": "002000",
+    "eu-variant": "FTS8FE",
+    "eu-versjon": "M4GI0G04CAE017",
+    "bruktimp": "0",
+    "avreg-dato": "00000000",
+    "farge": "HVIT",
+    "aksler": "02",
+    "aksler-drift": "1",
+    "thv-m-brems": "02000",
+    "thv-u-brems": "00750",
+    "tilh-kobling": "0000",
+    "bel-h-feste": "00100",
+    "maks-taklast": "0000",
+    "vogntogvekt": "005495",
+    "ytelsesmaal": "1",
+    "siste-pkk": "00000000",
+    "neste-pkk": "20181031",
+    "reg-dato": "20160406",
+    "reg-mnd": "08",
+    "reg-dag": "19",
+    "co2-utslipp": "0000",
+    "euronorm": "5",
+    "sitteplasser-totalt": "04",
+    "registration": "ZT49510",
+    "Kode": "4140",
+    "Name": "CAPRON"
+  }
+}

+ 44 - 0
src/__tests__/features/integrations/fixtures/regcheck/NZ.json

@@ -0,0 +1,44 @@
+{
+  "Description": "2008 Toyota Mark X",
+  "RegistrationYear": "2008",
+  "CarMake": {
+    "CurrentTextValue": "Toyota"
+  },
+  "CarModel": {
+    "CurrentTextValue": "Mark X"
+  },
+  "EngineSize": {
+    "CurrentTextValue": 2499
+  },
+  "FuelType": {
+    "CurrentTextValue": "Petrol"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "Toyota"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "Mark X"
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "SEDAN"
+  },
+  "VechileIdentificationNumber": "7AT0H64TX13059294",
+  "Chassis": "NZVTOYO2008AEIQ",
+  "EngineNumber": "",
+  "Colour": "",
+  "NumberOfSeats": {
+    "CurrentTextValue": "0"
+  },
+  "Assembly": "",
+  "Stolen": "N",
+  "EstimatedCurrentOdometer": "0",
+  "LastOdometerReading": "",
+  "WarrantOfFitness": "",
+  "PlateType": "",
+  "Origin": "",
+  "VehiclePurpose": "",
+  "ReasonLatestRegistration": "",
+  "FirstLocalRegistration": "",
+  "Imported": "",
+  "ImageUrl": "http://nz.carregistrationapi.com/image.aspx/@VG95b3RhIE1hcmsgWA=="
+}

+ 41 - 0
src/__tests__/features/integrations/fixtures/regcheck/SE.json

@@ -0,0 +1,41 @@
+{
+  "ABICode": "",
+  "Description": "Ford Focus 1.8",
+  "RegistrationYear": "2006",
+  "CarMake": {
+    "CurrentTextValue": "Ford"
+  },
+  "CarModel": {
+    "CurrentTextValue": "Focus 1.8"
+  },
+  "EngineSize": {
+    "CurrentTextValue": "1798 cm"
+  },
+  "FuelType": {
+    "CurrentTextValue": "Bensin"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "Ford"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "Focus 1.8"
+  },
+  "Immobiliser": {
+    "CurrentTextValue": ""
+  },
+  "NumberOfSeats": {
+    "CurrentTextValue": "5"
+  },
+  "DriverSide": {
+    "CurrentTextValue": ""
+  },
+  "BodyStyle": {
+    "CurrentTextValue": "Halvkombi"
+  },
+  "Colour": "Grön",
+  "RegistrationDate": "2006",
+  "VechileIdentificationNumber": "WF05XXGCD56B24845",
+  "Transmission": "Manuell",
+  "Co2": "167 g/km",
+  "ImageUrl": "http://www.registreringsnummerapi.se/image.aspx/@Rm9yZCBGb2N1cyAxLjg="
+}

+ 24 - 0
src/__tests__/features/integrations/fixtures/regcheck/SK.json

@@ -0,0 +1,24 @@
+{
+  "Description": "ŠKODA OCTAVIA",
+  "CarMake": {
+    "CurrentTextValue": "ŠKODA"
+  },
+  "CarModel": {
+    "CurrentTextValue": "OCTAVIA"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "ŠKODA"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "OCTAVIA"
+  },
+  "EngineSize": {
+    "CurrentTextValue": "1595"
+  },
+  "RegistrationYear": 2010,
+  "Power": "74",
+  "Colour": "MODRÁ",
+  "VechileIdentificationNumber": "TMBCK11U4Y2370804",
+  "Region": "Žilina",
+  "ImageUrl": "http://ečv.com/image.aspx/@xaBLT0RBIE9DVEFWSUE="
+}

+ 21 - 0
src/__tests__/features/integrations/fixtures/regcheck/UK-live.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Vehicle xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://regcheck.org.uk">
+<vehicleJson>{ "Description": "PEUGEOT 307 X-LINE", "RegistrationYear": "2007", "CarMake": { "CurrentTextValue": "PEUGEOT" }, "CarModel": { "CurrentTextValue": "307 X-LINE" }, "MakeDescription": { "CurrentTextValue": "PEUGEOT" }, "ModelDescription": { "CurrentTextValue": "307 X-LINE" }, "EngineSize": { "CurrentTextValue": "1360" }, "BodyStyle": { "CurrentTextValue": "Motorbike" }, "FuelType": { "CurrentTextValue": "PETROL" }, "Variant": "", "Colour": "SILVER", "VehicleIdentificationNumber": "VF33CKFUC84922414", "KType": "", "EngineNumber": "FE040407358", "ImageUrl": "https://www.regcheck.org.uk/image.aspx/@UEVVR0VPVCAzMDcgWC1MSU5FfG1vdG9yY3ljbGU=" }</vehicleJson>
+<vehicleData>
+<Description>PEUGEOT 307 X-LINE</Description>
+<RegistrationYear>2007</RegistrationYear>
+<CarMake>
+<CurrentTextValue>PEUGEOT</CurrentTextValue>
+</CarMake>
+<CarModel>307 X-LINE</CarModel>
+<BodyStyle>
+<CurrentTextValue>Motorbike</CurrentTextValue>
+</BodyStyle>
+<EngineSize>
+<CurrentTextValue>1360</CurrentTextValue>
+</EngineSize>
+<FuelType>
+<CurrentTextValue>PETROL</CurrentTextValue>
+</FuelType>
+</vehicleData>
+</Vehicle>

+ 25 - 0
src/__tests__/features/integrations/fixtures/regcheck/US.json

@@ -0,0 +1,25 @@
+{
+  "Description": "2009 Kia Rio Base / LX / SX",
+  "BodyStyle": {
+    "CurrentTextValue": "Sedan 4D"
+  },
+  "VechileIdentificationNumber": "KNADE223696445551",
+  "Assembly": "South Korea",
+  "EngineSize": {
+    "CurrentTextValue": "1.6L I4 MPI"
+  },
+  "RegistrationYear": "2009",
+  "CarMake": {
+    "CurrentTextValue": "Kia"
+  },
+  "CarModel": {
+    "CurrentTextValue": "Rio Base / LX / SX"
+  },
+  "MakeDescription": {
+    "CurrentTextValue": "Kia"
+  },
+  "ModelDescription": {
+    "CurrentTextValue": "Rio Base / LX / SX"
+  },
+  "ImageUrl": "http://vehicleregistrationapi.com/image.aspx/@S2lhIFJpbyBCYXNlIC8gTFggLyBTWA=="
+}

+ 7 - 5
src/__tests__/features/integrations/openapi-automotive.test.ts

@@ -1,6 +1,7 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
+import { makeCase } from '@/features/integrations/Lib/make-case'
 import type { ConnectorContext } from '@/features/integrations/Lib/types'
 import {
   type AutomotiveRecord,
@@ -10,7 +11,6 @@ import {
   isoDate,
   mapRecord,
   normalisePlate,
-  titleCase,
   transmission,
 } from '@/integrations/openapi-automotive/server'
 
@@ -224,10 +224,12 @@ describe('openapi automotive mapper', () => {
   })
 
   it('cases makes the way they are written, initials included', () => {
-    expect(titleCase('PEUGEOT')).toBe('Peugeot')
-    expect(titleCase('alfa romeo')).toBe('Alfa Romeo')
-    expect(titleCase('BMW')).toBe('BMW')
-    expect(titleCase('MERCEDES-BENZ')).toBe('Mercedes-Benz')
+    expect(makeCase('PEUGEOT')).toBe('Peugeot')
+    expect(makeCase('alfa romeo')).toBe('Alfa Romeo')
+    expect(makeCase('BMW')).toBe('BMW')
+    expect(makeCase('MERCEDES-BENZ')).toBe('Mercedes-Benz')
+    expect(makeCase('Kia')).toBe('Kia')
+    expect(makeCase('Land Rover')).toBe('Land Rover')
   })
 
   it('normalises plates the way each country prints them', () => {

+ 214 - 0
src/__tests__/features/integrations/rdw.test.ts

@@ -0,0 +1,214 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import type { ConnectorContext } from '@/features/integrations/Lib/types'
+import {
+  type RdwFuel,
+  type RdwVehicle,
+  connector,
+  fuelType,
+  isoDate,
+  mapVehicle,
+  normalisePlate,
+} from '@/integrations/rdw/server'
+
+/**
+ * Rows recorded from opendata.rdw.nl on 3 September 2026: a Toyota Prius+
+ * (petrol and electric, NOVC-HEV) and a Volkswagen Polo (petrol). The
+ * vocabulary the mapper reads (fuel names, hybrid classes, export flags,
+ * the colours that mean "none") was taken from the datasets' own value
+ * counts the same day, not assumed.
+ */
+const FIXTURES = path.join(__dirname, 'fixtures/rdw')
+function fixture<T>(name: string): T[] {
+  return JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8')) as T[]
+}
+const PRIUS = fixture<RdwVehicle>('SK209X-main')[0]
+const PRIUS_FUELS = fixture<RdwFuel>('SK209X-fuel')
+const POLO = fixture<RdwVehicle>('G317PG-main')[0]
+const POLO_FUELS = fixture<RdwFuel>('G317PG-fuel')
+
+function context(
+  fetchImpl: (url: string, init?: RequestInit) => Promise<Response>,
+  credentials: Record<string, unknown> = {}
+) {
+  const log = vi.fn(async () => undefined)
+  const ctx = {
+    connection: {
+      id: 'c1',
+      organizationId: 'org',
+      connectorId: 'rdw',
+      settings: {},
+      state: {},
+      externalAccountId: null,
+    },
+    credentials,
+    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/Amsterdam',
+    appUrl: 'https://app.test',
+  } as unknown as ConnectorContext
+  return { ctx, log, fetch: ctx.http.fetch as ReturnType<typeof vi.fn> }
+}
+
+/** Answers the vehicle and fuel datasets from the fixtures, by plate in the query string. */
+function datasets(url: string): Promise<Response> {
+  const u = new URL(url)
+  const plate = u.searchParams.get('kenteken')
+  const fuel = u.pathname.includes('8ys7-d773')
+  if (plate === 'SK209X') return Promise.resolve(Response.json(fuel ? PRIUS_FUELS : [PRIUS]))
+  if (plate === 'G317PG') return Promise.resolve(Response.json(fuel ? POLO_FUELS : [POLO]))
+  return Promise.resolve(Response.json([]))
+}
+
+describe('rdw mapper', () => {
+  it('reads a hybrid from its electric and petrol rows, with the APK date', () => {
+    expect(mapVehicle(PRIUS, PRIUS_FUELS)).toEqual({
+      make: 'Toyota',
+      model: 'PRIUS PLUS',
+      year: 2018,
+      licensePlate: 'SK209X',
+      color: 'Zwart',
+      fuelType: 'hybrid',
+      engineSize: '1.8 L',
+      vehicleClass: 'Personenauto',
+      firstRegistered: '2018-04-26',
+      inspectionDue: '2027-04-26',
+      weights: { kerb: 1475, grossMax: 2115 },
+      registered: true,
+    })
+  })
+
+  it('reads a petrol car and keeps the trade name when it does not repeat the make', () => {
+    expect(mapVehicle(POLO, POLO_FUELS)).toEqual({
+      make: 'Volkswagen',
+      model: 'POLO',
+      year: 2010,
+      licensePlate: 'G317PG',
+      color: 'Zwart',
+      fuelType: 'gasoline',
+      engineSize: '1.2 L',
+      vehicleClass: 'Personenauto',
+      firstRegistered: '2010-05-06',
+      inspectionDue: '2027-09-30',
+      weights: { kerb: 988, grossMax: 1570 },
+      registered: true,
+    })
+  })
+
+  it('names every fuel the dataset uses', () => {
+    const rows = (...names: string[]) => names.map((n) => ({ brandstof_omschrijving: n }))
+    expect(fuelType(rows('Benzine'))).toBe('gasoline')
+    expect(fuelType(rows('Alcohol'))).toBe('gasoline')
+    expect(fuelType(rows('Diesel'))).toBe('diesel')
+    expect(fuelType(rows('Elektriciteit'))).toBe('electric')
+    expect(fuelType(rows('Elektriciteit', 'Benzine'))).toBe('hybrid')
+    expect(fuelType(rows('Diesel', 'Elektriciteit'))).toBe('hybrid')
+    expect(fuelType(rows('Benzine', 'LPG'))).toBe('gasoline')
+    expect(fuelType(rows('LPG'))).toBe('other')
+    expect(fuelType(rows('CNG'))).toBe('other')
+    expect(fuelType(rows('LNG'))).toBe('other')
+    // A fuel-cell car runs on hydrogen; the form has no word for it.
+    expect(
+      fuelType([
+        { brandstof_omschrijving: 'Waterstof', klasse_hybride_elektrisch_voertuig: 'NOVC-FCHV' },
+        {
+          brandstof_omschrijving: 'Elektriciteit',
+          klasse_hybride_elektrisch_voertuig: 'NOVC-FCHV',
+        },
+      ])
+    ).toBe('other')
+    // A petrol car in a HEV class is a hybrid even when only one fuel row is present.
+    expect(
+      fuelType([
+        { brandstof_omschrijving: 'Benzine', klasse_hybride_elektrisch_voertuig: 'OVC-HEV' },
+      ])
+    ).toBe('hybrid')
+    expect(fuelType([])).toBeUndefined()
+  })
+
+  it('leaves out colours RDW records for "none", and an exported vehicle is not registered', () => {
+    for (const colour of ['N.v.t.', 'Niet geregistreerd', 'DIVERSEN']) {
+      expect(mapVehicle({ eerste_kleur: colour }, []).color).toBeUndefined()
+    }
+    expect(mapVehicle({ eerste_kleur: 'GRIJS' }, []).color).toBe('Grijs')
+    expect(mapVehicle({ export_indicator: 'Ja' }, []).registered).toBe(false)
+    expect(mapVehicle({ export_indicator: 'Nee' }, []).registered).toBe(true)
+    expect(mapVehicle({}, []).registered).toBeUndefined()
+  })
+
+  it('leaves out what the register did not say, and reads RDW dates', () => {
+    expect(mapVehicle({}, [])).toEqual({})
+    expect(mapVehicle({ cilinderinhoud: '0' }, []).engineSize).toBeUndefined()
+    expect(isoDate('20270426')).toBe('2027-04-26')
+    expect(isoDate('2027-04-26')).toBeUndefined()
+    expect(isoDate(undefined)).toBeUndefined()
+  })
+
+  it('normalises plates the way they are printed', () => {
+    expect(normalisePlate('SK-209-X')).toBe('SK209X')
+    expect(normalisePlate('sk 209 x')).toBe('SK209X')
+    expect(normalisePlate(' g-317-pg ')).toBe('G317PG')
+  })
+})
+
+describe('rdw connector', () => {
+  it('asks the vehicle dataset then the fuel dataset by plate, without a token', async () => {
+    const { ctx, fetch, log } = context(datasets)
+    const result = await connector.lookupVehicle?.(ctx, { plate: 'sk-209-x' })
+    expect(result?.fuelType).toBe('hybrid')
+    expect(result?.inspectionDue).toBe('2027-04-26')
+    expect(fetch.mock.calls.map((c) => c[0])).toEqual([
+      'https://opendata.rdw.nl/resource/m9d7-ebf2.json?kenteken=SK209X',
+      'https://opendata.rdw.nl/resource/8ys7-d773.json?kenteken=SK209X',
+    ])
+    expect((fetch.mock.calls[0][1] as RequestInit).headers).not.toHaveProperty('X-App-Token')
+    // The plate is personal data and must not land in the connection log.
+    for (const call of log.mock.calls) expect(JSON.stringify(call)).not.toContain('SK209X')
+  })
+
+  it('sends the app token when the workshop has one', async () => {
+    const { ctx, fetch } = context(datasets, { appToken: 'abc123' })
+    await connector.lookupVehicle?.(ctx, { plate: 'G317PG' })
+    expect((fetch.mock.calls[0][1] as RequestInit).headers).toMatchObject({
+      'X-App-Token': 'abc123',
+    })
+  })
+
+  it('returns null on an empty answer and spends no second request on it', async () => {
+    const { ctx, fetch } = context(datasets)
+    expect(await connector.lookupVehicle?.(ctx, { plate: 'XX-000-X' })).toBeNull()
+    expect(fetch).toHaveBeenCalledTimes(1)
+  })
+
+  it('finds nothing for a VIN alone, since the open data has none', async () => {
+    const { ctx, fetch } = context(datasets)
+    expect(await connector.lookupVehicle?.(ctx, { vin: 'JTDZN3EU0E3298500' })).toBeNull()
+    expect(fetch).not.toHaveBeenCalled()
+  })
+
+  it('explains a rejected token and a throttle', async () => {
+    const denied = context(async () => new Response('Invalid app_token', { status: 403 }))
+    await expect(connector.lookupVehicle?.(denied.ctx, { plate: 'SK209X' })).rejects.toThrow(
+      /rejected the app token/
+    )
+    const throttled = context(async () => new Response('', { status: 429 }))
+    await expect(connector.lookupVehicle?.(throttled.ctx, { plate: 'SK209X' })).rejects.toThrow(
+      /throttling/
+    )
+  })
+
+  it('tests with one public row, and fails only on a bad token', async () => {
+    const good = context(async () => Response.json([POLO]), { appToken: 'abc123' })
+    expect(await connector.test(good.ctx)).toEqual({ ok: true })
+    expect(good.fetch.mock.calls[0][0]).toBe(
+      'https://opendata.rdw.nl/resource/m9d7-ebf2.json?$limit=1'
+    )
+    const bad = context(async () => new Response('', { status: 403 }), { appToken: 'nope' })
+    expect((await connector.test(bad.ctx)).ok).toBe(false)
+    const anonymous = context(async () => Response.json([POLO]))
+    expect(await connector.test(anonymous.ctx)).toEqual({ ok: true })
+  })
+})

+ 409 - 0
src/__tests__/features/integrations/regcheck.test.ts

@@ -0,0 +1,409 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import type { ConnectorContext } from '@/features/integrations/Lib/types'
+import {
+  type RegCheckVehicle,
+  connector,
+  engineSize,
+  fuelType,
+  isoDate,
+  mapVehicle,
+  normalisePlate,
+  vehicleJsonOf,
+} from '@/integrations/regcheck/server'
+
+/**
+ * Answers recorded from the live service on 3 September 2026 using the
+ * vendor's free test plate for every country and Australian state offered,
+ * plus one envelope from a real UK lookup. Each country fills a different
+ * subset of a shared shape, wraps some values in CurrentTextValue and not
+ * others, and the documentation lags the wire in places (the VIN spelling,
+ * New South Wales' nested engine capacity). These are the shapes the mapper
+ * has to keep reading.
+ */
+const FIXTURES = path.join(__dirname, 'fixtures/regcheck')
+function fixture(name: string): RegCheckVehicle {
+  return JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8'))
+}
+function envelope(name: string): string {
+  return fs.readFileSync(path.join(FIXTURES, `${name}.xml`), 'utf-8')
+}
+
+function context(
+  fetchImpl: (url: string, init?: RequestInit) => Promise<Response>,
+  settings: Record<string, unknown> = { country: 'IE' },
+  credentials: Record<string, unknown> = { username: 'workshop' }
+) {
+  const log = vi.fn(async () => undefined)
+  const ctx = {
+    connection: {
+      id: 'c1',
+      organizationId: 'org',
+      connectorId: 'regcheck',
+      settings,
+      state: {},
+      externalAccountId: null,
+    },
+    credentials,
+    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/Dublin',
+    appUrl: 'https://app.test',
+  } as unknown as ConnectorContext
+  return { ctx, log, fetch: ctx.http.fetch as ReturnType<typeof vi.fn> }
+}
+
+describe('regcheck mapper, Australia state by state', () => {
+  it('NSW: engine capacity nested under extended, NEVDIS code is not a VIN', () => {
+    expect(mapVehicle(fixture('AU-NSW'), 'BEW76P')).toEqual({
+      make: 'Ford',
+      model: 'Fiesta 5D Hatchback',
+      year: 2007,
+      licensePlate: 'BEW76P',
+      transmission: 'manual',
+      engineSize: '1.6 L',
+      vehicleClass: 'Lx 5-Speed Manual',
+    })
+  })
+
+  it('WA: the flat capacity the documentation shows', () => {
+    expect(mapVehicle(fixture('AU-WA'))).toMatchObject({
+      make: 'Mazda',
+      model: 'Mazda6 4D Sedan',
+      year: 2008,
+      transmission: 'manual',
+      engineSize: '2.5 L',
+    })
+    expect(mapVehicle(fixture('AU-WA')).vin).toBeUndefined()
+  })
+
+  it('TAS: fuel bare, capacity in the Engine field, engine code', () => {
+    expect(mapVehicle(fixture('AU-TAS'))).toEqual({
+      make: 'VW',
+      model: 'AMAROK',
+      year: 2011,
+      vin: 'WV1ZZZ2HZEA044303',
+      fuelType: 'diesel',
+      engineSize: '2.0 L',
+      engineCode: 'CNEA|CSHA',
+      vehicleClass: 'Ute',
+    })
+  })
+
+  it('VIC and QLD: VIN and little else, an engine number is not a capacity', () => {
+    expect(mapVehicle(fixture('AU-VIC'))).toEqual({
+      make: 'Toyota',
+      model: 'Hiace',
+      year: 2014,
+      vin: 'JTFHT02P700145015',
+      color: 'White',
+    })
+    expect(mapVehicle(fixture('AU-QLD'))).toEqual({
+      make: 'Hyundai',
+      model: 'ACCENT HATCHBACK',
+      year: 2011,
+      vin: 'KMHCT51DLCU021130',
+    })
+  })
+
+  it('SA and ACT: empty strings are left out, a net weight is a kerb weight', () => {
+    expect(mapVehicle(fixture('AU-SA'))).toEqual({
+      make: 'Mitsubishi',
+      color: 'White',
+      vehicleClass: 'STATION WAGON',
+    })
+    expect(mapVehicle(fixture('AU-ACT'))).toEqual({
+      make: 'Ford',
+      model: 'ED FALCON',
+      year: 1994,
+      color: 'Maroon',
+      weights: { kerb: 1514, grossMax: undefined },
+    })
+  })
+
+  it('NT: the inspection date comes as epoch milliseconds', () => {
+    expect(mapVehicle(fixture('AU-NT'))).toEqual({
+      make: 'Toyota',
+      model: 'FJCRUISER',
+      year: 2012,
+      color: 'Black',
+      inspectionDue: '2017-04-30',
+    })
+  })
+})
+
+describe('regcheck mapper, other countries', () => {
+  it('New Zealand: engine size as a number', () => {
+    expect(mapVehicle(fixture('NZ'))).toEqual({
+      make: 'Toyota',
+      model: 'Mark X',
+      year: 2008,
+      vin: '7AT0H64TX13059294',
+      fuelType: 'gasoline',
+      engineSize: '2.5 L',
+      vehicleClass: 'SEDAN',
+    })
+  })
+
+  it('United States: engine described in litres', () => {
+    expect(mapVehicle(fixture('US'))).toEqual({
+      make: 'Kia',
+      model: 'Rio Base / LX / SX',
+      year: 2009,
+      vin: 'KNADE223696445551',
+      engineSize: '1.6 L',
+      vehicleClass: 'Sedan 4D',
+    })
+  })
+
+  it('Ireland: VIN under VIN, an empty gearbox is no gearbox', () => {
+    expect(mapVehicle(fixture('IE'))).toEqual({
+      make: 'Audi',
+      model: 'A6',
+      year: 2004,
+      vin: 'WAUZZZ4BX4N093080',
+      fuelType: 'diesel',
+      engineSize: '1.9 L',
+      vehicleClass: 'SALOON',
+    })
+  })
+
+  it('Sweden: "1798 cm", Bensin, Manuell, and a bare year that is not a date', () => {
+    expect(mapVehicle(fixture('SE'))).toEqual({
+      make: 'Ford',
+      model: 'Focus 1.8',
+      year: 2006,
+      vin: 'WF05XXGCD56B24845',
+      color: 'Grön',
+      fuelType: 'gasoline',
+      transmission: 'manual',
+      engineSize: '1.8 L',
+      vehicleClass: 'Halvkombi',
+    })
+  })
+
+  it('Denmark: registration, weights and last inspection from the Motorregister block', () => {
+    expect(mapVehicle(fixture('DK'))).toEqual({
+      make: 'Ferrari',
+      model: '348',
+      year: 1993,
+      vin: 'ZFFKA36B000096243',
+      fuelType: 'gasoline',
+      engineSize: '3.4 L',
+      firstRegistered: '1993-05-03',
+      lastInspected: '2025-04-10',
+      weights: { kerb: 1440, grossMax: 1680 },
+    })
+  })
+
+  it('Finland: Automaattinen, engine code, day-first registration date', () => {
+    expect(mapVehicle(fixture('FI'))).toEqual({
+      make: 'Volkswagen',
+      model: 'GOLF',
+      year: 2010,
+      vin: 'WVWZZZ1KZBW072586',
+      fuelType: 'diesel',
+      transmission: 'automatic',
+      engineSize: '1.6 L',
+      engineCode: 'CAYC',
+      vehicleClass: 'Car',
+      firstRegistered: '2010-09-21',
+      weights: { kerb: 1340, grossMax: undefined },
+    })
+  })
+
+  it('Estonia, Slovakia, Croatia: make, model, VIN and not much more', () => {
+    expect(mapVehicle(fixture('EE'))).toEqual({
+      make: 'Ford',
+      model: 'GALAXY',
+      year: 2011,
+      vin: 'WF0MXXGBWMAG34779',
+      weights: { kerb: undefined, grossMax: 2400 },
+    })
+    expect(mapVehicle(fixture('SK'))).toEqual({
+      make: 'Škoda',
+      model: 'OCTAVIA',
+      year: 2010,
+      vin: 'TMBCK11U4Y2370804',
+      color: 'Modrá',
+      engineSize: '1.6 L',
+    })
+    expect(mapVehicle(fixture('HR'))).toEqual({
+      make: 'BMW',
+      model: 'SERIJA 3, 316I',
+      vin: 'WBAAY31080KP02047',
+    })
+  })
+
+  it('Czechia: fuel as the letter D, bare fields, a weight range that is not a weight', () => {
+    expect(mapVehicle(fixture('CZ'))).toEqual({
+      make: 'Škoda',
+      model: 'OCTAVIA',
+      year: 2005,
+      vin: 'TMBCS21Z262149586',
+      fuelType: 'diesel',
+      engineSize: '1.9 L',
+      vehicleClass: 'sedan/limuzína',
+      firstRegistered: '2005-10-04',
+      weights: { kerb: undefined, grossMax: 1970 },
+    })
+  })
+
+  it('Norway: VIN, colour, fuel code, tyres, weights and EU-kontroll dates from the register block', () => {
+    expect(mapVehicle(fixture('NO'), 'ZT49510')).toEqual({
+      make: 'Capron',
+      model: 'T 68/T 447/T 742',
+      year: 2014,
+      vin: 'ZFA25000002498708',
+      licensePlate: 'ZT49510',
+      color: 'Hvit',
+      fuelType: 'diesel',
+      engineSize: '2.3 L',
+      firstRegistered: '2014-08-19',
+      inspectionDue: '2018-10-31',
+      tyres: [
+        { axle: 1, tyre: '215/70 R 15C', rim: '6JX15H2', loadIndex: '105', speedRating: 'N' },
+        { axle: 2, tyre: '215/70 R 15C', rim: '6JX15H2', loadIndex: '108', speedRating: undefined },
+      ],
+      weights: { kerb: 2855, grossMax: 3495 },
+    })
+    // 00000000 is the register's "never", not a date.
+    expect(mapVehicle(fixture('NO')).lastInspected).toBeUndefined()
+  })
+
+  it('Hungary: lower-case benzin and a dotted date', () => {
+    expect(mapVehicle(fixture('HU'))).toEqual({
+      make: 'Suzuki',
+      model: 'GS500',
+      year: 2006,
+      vin: 'VTTBK232100104138',
+      fuelType: 'gasoline',
+      engineSize: '0.5 L',
+      firstRegistered: '2006-11-23',
+      weights: { kerb: 380, grossMax: 380 },
+    })
+  })
+
+  it('reads the JSON out of the XML envelope, with the VIN spelt as on the wire', () => {
+    const record = vehicleJsonOf(envelope('UK-live'))
+    expect(record?.VehicleIdentificationNumber).toBe('VF33CKFUC84922414')
+    expect(mapVehicle(record as RegCheckVehicle, 'AB07CDE')).toEqual({
+      make: 'Peugeot',
+      model: '307 X-LINE',
+      year: 2007,
+      vin: 'VF33CKFUC84922414',
+      licensePlate: 'AB07CDE',
+      color: 'Silver',
+      fuelType: 'gasoline',
+      engineSize: '1.4 L',
+      vehicleClass: 'Motorbike',
+    })
+    expect(vehicleJsonOf(envelope('AU-NSW'))?.State).toBe('NSW')
+    expect(vehicleJsonOf('<Vehicle><vehicleJson></vehicleJson></Vehicle>')).toBeNull()
+    expect(vehicleJsonOf('<Vehicle />')).toBeNull()
+  })
+
+  it('normalises the words and forms the wire uses', () => {
+    expect(fuelType('PETROL')).toBe('gasoline')
+    expect(fuelType('Bensin')).toBe('gasoline')
+    expect(fuelType('benzin')).toBe('gasoline')
+    expect(fuelType('DIESEL')).toBe('diesel')
+    expect(fuelType('D')).toBe('diesel')
+    expect(fuelType('Petrol/Electric')).toBe('hybrid')
+    expect(fuelType('LPG')).toBeUndefined()
+    expect(engineSize('1798 cm')).toBe('1.8 L')
+    expect(engineSize('3405.0')).toBe('3.4 L')
+    expect(engineSize('1.6L I4 MPI')).toBe('1.6 L')
+    expect(engineSize('2.5', 'L')).toBe('2.5 L')
+    expect(engineSize('')).toBeUndefined()
+    expect(isoDate('21/09/2010')).toBe('2010-09-21')
+    expect(isoDate('2006.11.23')).toBe('2006-11-23')
+    expect(isoDate('1993-05-03+02:00')).toBe('1993-05-03')
+    expect(isoDate('2006')).toBeUndefined()
+    expect(isoDate('20181031')).toBe('2018-10-31')
+    expect(isoDate('00000000')).toBeUndefined()
+    expect(normalisePlate('bew 76p')).toBe('BEW76P')
+    expect(normalisePlate('LZF-630')).toBe('LZF630')
+  })
+})
+
+describe('regcheck connector', () => {
+  it('asks the country operation with the username, and the state for Australia', async () => {
+    const { ctx, fetch, log } = context(
+      async () => new Response(envelope('AU-NSW'), { status: 200 }),
+      { country: 'AU', auState: 'NSW' }
+    )
+    const result = await connector.lookupVehicle?.(ctx, { plate: 'bew 76p' })
+    expect(result?.make).toBe('Ford')
+    expect(fetch.mock.calls[0][0]).toBe(
+      'https://www.regcheck.org.uk/api/reg.asmx/CheckAustralia?RegistrationNumber=BEW76P&State=NSW&username=workshop'
+    )
+    // The plate is personal data and must not land in the connection log.
+    for (const call of log.mock.calls) expect(JSON.stringify(call)).not.toContain('BEW76P')
+  })
+
+  it('leaves the state out for countries that do not have one', async () => {
+    const { ctx, fetch } = context(async () => new Response(envelope('UK-live'), { status: 200 }), {
+      country: 'IE',
+    })
+    await connector.lookupVehicle?.(ctx, { plate: '04MH8917' })
+    expect(fetch.mock.calls[0][0]).toBe(
+      'https://www.regcheck.org.uk/api/reg.asmx/CheckIreland?RegistrationNumber=04MH8917&username=workshop'
+    )
+  })
+
+  it('refuses to guess a country, a state or a username', async () => {
+    const answer = async () => new Response(envelope('UK-live'), { status: 200 })
+    await expect(
+      connector.lookupVehicle?.(context(answer, {}).ctx, { plate: 'X' })
+    ).rejects.toThrow(/choose a country/)
+    await expect(
+      connector.lookupVehicle?.(context(answer, { country: 'AU' }).ctx, { plate: 'X' })
+    ).rejects.toThrow(/choose the state/)
+    await expect(
+      connector.lookupVehicle?.(context(answer, { country: 'US', usState: 'Texas' }).ctx, {
+        plate: 'X',
+      })
+    ).rejects.toThrow(/choose the state/)
+    await expect(
+      connector.lookupVehicle?.(context(answer, { country: 'IE' }, {}).ctx, { plate: 'X' })
+    ).rejects.toThrow(/username is required/)
+  })
+
+  it('treats the vendor\'s "Lookup failed" as no vehicle, and a bad username as an error', async () => {
+    const missing = context(async () => new Response('UK Lookup failed\n', { status: 500 }))
+    expect(await connector.lookupVehicle?.(missing.ctx, { plate: 'ZZ99ZZZ' })).toBeNull()
+    const denied = context(async () => new Response('Your username is incorrect', { status: 500 }))
+    await expect(connector.lookupVehicle?.(denied.ctx, { plate: 'ZZ99ZZZ' })).rejects.toThrow(
+      /rejected the username/
+    )
+    const other = context(async () => new Response('Service unavailable', { status: 503 }))
+    await expect(connector.lookupVehicle?.(other.ctx, { plate: 'ZZ99ZZZ' })).rejects.toThrow(
+      /RegCheck: Service unavailable/
+    )
+  })
+
+  it('finds nothing for a VIN alone and spends no credit on it', async () => {
+    const { ctx, fetch } = context(async () => new Response(envelope('UK-live')))
+    expect(await connector.lookupVehicle?.(ctx, { vin: 'VF33CKFUC84922414' })).toBeNull()
+    expect(fetch).not.toHaveBeenCalled()
+  })
+
+  it('tests with the free credit balance, which is 0 for unknown users and empty accounts alike', async () => {
+    const funded = context(async () => new Response('10'))
+    expect(await connector.test(funded.ctx)).toEqual({ ok: true })
+    expect(funded.fetch.mock.calls[0][0]).toBe(
+      'https://www.regcheck.org.uk/ajax/getcredits.aspx?username=workshop'
+    )
+    const empty = context(async () => new Response('0'))
+    expect((await connector.test(empty.ctx)).message).toMatch(
+      /unknown or its account has no credits/
+    )
+    const noState = context(async () => new Response('10'), { country: 'AU' })
+    expect((await connector.test(noState.ctx)).ok).toBe(false)
+    expect(noState.fetch).not.toHaveBeenCalled()
+  })
+})

+ 19 - 0
src/features/integrations/Lib/make-case.ts

@@ -0,0 +1,19 @@
+/**
+ * Make names as registries send them: "PEUGEOT", "alfa romeo", "BMW",
+ * "Kia". A word written all in one case is recased, keeping initials of
+ * three letters or fewer as initials (BMW, VW, MG, DS); a word already in
+ * mixed case is the registry's own spelling and is kept.
+ */
+export function makeCase(value: string): string {
+  return value
+    .split(/(\s+|-)/)
+    .map((part) => {
+      if (!part.trim() || part === '-') return part
+      const upper = part.toUpperCase()
+      const lower = part.toLowerCase()
+      if (part !== upper && part !== lower) return part
+      if (part.length <= 3 && part === upper) return upper
+      return upper[0] + lower.slice(1)
+    })
+    .join('')
+}

+ 13 - 5
src/features/vehicles/Components/VehicleForm.tsx

@@ -290,18 +290,26 @@ export function VehicleForm({
         }
       }
 
+      // An emptied field has to reach the server as '' when editing, which
+      // the update action turns into null; undefined there means "not
+      // touched" and would bring the old value back. On create, leaving an
+      // empty field out is the same thing as clearing it.
+      const optional = (name: string): string | undefined => {
+        const value = ((formData.get(name) as string | null) ?? '').trim()
+        return value || (vehicle ? '' : undefined)
+      }
       const data: CreateVehicleInput & { imageUrl?: string } = {
         make: formData.get('make') as string,
         model: formData.get('model') as string,
         year: Number(formData.get('year')),
-        vin: (formData.get('vin') as string) || undefined,
-        licensePlate: (formData.get('licensePlate') as string) || undefined,
-        color: (formData.get('color') as string) || undefined,
+        vin: optional('vin'),
+        licensePlate: optional('licensePlate'),
+        color: optional('color'),
         mileage: Number(formData.get('mileage')) || 0,
         fuelType: fuelType || undefined,
         transmission: transmission || undefined,
-        engineSize: (formData.get('engineSize') as string) || undefined,
-        engineCode: (formData.get('engineCode') as string) || undefined,
+        engineSize: optional('engineSize'),
+        engineCode: optional('engineCode'),
         // Sent as-is: an empty string clears a hand-typed date, undefined leaves it alone.
         inspectionDueAt: isMarine ? undefined : ((formData.get('inspectionDueAt') as string) ?? ''),
         customerId,

+ 3 - 14
src/integrations/openapi-automotive/server.ts

@@ -4,6 +4,7 @@ import type {
   VehicleLookupQuery,
   VehicleLookupResult,
 } from '@/features/integrations/Lib/types'
+import { makeCase } from '@/features/integrations/Lib/make-case'
 import { manifest } from './manifest'
 
 /**
@@ -106,18 +107,6 @@ function text(value: unknown): string | undefined {
   return trimmed ? trimmed : undefined
 }
 
-/** "PEUGEOT" as Peugeot, "alfa romeo" as Alfa Romeo, but BMW, VW and DS stay the initials they are. */
-export function titleCase(value: string): string {
-  return value
-    .split(/(\s+|-)/)
-    .map((part) => {
-      if (!part.trim() || part === '-') return part
-      if (part.length <= 3) return part.toUpperCase()
-      return part[0].toUpperCase() + part.slice(1).toLowerCase()
-    })
-    .join('')
-}
-
 /**
  * One fuel for the form's select, from words in five languages. Two fuels
  * named together, such as "Petrol/Electric", make a hybrid; a word the list
@@ -202,12 +191,12 @@ export function mapRecord(record: AutomotiveRecord, plate?: string): VehicleLook
   const grossMax = kilograms(record.GrossWeight)
 
   const result: VehicleLookupResult = {
-    make: make ? titleCase(make) : undefined,
+    make: make ? makeCase(make) : undefined,
     model: text(record.CarModel) ?? text(record.ModelDescription),
     year: Number.isFinite(year) && year > 1800 ? year : undefined,
     vin: vin(record),
     licensePlate: text(record.LicensePlate) ?? plate,
-    color: colour ? titleCase(colour) : undefined,
+    color: colour ? makeCase(colour) : undefined,
     fuelType: fuelType(text(record.FuelType) ?? text(record.Fuel)),
     transmission: transmission(
       text(record.Transmission) ?? text(record.ExtendedData?.boiteDeVitesse)

+ 28 - 0
src/integrations/rdw/manifest.ts

@@ -0,0 +1,28 @@
+import { INSPECTION_MANIFEST } from '@/features/integrations/Lib/inspection-sync'
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * RDW, the Dutch vehicle authority, and its open data on every registered
+ * vehicle: make, model, first registration, fuel, colour, weights and the
+ * date the next APK inspection is due, by plate, free and without a key.
+ *
+ * The one optional credential is a Socrata app token from opendata.rdw.nl.
+ * Anonymous requests share a throttled pool per address, so a workshop that
+ * turns on the inspection sync is better off with a token of its own. No
+ * owner data and no VIN come back: RDW keeps both out of the open data.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'rdw',
+  name: 'RDW Open Data',
+  category: 'registry',
+  countries: ['NL'],
+  logo: '/images/integrations/rdw.svg',
+  docs: '/docs/integrations/rdw',
+  auth: {
+    type: 'api-key',
+    fields: [{ key: 'appToken', label: 'appToken', type: 'password', help: 'appTokenHelp' }],
+  },
+  capabilities: ['vehicle.lookup', INSPECTION_MANIFEST.capability],
+  settings: [INSPECTION_MANIFEST.setting],
+  schedules: [INSPECTION_MANIFEST.schedule],
+}

+ 222 - 0
src/integrations/rdw/server.ts

@@ -0,0 +1,222 @@
+import type {
+  ConnectorContext,
+  ConnectorServer,
+  VehicleLookupQuery,
+  VehicleLookupResult,
+} from '@/features/integrations/Lib/types'
+import { makeCase } from '@/features/integrations/Lib/make-case'
+import { inspectionJobs } from '@/features/integrations/Lib/inspection-sync'
+import { manifest } from './manifest'
+
+/**
+ * RDW open data on the Socrata platform: one row per plate in the vehicle
+ * dataset, and one row per fuel in the fuel dataset, both filtered with a
+ * plain query parameter. No key is needed; an app token in X-App-Token lifts
+ * the anonymous throttle. An empty array means no such vehicle, 403 an app
+ * token Socrata does not accept, and 429 the throttle, which the HTTP client
+ * retries on its own.
+ *
+ * A plate is personal data under Dutch law, so nothing here writes one into
+ * the connection log; the log records outcomes only.
+ */
+const VEHICLES_URL = 'https://opendata.rdw.nl/resource/m9d7-ebf2.json'
+const FUELS_URL = 'https://opendata.rdw.nl/resource/8ys7-d773.json'
+
+/** The slice of the vehicle row the app reads. Every field is a string in Socrata's JSON, and optional in the wild. */
+export interface RdwVehicle {
+  kenteken?: string
+  voertuigsoort?: string
+  merk?: string
+  handelsbenaming?: string
+  vervaldatum_apk?: string
+  datum_eerste_toelating?: string
+  eerste_kleur?: string
+  cilinderinhoud?: string
+  massa_ledig_voertuig?: string
+  toegestane_maximum_massa_voertuig?: string
+  export_indicator?: string
+}
+
+export interface RdwFuel {
+  kenteken?: string
+  brandstof_volgnummer?: string
+  brandstof_omschrijving?: string
+  klasse_hybride_elektrisch_voertuig?: string
+}
+
+function text(value: unknown): string | undefined {
+  if (typeof value !== 'string') return undefined
+  const trimmed = value.trim()
+  return trimmed ? trimmed : undefined
+}
+
+/** RDW writes dates as YYYYMMDD. */
+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
+}
+
+/**
+ * One fuel for the form's select from RDW's fuel rows. The dataset names
+ * eight fuels (Benzine, Diesel, Elektriciteit, LPG, CNG, LNG, Alcohol,
+ * Waterstof) and four hybrid classes (NOVC-HEV, OVC-HEV and the fuel-cell
+ * pair NOVC-FCHV, OVC-FCHV). An electric row beside a combustion row is a
+ * hybrid, as is a combustion car in a HEV class; a hydrogen car is neither
+ * of the form's words and is left as other, as are LPG, CNG and LNG alone.
+ */
+export function fuelType(fuels: RdwFuel[]): string | undefined {
+  const names = new Set<string>()
+  let hev = false
+  for (const row of fuels) {
+    const name = text(row.brandstof_omschrijving)?.toLowerCase()
+    if (name) names.add(name)
+    if (/^(N?OVC-)?HEV$/i.test(text(row.klasse_hybride_elektrisch_voertuig) ?? '')) hev = true
+  }
+  if (names.size === 0) return undefined
+  if (names.has('waterstof')) return 'other'
+  const electric = names.has('elektriciteit')
+  const diesel = names.has('diesel')
+  const gasoline = names.has('benzine') || names.has('alcohol')
+  if ((electric && (diesel || gasoline)) || ((diesel || gasoline) && hev)) return 'hybrid'
+  if (electric) return 'electric'
+  if (diesel) return 'diesel'
+  if (gasoline) return 'gasoline'
+  return 'other'
+}
+
+/** Colours RDW records for vehicles that have none, or has not recorded. */
+const NO_COLOUR = new Set(['n.v.t.', 'niet geregistreerd', 'diversen'])
+
+/** Displacement as the form shows it: litres with one decimal, from cubic centimetres. */
+function engineSize(value: string | undefined): string | undefined {
+  const cc = Number(value)
+  if (!Number.isFinite(cc) || cc <= 0) return undefined
+  return `${(cc / 1000).toFixed(1)} L`
+}
+
+function kilograms(value: string | undefined): number | undefined {
+  const n = Number(value)
+  return Number.isFinite(n) && n > 0 ? n : undefined
+}
+
+/** RDW's rows in the app's vocabulary. Exported for the contract test. */
+export function mapVehicle(vehicle: RdwVehicle, fuels: RdwFuel[]): VehicleLookupResult {
+  const make = text(vehicle.merk)
+  const model = text(vehicle.handelsbenaming)
+  const firstRegistered = isoDate(vehicle.datum_eerste_toelating)
+  const colour = text(vehicle.eerste_kleur)
+  const kerb = kilograms(vehicle.massa_ledig_voertuig)
+  const grossMax = kilograms(vehicle.toegestane_maximum_massa_voertuig)
+  const exported = text(vehicle.export_indicator)?.toLowerCase()
+
+  const result: VehicleLookupResult = {
+    make: make ? makeCase(make) : undefined,
+    // RDW often repeats the make in the trade name ("TOYOTA PRIUS PLUS").
+    model:
+      model && make && model.toUpperCase().startsWith(`${make.toUpperCase()} `)
+        ? model.slice(make.length + 1)
+        : model,
+    year: firstRegistered ? Number(firstRegistered.slice(0, 4)) : undefined,
+    licensePlate: text(vehicle.kenteken),
+    color: colour && !NO_COLOUR.has(colour.toLowerCase()) ? makeCase(colour) : undefined,
+    fuelType: fuelType(fuels),
+    engineSize: engineSize(vehicle.cilinderinhoud),
+    vehicleClass: text(vehicle.voertuigsoort),
+    firstRegistered,
+    inspectionDue: isoDate(vehicle.vervaldatum_apk),
+    weights: kerb || grossMax ? { kerb, grossMax } : undefined,
+    registered: exported === 'nee' ? true : exported === 'ja' ? false : undefined,
+  }
+  // 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
+}
+
+/** Dutch plates are printed with hyphens, SK-209-X; RDW stores them without. */
+export function normalisePlate(plate: string): string {
+  return plate.replace(/[\s-]+/g, '').toUpperCase()
+}
+
+function appToken(ctx: ConnectorContext): string {
+  return typeof ctx.credentials.appToken === 'string' ? ctx.credentials.appToken.trim() : ''
+}
+
+function headers(ctx: ConnectorContext): Record<string, string> {
+  const token = appToken(ctx)
+  return token
+    ? { 'X-App-Token': token, Accept: 'application/json' }
+    : { Accept: 'application/json' }
+}
+
+async function rows<T>(ctx: ConnectorContext, url: string, what: string): Promise<T[]> {
+  const res = await ctx.http.fetch(url, { headers: headers(ctx) })
+  if (res.status === 401 || res.status === 403) {
+    await ctx.log('error', `${what}: app token rejected`, { status: res.status })
+    throw new Error('RDW rejected the app token')
+  }
+  if (res.status === 429) {
+    await ctx.log('warn', `${what}: throttled`, { status: res.status })
+    throw new Error('RDW is throttling requests, add an app token or try again in a minute')
+  }
+  if (!res.ok) {
+    await ctx.log('error', `${what} failed`, { status: res.status })
+    throw new Error(`RDW answered HTTP ${res.status}`)
+  }
+  const body = (await res.json()) as unknown
+  return Array.isArray(body) ? (body as T[]) : []
+}
+
+function byPlate(url: string, plate: string): string {
+  const u = new URL(url)
+  u.searchParams.set('kenteken', plate)
+  return u.toString()
+}
+
+/**
+ * One vehicle by plate. RDW's open data carries no chassis number, so a VIN
+ * alone finds nothing. The fuel rows are fetched only once the vehicle row
+ * exists, so a miss costs one request.
+ */
+async function lookupVehicle(
+  ctx: ConnectorContext,
+  query: VehicleLookupQuery
+): Promise<VehicleLookupResult | null> {
+  const plate = query.plate?.trim() ? normalisePlate(query.plate) : ''
+  if (!plate) {
+    await ctx.log('info', 'Vehicle lookup: a plate is needed, RDW open data has no VIN')
+    return null
+  }
+  const vehicles = await rows<RdwVehicle>(ctx, byPlate(VEHICLES_URL, plate), 'Vehicle lookup')
+  const vehicle = vehicles[0]
+  if (!vehicle) {
+    await ctx.log('info', 'Vehicle lookup: no match')
+    return null
+  }
+  const fuels = await rows<RdwFuel>(ctx, byPlate(FUELS_URL, plate), 'Fuel lookup')
+  await ctx.log('info', 'Vehicle lookup: match', {
+    fuels: fuels.map((f) => f.brandstof_omschrijving ?? '?'),
+  })
+  return mapVehicle(vehicle, fuels)
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    // The data is public, so the test is one row from the dataset. With a
+    // token set, Socrata answers 403 to one it does not know, which is the
+    // only thing here that can be wrong.
+    const res = await ctx.http.fetch(`${VEHICLES_URL}?$limit=1`, { headers: headers(ctx) })
+    if (res.status === 401 || res.status === 403) {
+      return { ok: false, message: 'RDW rejected the app token' }
+    }
+    if (!res.ok) return { ok: false, message: `RDW answered HTTP ${res.status}` }
+    return { ok: true }
+  },
+
+  lookupVehicle,
+
+  jobs: inspectionJobs((ctx, query) => lookupVehicle(ctx, query)),
+}

+ 74 - 0
src/integrations/regcheck/manifest.ts

@@ -0,0 +1,74 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * RegCheck, Infinite Loop Development's plate lookup covering some sixty
+ * countries through one web service. Offered here for the countries the
+ * app has no free or national registry for and whose answers the vendor
+ * documents: Australia and New Zealand, the United States, Ireland, the
+ * Nordics, and central Europe. Norway is here as a fallback beside the free
+ * Statens vegvesen connector.
+ *
+ * The account is the workshop's own: a username, with credits bought on the
+ * vendor's site and spent per successful lookup. Australia and the United
+ * States register vehicles per state, and the vendor needs the state named
+ * on every call, so the workshop's own state is a setting. No owner data
+ * comes back.
+ */
+export const AUSTRALIAN_STATES = ['NSW', 'VIC', 'QLD', 'SA', 'WA', 'TAS', 'ACT', 'NT'] as const
+
+export const manifest: ConnectorManifest = {
+  id: 'regcheck',
+  name: 'RegCheck',
+  category: 'registry',
+  countries: ['AU', 'NZ', 'US', 'IE', 'NO', 'SE', 'DK', 'FI', 'EE', 'CZ', 'SK', 'HU', 'HR'],
+  logo: '/images/integrations/regcheck.svg',
+  docs: '/docs/integrations/regcheck',
+  auth: {
+    type: 'api-key',
+    fields: [
+      { key: 'username', label: 'username', type: 'text', required: true, help: 'usernameHelp' },
+    ],
+  },
+  capabilities: ['vehicle.lookup'],
+  settings: [
+    {
+      key: 'country',
+      type: 'select',
+      label: 'country',
+      help: 'countryHelp',
+      required: true,
+      options: [
+        { value: 'AU', label: 'Australia' },
+        { value: 'NZ', label: 'New Zealand' },
+        { value: 'US', label: 'United States' },
+        { value: 'IE', label: 'Ireland' },
+        { value: 'NO', label: 'Norge' },
+        { value: 'SE', label: 'Sverige' },
+        { value: 'DK', label: 'Danmark' },
+        { value: 'FI', label: 'Suomi' },
+        { value: 'EE', label: 'Eesti' },
+        { value: 'CZ', label: 'Česko' },
+        { value: 'SK', label: 'Slovensko' },
+        { value: 'HU', label: 'Magyarország' },
+        { value: 'HR', label: 'Hrvatska' },
+      ],
+    },
+    {
+      key: 'auState',
+      type: 'select',
+      label: 'auState',
+      help: 'auStateHelp',
+      required: true,
+      showWhen: { key: 'country', equals: 'AU' },
+      options: AUSTRALIAN_STATES.map((s) => ({ value: s, label: s })),
+    },
+    {
+      key: 'usState',
+      type: 'text',
+      label: 'usState',
+      help: 'usStateHelp',
+      required: true,
+      showWhen: { key: 'country', equals: 'US' },
+    },
+  ],
+}

+ 487 - 0
src/integrations/regcheck/server.ts

@@ -0,0 +1,487 @@
+import type {
+  ConnectorContext,
+  ConnectorServer,
+  VehicleLookupQuery,
+  VehicleLookupResult,
+} from '@/features/integrations/Lib/types'
+import { makeCase } from '@/features/integrations/Lib/make-case'
+import { AUSTRALIAN_STATES, manifest } from './manifest'
+
+/**
+ * RegCheck is an ASP.NET web service with one operation per country,
+ * reachable with a plain GET: /api/reg.asmx/CheckIreland?RegistrationNumber=
+ * ...&username=... The XML that comes back carries a vehicleJson element
+ * with the vehicle as JSON, which is what the mapper reads. The JSON's shape
+ * is shared across countries but each fills a different subset, and several
+ * fields arrive either as a string or as an object with a CurrentTextValue.
+ *
+ * Errors come back as HTTP 500 with a plain-text reason ("Your username is
+ * incorrect"). The vendor does not document how a plate that is not on file
+ * is reported, so both an empty answer and a reason that reads like "not
+ * found" are treated as no vehicle.
+ *
+ * A plate is personal data in most of these countries, so nothing here
+ * writes one into the connection log; the log records outcomes only.
+ */
+const BASE_URL = 'https://www.regcheck.org.uk/api/reg.asmx'
+const CREDITS_URL = 'https://www.regcheck.org.uk/ajax/getcredits.aspx'
+
+export type Country =
+  | 'AU'
+  | 'NZ'
+  | 'US'
+  | 'IE'
+  | 'SE'
+  | 'DK'
+  | 'FI'
+  | 'EE'
+  | 'CZ'
+  | 'SK'
+  | 'HU'
+  | 'HR'
+  | 'NO'
+
+const OPERATIONS: Record<Country, string> = {
+  AU: 'CheckAustralia',
+  NZ: 'CheckNewZealand',
+  US: 'CheckUSA',
+  IE: 'CheckIreland',
+  SE: 'CheckSweden',
+  DK: 'CheckDenmark',
+  FI: 'CheckFinland',
+  EE: 'CheckEstonia',
+  CZ: 'CheckCzechRepublic',
+  SK: 'CheckSlovakia',
+  HU: 'CheckHungary',
+  HR: 'CheckCroatia',
+  NO: 'CheckNorway',
+}
+
+/** A field the vendor sends either bare or wrapped, depending on the country. */
+export type Wrapped = string | number | null | { CurrentTextValue?: string | number | null }
+
+/**
+ * The union of what the vendor's documented samples and a live answer carry
+ * for the countries offered. Australia's states differ among themselves as
+ * much as countries do: NSW, TAS and WA describe the engine in words and put
+ * the real fields under "extended", VIC and QLD send the VIN and little
+ * else, NT nests the registration in RegistrationPlate. The VIN field is
+ * spelt "Vechile..." in the documentation and "Vehicle..." on the wire.
+ */
+export interface RegCheckVehicle {
+  Description?: string
+  RegistrationYear?: string | number
+  CarMake?: Wrapped
+  CarModel?: Wrapped
+  MakeDescription?: Wrapped
+  ModelDescription?: Wrapped
+  EngineSize?: Wrapped
+  FuelType?: Wrapped
+  Transmission?: Wrapped
+  BodyStyle?: Wrapped
+  Colour?: string
+  VehicleIdentificationNumber?: string
+  VechileIdentificationNumber?: string
+  VIN?: string
+  EngineCode?: string
+  /**
+   * Ireland and Victoria: an engine number. New South Wales and Western
+   * Australia: a description ("1.6 litre, 4 cyl, WQ"). Tasmania: cubic
+   * centimetres ("1968").
+   */
+  Engine?: string
+  /** Tasmania's fuel, bare. */
+  Fuel?: string
+  RegistrationDate?: string
+  /** Czech Republic's registration date. */
+  Date?: string
+  NetWeight?: string | number
+  GrossWeight?: string | number
+  VehicleType?: string
+  State?: string
+  /**
+   * Australia, NSW and WA. Western Australia still sends the flat
+   * capacityValue and capacityUnit the documentation shows; New South Wales
+   * now nests them under engine.capacity. Tasmania sends null.
+   */
+  extended?: {
+    capacityValue?: string
+    capacityUnit?: string
+    engine?: { capacity?: { value?: string; unit?: string } }
+    transmissionType?: string
+    fuelType?: string
+    bodyType?: string
+  } | null
+  /** Australia, NT: registration and inspection as epoch milliseconds. */
+  RegistrationPlate?: { date_inspection?: number; status?: string }
+  /**
+   * Denmark: the Motorregister record behind the summary. Norway: the
+   * vehicle register's own fields, zero-padded numbers and YYYYMMDD dates,
+   * with 00000000 for a date that is not set.
+   */
+  ExtendedInformation?: {
+    KoeretoejOplysningGrundStruktur?: {
+      KoeretoejOplysningFoersteRegistreringDato?: string
+      KoeretoejOplysningTotalVaegt?: string
+      KoeretoejOplysningEgenVaegt?: string
+    }
+    SynResultatStruktur?: { SynResultatSynsDato?: string; SynResultatSynsResultat?: string }
+    unr?: string
+    'f-g-n'?: string
+    farge?: string
+    girkasse?: string
+    drivst?: string
+    egenvekt?: string
+    totvekt?: string
+    'siste-pkk'?: string
+    'neste-pkk'?: string
+    'dekk-f'?: string
+    'dekk-b'?: string
+    'felg-f'?: string
+    'felg-b'?: string
+    'mili-f'?: string
+    'mili-b'?: string
+    'hast-f'?: string
+    'hast-b'?: string
+  }
+}
+
+/** Norway's drivst codes, the same code list Statens vegvesen publishes. */
+const NORWEGIAN_FUEL: Record<string, string> = {
+  '01': 'gasoline',
+  '02': 'diesel',
+  '05': 'electric',
+}
+
+export function text(value: Wrapped | undefined): string | undefined {
+  if (value === null || value === undefined) return undefined
+  if (typeof value === 'object') return text(value.CurrentTextValue)
+  const s = String(value).trim()
+  return s ? s : undefined
+}
+
+/**
+ * One fuel for the form's select, from the words the samples use: Petrol,
+ * Diesel (UK, Ireland, New Zealand, Finland), Bensin (Sweden), Benzin
+ * (Denmark, Hungary), and the Czech single letter D. Two named together are a
+ * hybrid; a word the list does not know leaves the field alone.
+ */
+export function fuelType(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  const v = value.toLowerCase()
+  const hybrid = /hybrid|hybride|hibrid/.test(v)
+  const electric = /electri|elektri|sähkö|^el$/.test(v)
+  const diesel = /diesel|dísel|gasoil|nafta/.test(v) || v === 'd'
+  const gasoline = /petrol|gasoline|bensin|benzin|benzín|bensiini|gasolina/.test(v)
+  if (hybrid || (electric && (diesel || gasoline))) return 'hybrid'
+  if (electric) return 'electric'
+  if (diesel) return 'diesel'
+  if (gasoline) return 'gasoline'
+  return undefined
+}
+
+/** Gearbox words from the samples: Automatic, Automaattinen, Manuell, MANUAL, "4-Speed Auto". */
+export function transmission(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  const v = value.toLowerCase()
+  if (/cvt|variat|continu/.test(v)) return 'cvt'
+  if (/auto/.test(v)) return 'automatic'
+  if (/manu/.test(v)) return 'manual'
+  return undefined
+}
+
+/**
+ * Displacement as the form shows it, from the forms the samples take:
+ * "2143", "1798 cm", "3405.0", 1984 (cubic centimetres), "2.0" with unit "L"
+ * from Australia's extended data, or "5.7L V8 MPI" from the United States.
+ */
+export function engineSize(value: string | undefined, unit?: string): string | undefined {
+  if (!value) return undefined
+  const m = /(\d+(?:[.,]\d+)?)\s*(l\b|litre|liter)?/i.exec(value)
+  if (!m) return undefined
+  const n = Number(m[1].replace(',', '.'))
+  if (!Number.isFinite(n) || n <= 0) return undefined
+  const litres = Boolean(m[2]) || unit?.toUpperCase() === 'L' || n < 30
+  return `${(litres ? n : n / 1000).toFixed(1)} L`
+}
+
+/**
+ * ISO date from the forms seen on the wire: 21/09/2010 (Finland, Czech
+ * Republic), 2006.11.23 (Hungary), 1993-05-03+02:00 (Denmark). Sweden's bare
+ * "2006" is a year, not a date.
+ */
+export function isoDate(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  const v = value.trim()
+  let m = /^(\d{4})[-.](\d{1,2})[-.](\d{1,2})/.exec(v)
+  if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`
+  m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(v)
+  if (m) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
+  // Norway's YYYYMMDD, where 00000000 means not set.
+  m = /^(\d{4})(\d{2})(\d{2})$/.exec(v)
+  if (m && m[1] !== '0000') return `${m[1]}-${m[2]}-${m[3]}`
+  return undefined
+}
+
+/**
+ * Australia's Engine field, when it is a capacity: "1.6 litre, 4 cyl, WQ" or
+ * "1968". Victoria and Ireland put an engine number there, which is neither.
+ */
+function engineFieldSize(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  if (/litre|liter|\bl\b/i.test(value) || /^\d{3,5}$/.test(value.trim())) return engineSize(value)
+  return undefined
+}
+
+function vin(record: RegCheckVehicle): string | undefined {
+  for (const candidate of [
+    record.VIN,
+    record.VehicleIdentificationNumber,
+    record.VechileIdentificationNumber,
+    record.ExtendedInformation?.unr,
+  ]) {
+    const v = text(candidate)?.toUpperCase()
+    // NSW, TAS and WA put a NEVDIS code here ("FORFMT---7402E01994A"), not a VIN.
+    if (v && /^[A-HJ-NPR-Z0-9]{11,17}$/.test(v)) return v
+  }
+  return undefined
+}
+
+function kilograms(value: string | number | undefined): number | undefined {
+  const s = text(value)
+  // The Czech sample gives a range ("1 385 - 1 575"), which is not one weight.
+  if (!s || !/^\d+(\.\d+)?$/.test(s)) return undefined
+  const n = Number(s)
+  return n > 0 ? n : undefined
+}
+
+/** Norway's tyre and rim sizes, front then rear, with the load index and speed rating. */
+function norwegianTyres(ext: NonNullable<RegCheckVehicle['ExtendedInformation']>) {
+  const axles = [
+    {
+      axle: 1,
+      tyre: text(ext['dekk-f']),
+      rim: text(ext['felg-f']),
+      loadIndex: text(ext['mili-f']),
+      speedRating: text(ext['hast-f']),
+    },
+    {
+      axle: 2,
+      tyre: text(ext['dekk-b']),
+      rim: text(ext['felg-b']),
+      loadIndex: text(ext['mili-b']),
+      speedRating: text(ext['hast-b']),
+    },
+  ]
+  const present = axles.filter((a) => a.tyre || a.rim)
+  return present.length > 0 ? present : undefined
+}
+
+/** The vendor's JSON in the app's vocabulary. Exported for the contract test. */
+export function mapVehicle(record: RegCheckVehicle, plate?: string): VehicleLookupResult {
+  const make = text(record.CarMake) ?? text(record.MakeDescription)
+  const model = text(record.CarModel) ?? text(record.ModelDescription)
+  const ext = record.ExtendedInformation
+  const dmr = ext?.KoeretoejOplysningGrundStruktur
+  const syn = ext?.SynResultatStruktur
+  const capacity = record.extended?.engine?.capacity
+  const firstRegistered =
+    isoDate(text(record.RegistrationDate)) ??
+    isoDate(text(record.Date)) ??
+    isoDate(text(dmr?.KoeretoejOplysningFoersteRegistreringDato)) ??
+    isoDate(text(ext?.['f-g-n']))
+  const yearText = text(record.RegistrationYear) ?? firstRegistered?.slice(0, 4)
+  const year = Number(yearText)
+  const colour = text(record.Colour) ?? text(ext?.farge)
+  const kerb =
+    kilograms(record.NetWeight) ??
+    kilograms(dmr?.KoeretoejOplysningEgenVaegt) ??
+    kilograms(ext?.egenvekt)
+  const grossMax =
+    kilograms(record.GrossWeight) ??
+    kilograms(dmr?.KoeretoejOplysningTotalVaegt) ??
+    kilograms(ext?.totvekt)
+  const drivst = text(ext?.drivst)
+  const inspection = record.RegistrationPlate?.date_inspection
+
+  const result: VehicleLookupResult = {
+    make: make ? makeCase(make) : undefined,
+    // The ACT sample repeats the make as the model; that is not a model.
+    model: model && model !== make ? model : undefined,
+    year: Number.isFinite(year) && year > 1800 ? year : undefined,
+    vin: vin(record),
+    licensePlate: plate,
+    color: colour ? makeCase(colour) : undefined,
+    fuelType:
+      fuelType(text(record.FuelType) ?? text(record.Fuel) ?? text(record.extended?.fuelType)) ??
+      (drivst ? NORWEGIAN_FUEL[drivst] : undefined),
+    transmission: transmission(
+      text(record.Transmission) ?? text(record.extended?.transmissionType) ?? text(ext?.girkasse)
+    ),
+    engineSize:
+      engineSize(text(capacity?.value), capacity?.unit) ??
+      engineSize(text(record.extended?.capacityValue), record.extended?.capacityUnit) ??
+      engineSize(text(record.EngineSize)) ??
+      engineFieldSize(text(record.Engine)),
+    engineCode: text(record.EngineCode),
+    vehicleClass: text(record.BodyStyle) ?? text(record.VehicleType),
+    firstRegistered,
+    inspectionDue:
+      typeof inspection === 'number' && inspection > 0
+        ? new Date(inspection).toISOString().slice(0, 10)
+        : isoDate(text(ext?.['neste-pkk'])),
+    lastInspected:
+      syn?.SynResultatSynsResultat === 'Godkendt'
+        ? isoDate(text(syn.SynResultatSynsDato))
+        : isoDate(text(ext?.['siste-pkk'])),
+    tyres: ext ? norwegianTyres(ext) : undefined,
+    weights: kerb || grossMax ? { kerb, grossMax } : undefined,
+  }
+  // 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
+}
+
+/** Plates as printed, with the spaces and hyphens people type; the vendor's samples carry none. */
+export function normalisePlate(plate: string): string {
+  return plate.replace(/[\s-]+/g, '').toUpperCase()
+}
+
+/** The JSON out of the vendor's XML envelope, or null when the element is empty or missing. */
+export function vehicleJsonOf(xml: string): RegCheckVehicle | null {
+  const m = /<vehicleJson>([\s\S]*?)<\/vehicleJson>/.exec(xml)
+  if (!m) return null
+  const decoded = m[1]
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"')
+    .replace(/&apos;/g, "'")
+    .replace(/&#(\d+);/g, (_, code: string) => String.fromCharCode(Number(code)))
+    .replace(/&amp;/g, '&')
+    .trim()
+  if (!decoded) return null
+  const parsed = JSON.parse(decoded) as RegCheckVehicle
+  return parsed && typeof parsed === 'object' ? parsed : null
+}
+
+function username(ctx: ConnectorContext): string {
+  return typeof ctx.credentials.username === 'string' ? ctx.credentials.username.trim() : ''
+}
+
+export function countryOf(ctx: ConnectorContext): Country | null {
+  const value = ctx.connection.settings.country
+  return typeof value === 'string' && value in OPERATIONS ? (value as Country) : null
+}
+
+/** The state a lookup is made in, for the two countries that register per state; '' elsewhere, null when missing. */
+function stateOf(ctx: ConnectorContext, country: Country): string | null {
+  if (country === 'AU') {
+    const s = ctx.connection.settings.auState
+    return typeof s === 'string' && (AUSTRALIAN_STATES as readonly string[]).includes(s) ? s : null
+  }
+  if (country === 'US') {
+    const s = ctx.connection.settings.usState
+    return typeof s === 'string' && /^[A-Za-z]{2}$/.test(s.trim()) ? s.trim().toUpperCase() : null
+  }
+  return ''
+}
+
+function lookupUrl(country: Country, plate: string, state: string, user: string): string {
+  const url = new URL(`${BASE_URL}/${OPERATIONS[country]}`)
+  url.searchParams.set('RegistrationNumber', plate)
+  if (state) url.searchParams.set('State', state)
+  url.searchParams.set('username', user)
+  return url.toString()
+}
+
+/** "UK Lookup failed" is what a plate not on file gets, seen live; the rest are phrases from the vendor's site. */
+const NOT_FOUND =
+  /lookup failed|not found|no (vehicle|data|record|result)|unable to find|could not find|no match/i
+
+async function lookupVehicle(
+  ctx: ConnectorContext,
+  query: VehicleLookupQuery
+): Promise<VehicleLookupResult | null> {
+  const plate = query.plate?.trim() ? normalisePlate(query.plate) : ''
+  if (!plate) {
+    await ctx.log('info', 'Vehicle lookup: a plate is needed, this registry has no VIN search')
+    return null
+  }
+  const country = countryOf(ctx)
+  if (!country) throw new Error('RegCheck: choose a country on the integration page')
+  const state = stateOf(ctx, country)
+  if (state === null) throw new Error('RegCheck: choose the state on the integration page')
+  const user = username(ctx)
+  if (!user) throw new Error('RegCheck: a username is required')
+
+  const res = await ctx.http.fetch(lookupUrl(country, plate, state, user), {
+    headers: { Accept: 'application/xml' },
+  })
+  const body = await res.text()
+  if (!res.ok) {
+    const reason = body
+      .replace(/<[^>]+>/g, ' ')
+      .replace(/\s+/g, ' ')
+      .trim()
+      .slice(0, 200)
+    if (/username is incorrect/i.test(reason)) {
+      await ctx.log('error', 'Vehicle lookup: username rejected', { status: res.status })
+      throw new Error('RegCheck rejected the username')
+    }
+    if (NOT_FOUND.test(reason)) {
+      await ctx.log('info', 'Vehicle lookup: no match', { country })
+      return null
+    }
+    await ctx.log('error', 'Vehicle lookup failed', { status: res.status, reason })
+    throw new Error(reason ? `RegCheck: ${reason}` : `RegCheck answered HTTP ${res.status}`)
+  }
+  const record = vehicleJsonOf(body)
+  if (!record || (!text(record.CarMake) && !text(record.MakeDescription) && !record.Description)) {
+    await ctx.log('info', 'Vehicle lookup: no match', { country })
+    return null
+  }
+  await ctx.log('info', 'Vehicle lookup: match', {
+    country,
+    state: text(record.State),
+    fields: Object.keys(record).filter((k) => k !== 'ImageUrl'),
+  })
+  return mapVehicle(record, plate)
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const user = username(ctx)
+    if (!user) return { ok: false, message: 'RegCheck: a username is required' }
+    const country = countryOf(ctx)
+    if (!country) return { ok: false, message: 'RegCheck: choose the country to search' }
+    if (stateOf(ctx, country) === null) {
+      return { ok: false, message: 'RegCheck: choose the state to search' }
+    }
+    // The credit balance is free to read and needs only the username. The
+    // vendor answers 0 both for an unknown username and for an empty account,
+    // and either way no lookup would succeed.
+    const url = new URL(CREDITS_URL)
+    url.searchParams.set('username', user)
+    const res = await ctx.http.fetch(url.toString())
+    if (!res.ok) return { ok: false, message: `RegCheck answered HTTP ${res.status}` }
+    const credits = Number((await res.text()).trim())
+    if (!Number.isFinite(credits)) {
+      return { ok: false, message: 'RegCheck gave an unexpected answer' }
+    }
+    if (credits <= 0) {
+      return {
+        ok: false,
+        message: 'RegCheck: this username is unknown or its account has no credits left',
+      }
+    }
+    return { ok: true }
+  },
+
+  lookupVehicle,
+
+  jobs: {},
+}

+ 4 - 0
src/integrations/registry.ts

@@ -14,6 +14,8 @@ import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
 import { manifest as postmark } from './postmark/manifest'
+import { manifest as rdw } from './rdw/manifest'
+import { manifest as regcheck } from './regcheck/manifest'
 import { manifest as resend } from './resend/manifest'
 import { manifest as sendgrid } from './sendgrid/manifest'
 import { manifest as smtp } from './smtp/manifest'
@@ -37,6 +39,8 @@ const ENTRIES: readonly RegistryEntry[] = [
   { manifest: zoom, load: () => import('./zoom/server') },
   { manifest: vegvesen, load: () => import('./vegvesen/server') },
   { manifest: openapiAutomotive, load: () => import('./openapi-automotive/server') },
+  { manifest: rdw, load: () => import('./rdw/server') },
+  { manifest: regcheck, load: () => import('./regcheck/server') },
   { manifest: twilioSms, load: () => import('./twilio-sms/server') },
   { manifest: vonageSms, load: () => import('./vonage-sms/server') },
   { manifest: telnyxSms, load: () => import('./telnyx-sms/server') },