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

Openapi Automotive plate lookup for France, Italy, Spain, Portugal and the UK (#309)

* Openapi Automotive plate lookup for France, Italy, Spain, Portugal and the UK

One registry connector on Openapi.com's Automotive API, with the workshop's
own billed token, a country setting because French and Italian plates share
a format, an optional motorcycle fallback, and polling for the vendor's
asynchronous answers. Maps each country's record shape into
VehicleLookupResult and never logs a plate.

* Openapi connector: real Openapi mark as the logo, keep make initials uppercase

* Openapi connector: sandbox contract tests per country and vendor plate formats

Recorded answers from the vendor's sandbox for all six endpoints as fixtures,
so the mapper is tested against what each country actually sends, and the
plates people type are checked against the vendor's published patterns.
Bernt Christian Egeland 4 недель назад
Родитель
Сommit
c4b8a62210

+ 13 - 0
messages/de/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "HU-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 EU-kontroll-Termin gespeichert. Es werden nur Fahrzeuge dieser Werkstatt gesendet. Ein Fahrzeug wird monatlich geprüft, wöchentlich sobald die Frist unter zwei Monaten liegt."
       }
+    },
+    "openapi-automotive": {
+      "description": "Fahrzeug aus dem französischen, italienischen, spanischen, portugiesischen oder britischen Kennzeichen ausfüllen: Marke, Modell, Baujahr, FIN, Kraftstoff, Hubraum und mehr aus den Zulassungsdaten des Landes über Openapi.com. Jede Abfrage wird dem eigenen Openapi-Konto der Werkstatt berechnet. Halterdaten werden nicht geliefert.",
+      "fields": {
+        "token": "API-Token",
+        "tokenHelp": "Legen Sie ein Konto auf console.openapi.com an, laden Sie Guthaben auf und erstellen Sie unter Automotive ein Token für die Produktionsumgebung. Jede erfolgreiche Abfrage kostet einige Dutzend Cent; ein Fehltreffer ist kostenlos."
+      },
+      "settings": {
+        "country": "Land",
+        "countryHelp": "Das zu durchsuchende Register. Französische und italienische Kennzeichen sehen gleich aus, das Land lässt sich daher nicht am Kennzeichen ablesen.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/en/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Keep inspection 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 EU-kontroll 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Fill in a vehicle from its French, Italian, Spanish, Portuguese or British plate: make, model, year, VIN, fuel, engine size and more, from the country's registration data through Openapi.com. Each lookup is billed to the workshop's own Openapi account. No owner data is returned.",
+      "fields": {
+        "token": "API token",
+        "tokenHelp": "Create an account on console.openapi.com, add credit to the wallet, and under Automotive create a token for the production environment. Each successful lookup costs a few tens of cents; a miss is free."
+      },
+      "settings": {
+        "country": "Country",
+        "countryHelp": "The registry to search. French and Italian plates look the same, so the country cannot be read off the plate.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/es/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Mantener actualizadas las fechas de inspección 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 EU-kontroll. 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Rellena un vehículo a partir de su matrícula francesa, italiana, española, portuguesa o británica: marca, modelo, año, VIN, combustible, cilindrada y más, desde los datos de matriculación del país a través de Openapi.com. Cada consulta se cobra a la cuenta de Openapi del propio taller. No se devuelven datos del propietario.",
+      "fields": {
+        "token": "Token de API",
+        "tokenHelp": "Crea una cuenta en console.openapi.com, añade saldo al monedero y, en Automotive, crea un token para el entorno de producción. Cada consulta con resultado cuesta unas decenas de céntimos; una sin resultado es gratis."
+      },
+      "settings": {
+        "country": "País",
+        "countryHelp": "El registro donde buscar. Las matrículas francesas e italianas son iguales, así que el país no puede deducirse de la matrícula.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/fr/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Tenir à jour les dates de contrôle 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 EU-kontroll 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Remplissez un véhicule à partir de sa plaque française, italienne, espagnole, portugaise ou britannique : marque, modèle, année, VIN, carburant, cylindrée et plus, depuis les données d'immatriculation du pays via Openapi.com. Chaque recherche est facturée sur le compte Openapi de l'atelier. Aucune donnée sur le propriétaire n'est renvoyée.",
+      "fields": {
+        "token": "Jeton d'API",
+        "tokenHelp": "Créez un compte sur console.openapi.com, créditez le portefeuille et, dans Automotive, créez un jeton pour l'environnement de production. Chaque recherche aboutie coûte quelques dizaines de centimes ; une recherche sans résultat est gratuite."
+      },
+      "settings": {
+        "country": "Pays",
+        "countryHelp": "Le registre à interroger. Les plaques françaises et italiennes ont le même format, le pays ne peut donc pas être déduit de la plaque.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/it/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Tieni aggiornate le scadenze di revisione 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 della prossima EU-kontroll 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Compila un veicolo dalla targa francese, italiana, spagnola, portoghese o britannica: marca, modello, anno, VIN, carburante, cilindrata e altro, dai dati di immatricolazione del paese tramite Openapi.com. Ogni ricerca è addebitata sull'account Openapi dell'officina. Non vengono restituiti dati sul proprietario.",
+      "fields": {
+        "token": "Token API",
+        "tokenHelp": "Crea un account su console.openapi.com, ricarica il portafoglio e, sotto Automotive, crea un token per l'ambiente di produzione. Ogni ricerca con esito costa qualche decina di centesimi; una senza esito è gratuita."
+      },
+      "settings": {
+        "country": "Paese",
+        "countryHelp": "Il registro da consultare. Le targhe francesi e italiane hanno lo stesso formato, quindi il paese non si può ricavare dalla targa.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/lt/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Atnaujinti visų šių dirbtuvių transporto priemonių apžiūros terminus",
         "syncInspectionsHelp": "Kartą per valandą neseniai netikrintos transporto priemonės ieškomos pagal numerį ir išsaugoma kita EU-kontroll 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ę."
       }
+    },
+    "openapi-automotive": {
+      "description": "Užpildykite transporto priemonę pagal prancūzišką, itališką, ispanišką, portugališką arba britišką numerį: markė, modelis, metai, VIN, degalai, variklio tūris ir daugiau iš šalies registracijos duomenų per Openapi.com. Kiekviena užklausa apmokestinama iš pačių dirbtuvių Openapi paskyros. Savininko duomenys negrąžinami.",
+      "fields": {
+        "token": "API žetonas",
+        "tokenHelp": "Susikurkite paskyrą console.openapi.com, papildykite piniginę ir skiltyje Automotive sukurkite žetoną gamybinei aplinkai. Kiekviena sėkminga užklausa kainuoja kelias dešimtis centų; nerastas rezultatas nemokamas."
+      },
+      "settings": {
+        "country": "Šalis",
+        "countryHelp": "Registras, kuriame ieškoti. Prancūziški ir itališki numeriai atrodo vienodai, todėl šalies negalima nustatyti iš numerio.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/nb/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Hold EU-kontrollfristen 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 EU-kontroll 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Fyll ut et kjøretøy fra fransk, italiensk, spansk, portugisisk eller britisk registreringsnummer: merke, modell, årsmodell, understellsnummer, drivstoff, slagvolum og mer fra landets registreringsdata via Openapi.com. Hvert oppslag belastes verkstedets egen Openapi-konto. Eieropplysninger leveres ikke.",
+      "fields": {
+        "token": "API-token",
+        "tokenHelp": "Opprett en konto på console.openapi.com, fyll på lommeboken, og lag et token for produksjonsmiljøet under Automotive. Hvert vellykkede oppslag koster noen titalls cent; et bomskudd er gratis."
+      },
+      "settings": {
+        "country": "Land",
+        "countryHelp": "Registeret som skal søkes i. Franske og italienske skilt ser like ut, så landet kan ikke leses av skiltet.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/nl/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Keuringsdatums 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 EU-kontroll-datum opgeslagen. Alleen voertuigen van deze werkplaats worden verstuurd. Een voertuig wordt maandelijks gecontroleerd, en wekelijks zodra de datum binnen twee maanden valt."
       }
+    },
+    "openapi-automotive": {
+      "description": "Vul een voertuig in vanuit het Franse, Italiaanse, Spaanse, Portugese of Britse kenteken: merk, model, bouwjaar, VIN, brandstof, cilinderinhoud en meer uit de registratiegegevens van het land via Openapi.com. Elke opzoeking wordt in rekening gebracht op het eigen Openapi-account van de werkplaats. Er worden geen eigenaarsgegevens teruggegeven.",
+      "fields": {
+        "token": "API-token",
+        "tokenHelp": "Maak een account op console.openapi.com, zet tegoed in de portemonnee en maak onder Automotive een token voor de productieomgeving. Elke geslaagde opzoeking kost enkele tientallen centen; geen resultaat is gratis."
+      },
+      "settings": {
+        "country": "Land",
+        "countryHelp": "Het register om in te zoeken. Franse en Italiaanse kentekens zien er hetzelfde uit, dus het land is niet van het kenteken af te lezen.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/pl/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Aktualizuj terminy przeglądów 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 EU-kontroll 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Uzupełnij pojazd na podstawie francuskiej, włoskiej, hiszpańskiej, portugalskiej lub brytyjskiej tablicy: marka, model, rocznik, VIN, paliwo, pojemność silnika i więcej, z danych rejestracyjnych kraju przez Openapi.com. Każde zapytanie jest rozliczane z własnego konta Openapi warsztatu. Dane właściciela nie są zwracane.",
+      "fields": {
+        "token": "Token API",
+        "tokenHelp": "Załóż konto na console.openapi.com, zasil portfel i w sekcji Automotive utwórz token dla środowiska produkcyjnego. Każde udane zapytanie kosztuje kilkadziesiąt centów; brak wyniku jest bezpłatny."
+      },
+      "settings": {
+        "country": "Kraj",
+        "countryHelp": "Rejestr, w którym szukać. Francuskie i włoskie tablice wyglądają tak samo, więc kraju nie da się odczytać z tablicy.",
+        "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."
+      }
     }
   },
   "jobKinds": {

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

@@ -358,6 +358,19 @@
         "syncInspections": "Manter as datas de inspeção 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 EU-kontroll é 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Preencha um veículo a partir da placa francesa, italiana, espanhola, portuguesa ou britânica: marca, modelo, ano, VIN, combustível, cilindrada e mais, dos dados de registro do país via Openapi.com. Cada consulta é cobrada na conta Openapi da própria oficina. Nenhum dado do proprietário é retornado.",
+      "fields": {
+        "token": "Token de API",
+        "tokenHelp": "Crie uma conta em console.openapi.com, adicione crédito à carteira e, em Automotive, crie um token para o ambiente de produção. Cada consulta bem-sucedida custa algumas dezenas de centavos; uma sem resultado é gratuita."
+      },
+      "settings": {
+        "country": "País",
+        "countryHelp": "O registro a consultar. Placas francesas e italianas têm o mesmo formato, então o país não pode ser deduzido da placa.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/ru/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Обновлять сроки техосмотра для всех автомобилей этой мастерской",
         "syncInspectionsHelp": "Раз в час автомобили, которые давно не проверялись, ищутся по номеру, и сохраняется дата следующего EU-kontroll. Отправляются только автомобили этой мастерской. Автомобиль проверяется ежемесячно, а когда до срока меньше двух месяцев, еженедельно."
       }
+    },
+    "openapi-automotive": {
+      "description": "Заполните данные автомобиля по французскому, итальянскому, испанскому, португальскому или британскому номеру: марка, модель, год, VIN, топливо, объём двигателя и другое из регистрационных данных страны через Openapi.com. Каждый запрос списывается со счёта Openapi самой мастерской. Данные о владельце не передаются.",
+      "fields": {
+        "token": "API-токен",
+        "tokenHelp": "Создайте аккаунт на console.openapi.com, пополните кошелёк и в разделе Automotive создайте токен для производственной среды. Каждый успешный запрос стоит несколько десятков центов; запрос без результата бесплатен."
+      },
+      "settings": {
+        "country": "Страна",
+        "countryHelp": "Реестр, в котором искать. Французские и итальянские номера выглядят одинаково, поэтому страну нельзя определить по номеру.",
+        "bikes": "Искать также мотоциклы",
+        "bikesHelp": "Если номер не принадлежит автомобилю, запрашивается и реестр мотоциклов. Запрос по автомобилю без результата бесплатен, поэтому дополнительных расходов нет, если мотоцикл не найден. Недоступно для Португалии."
+      }
     }
   },
   "jobKinds": {

+ 13 - 0
messages/tr/integrations.json

@@ -358,6 +358,19 @@
         "syncInspections": "Bu atölyedeki tüm araçların muayene tarihlerini güncel tut",
         "syncInspectionsHelp": "Saatte bir, yakın zamanda kontrol edilmemiş araçlar plakayla sorgulanır ve bir sonraki EU-kontroll 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."
       }
+    },
+    "openapi-automotive": {
+      "description": "Fransız, İtalyan, İspanyol, Portekiz veya İngiliz plakasından aracı doldurun: marka, model, yıl, VIN, yakıt, motor hacmi ve daha fazlası, ülkenin tescil verilerinden Openapi.com aracılığıyla. Her sorgu atölyenin kendi Openapi hesabına yansıtılır. Araç sahibi bilgisi döndürülmez.",
+      "fields": {
+        "token": "API belirteci",
+        "tokenHelp": "console.openapi.com'da bir hesap oluşturun, cüzdana bakiye yükleyin ve Automotive altında üretim ortamı için bir belirteç oluşturun. Her başarılı sorgu birkaç on sent tutar; sonuçsuz sorgu ücretsizdir."
+      },
+      "settings": {
+        "country": "Ülke",
+        "countryHelp": "Aranacak sicil. Fransız ve İtalyan plakaları aynı görünür, bu yüzden ülke plakadan okunamaz.",
+        "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."
+      }
     }
   },
   "jobKinds": {

+ 7 - 0
public/images/integrations/openapi-automotive.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.7 7.4 26.6 26.6" width="48" height="48" role="img" aria-label="Openapi">
+  <title>Openapi</title>
+  <path fill="#6d3e91" fill-rule="evenodd" d="M14,7.4c7.4,0,13.3,6,13.3,13.3s-6,13.3-13.3,13.3s-13.3-6-13.3-13.3S6.6,7.4,14,7.4z"/>
+  <path fill="#fff" d="M14.1,24.3L14.1,24.3c0-1,0.8-1.7,1.7-1.7h6.5c1,0,1.7,0.8,1.7,1.7l0,0c0,1-0.8,1.7-1.7,1.7h-6.5C14.9,26.1,14.1,25.3,14.1,24.3z"/>
+  <path fill="#fff" d="M13.3,21L13.3,21c-0.7,0.7-1.8,0.7-2.5,0l-4.6-4.6c-0.7-0.7-0.7-1.8,0-2.5l0,0c0.7-0.7,1.8-0.7,2.5,0l4.6,4.6C14,19.2,14,20.3,13.3,21z"/>
+  <path fill="#fff" d="M13.3,18.6L13.3,18.6c0.7,0.7,0.7,1.8,0,2.5l-4.6,4.6c-0.7,0.7-1.8,0.7-2.5,0l0,0c-0.7-0.7-0.7-1.8,0-2.5l4.6-4.6C11.5,17.9,12.6,17.9,13.3,18.6z"/>
+</svg>

+ 29 - 0
src/__tests__/features/integrations/fixtures/openapi-automotive/ES-car.json

@@ -0,0 +1,29 @@
+{
+  "data": {
+    "LicensePlate": "5428GXS",
+    "Description": "RENAULT MEGANE",
+    "CarMake": "RENAULT",
+    "CarModel": "MEGANE",
+    "MakeDescription": "RENAULT",
+    "ModelDescription": "MEGANE",
+    "EngineSize": "1461",
+    "VehicleIdentificationNumber": null,
+    "RegistrationYear": "2010",
+    "RegistrationDate": "06/07/2010",
+    "Variation": "EXPRESSION 1.5DCI 85",
+    "Seats": null,
+    "VariantType": "Diesel 1461 cc 5 puertas",
+    "VehicleType": "Car",
+    "Fuel": "Diesel",
+    "IndicativePrice": null,
+    "Doors": "5",
+    "AllTerain": null,
+    "KType": null,
+    "DynamicPower": "85.0",
+    "Stolen": null,
+    "TimeStamp": 1744108907
+  },
+  "success": true,
+  "message": "",
+  "error": null
+}

+ 55 - 0
src/__tests__/features/integrations/fixtures/openapi-automotive/FR-bike.json

@@ -0,0 +1,55 @@
+{
+  "data": {
+    "LicensePlate": "DJ455BR",
+    "Description": "BMW R 1200",
+    "RegistrationYear": "2014",
+    "CarMake": "BMW",
+    "CarModel": "R 1200",
+    "EngineSize": "11",
+    "FuelType": "ESSENCE",
+    "MakeDescription": "BMW",
+    "ModelDescription": "R 1200",
+    "Immobiliser": "",
+    "IndicativeValue": 0,
+    "DriverSide": "",
+    "BodyStyle": "TOUS TERRAINS",
+    "RegistrationDate": "2014-07-30",
+    "ExtendedData": {
+      "anneeSortie": "2014",
+      "boiteDeVitesse": "MECANIQUE",
+      "carburantVersion": "E",
+      "carrosserieVersion": "",
+      "classeSra": "",
+      "libVersion": "",
+      "libelleModele": "R 1200",
+      "marque": "",
+      "modele": "",
+      "produit": "",
+      "puissance": "11",
+      "version": "",
+      "cleCarrosserie": "",
+      "groupeSra": "",
+      "nbPlace": "2",
+      "datePremiereMiseCirculation": "30072014",
+      "questionBatterie": "",
+      "electrique": "",
+      "genre": "MTT2",
+      "typeVehicule": "",
+      "numSerieMoteur": "WB10A0101EZ151151",
+      "valeurANeufSRA": "",
+      "niveauRisqueVol": "",
+      "protectionConstructeur": "",
+      "puissanceDyn": "",
+      "segmentVeh": "",
+      "KtypeId": "",
+      "EngineCC": "",
+      "Co2": "0",
+      "Cylinders": "",
+      "CNIT": "L3EBMWM2009P056"
+    },
+    "TimeStamp": 1744213606
+  },
+  "success": true,
+  "message": "",
+  "error": null
+}

+ 55 - 0
src/__tests__/features/integrations/fixtures/openapi-automotive/FR-car.json

@@ -0,0 +1,55 @@
+{
+  "data": {
+    "LicensePlate": "EG258MA",
+    "Description": "RENAULT SCÉNIC III",
+    "RegistrationYear": "2016",
+    "CarMake": "RENAULT",
+    "CarModel": "SCÉNIC III",
+    "EngineSize": "5",
+    "FuelType": "DIESEL",
+    "MakeDescription": "RENAULT",
+    "ModelDescription": "SCÉNIC III",
+    "Immobiliser": "",
+    "IndicativeValue": 0,
+    "DriverSide": "",
+    "BodyStyle": "MONOSPACE COMPACT",
+    "RegistrationDate": "2016-06-24",
+    "ExtendedData": {
+      "anneeSortie": "2016",
+      "boiteDeVitesse": "MECANIQUE",
+      "carburantVersion": "D",
+      "carrosserieVersion": "25",
+      "classeSra": "L",
+      "libVersion": "1.5 dCi 1461cm3 110cv ",
+      "libelleModele": "SCÉNIC III",
+      "marque": "RE",
+      "modele": "81",
+      "produit": "A1",
+      "puissance": "5",
+      "version": "463",
+      "cleCarrosserie": "25MSBK5",
+      "groupeSra": "31",
+      "nbPlace": "5",
+      "datePremiereMiseCirculation": "24062016",
+      "questionBatterie": "N",
+      "electrique": "N",
+      "genre": "VP",
+      "typeVehicule": "MS",
+      "numSerieMoteur": "VF1JZ890H55864144",
+      "valeurANeufSRA": "",
+      "niveauRisqueVol": "0",
+      "protectionConstructeur": "S7",
+      "puissanceDyn": "81",
+      "segmentVeh": "M1",
+      "KtypeId": "5853",
+      "EngineCC": "1461",
+      "Co2": "105",
+      "Cylinders": "4",
+      "CNIT": "M10RENVP472E768"
+    },
+    "TimeStamp": 1786618522
+  },
+  "success": true,
+  "message": "",
+  "error": null
+}

+ 27 - 0
src/__tests__/features/integrations/fixtures/openapi-automotive/IT-car.json

@@ -0,0 +1,27 @@
+{
+  "data": {
+    "Description": "CITROEN C4 Cactus",
+    "RegistrationYear": "2018",
+    "CarMake": "CITROEN",
+    "CarModel": "C4 Cactus",
+    "EngineSize": "1560",
+    "FuelType": "Diesel",
+    "MakeDescription": "CITROEN",
+    "ModelDescription": "C4 Cactus",
+    "Immobiliser": "",
+    "NumberOfDoors": "",
+    "Version": "CITROEN BlueHDi 100 S&S Live ( 2/2018 )",
+    "ABS": "",
+    "AirBag": "",
+    "Vin": "",
+    "KType": "",
+    "PowerCV": 99,
+    "PowerKW": 73,
+    "PowerFiscal": 17,
+    "TimeStamp": 1710153399,
+    "LicensePlate": "FS918BG"
+  },
+  "success": true,
+  "message": "",
+  "error": null
+}

+ 29 - 0
src/__tests__/features/integrations/fixtures/openapi-automotive/PT-car.json

@@ -0,0 +1,29 @@
+{
+  "data": {
+    "LicensePlate": "AA-00-AA",
+    "ABICode": "939747",
+    "Description": "VOLKSWAGEN ID.4 ID.4 77 kWh Pro Urban",
+    "RegistrationYear": "2025",
+    "CarMake": "VOLKSWAGEN",
+    "CarModel": "ID.4",
+    "EngineSize": "0",
+    "FuelType": "E",
+    "MakeDescription": "VOLKSWAGEN",
+    "ModelDescription": "ID.4",
+    "Immobiliser": "",
+    "NumberOfSeats": "5",
+    "IndicativeValue": "",
+    "DriverSide": "",
+    "Version": "ID.4 77 kWh Pro Urban",
+    "Colour": "",
+    "VechileIdentificationNumber": "",
+    "GrossWeight": "1979",
+    "NetWeight": "1979",
+    "Imported": 0,
+    "RegistrationDate": "01/4/2025",
+    "TimeStamp": 1743605674
+  },
+  "success": true,
+  "message": "",
+  "error": null
+}

+ 31 - 0
src/__tests__/features/integrations/fixtures/openapi-automotive/UK-car.json

@@ -0,0 +1,31 @@
+{
+  "data": {
+    "ABICode": "31019211",
+    "Description": "Maserati Levante D V6 Auto",
+    "RegistrationYear": "2017",
+    "CarMake": "Maserati",
+    "CarModel": "Levante D V6 Auto",
+    "BodyStyle": "SUV",
+    "EngineSize": "2987",
+    "NumberOfDoors": "5",
+    "Transmission": "Automatic",
+    "FuelType": "Diesel",
+    "MakeDescription": "Maserati",
+    "ModelDescription": "Levante D V6 Auto",
+    "Immobiliser": "",
+    "NumberOfSeats": "5",
+    "IndicativeValue": 0,
+    "DriverSide": "",
+    "VehicleInsuranceGroup": 0,
+    "VehicleInsuranceGroupOutOf": 50,
+    "VehicleIdentificationNumber": "ZN6TU61C00X248858",
+    "EngineCode": "B630WM",
+    "EngineNumber": "B630WM",
+    "Colour": "Black",
+    "TimeStamp": 1709827711,
+    "LicensePlate": "CT17MLE"
+  },
+  "success": true,
+  "message": "",
+  "error": null
+}

+ 485 - 0
src/__tests__/features/integrations/openapi-automotive.test.ts

@@ -0,0 +1,485 @@
+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 AutomotiveRecord,
+  connector,
+  engineSize,
+  fuelType,
+  isoDate,
+  mapRecord,
+  normalisePlate,
+  titleCase,
+  transmission,
+} from '@/integrations/openapi-automotive/server'
+
+/**
+ * Records shaped the way each country's endpoint answers, taken from the
+ * vendor's published examples. The shapes are the contract: France hides
+ * the capacity and VIN in ExtendedData, Spain misspells its VIN field, the
+ * United Kingdom is the only one that names the gearbox and engine code.
+ */
+const FRANCE: AutomotiveRecord = {
+  LicensePlate: 'GQ478ZV',
+  Description: 'PEUGEOT 207',
+  RegistrationYear: '2008',
+  CarMake: 'PEUGEOT',
+  CarModel: '207',
+  EngineSize: '7',
+  FuelType: 'ESSENCE',
+  MakeDescription: 'PEUGEOT',
+  ModelDescription: '207',
+  BodyStyle: 'BERLINE',
+  RegistrationDate: '2008-10-22',
+  ExtendedData: {
+    EngineCC: '1598',
+    boiteDeVitesse: 'MECANIQUE',
+    numSerieMoteur: 'VF3WA5FWF34246147',
+    datePremiereMiseCirculation: '22102008',
+    genre: 'VP',
+  },
+}
+
+const UNITED_KINGDOM: AutomotiveRecord = {
+  LicensePlate: 'LT17MLE',
+  RegistrationYear: '2017',
+  CarMake: 'Maserati',
+  CarModel: 'Levante D V6 Auto',
+  BodyStyle: 'SUV',
+  EngineSize: '2987',
+  Transmission: 'Automatic',
+  FuelType: 'Diesel',
+  VehicleIdentificationNumber: 'ZN6TU61C00X248858',
+  EngineCode: 'B630WM',
+  Colour: 'Black',
+}
+
+const SPAIN: AutomotiveRecord = {
+  LicensePlate: '5776CNS',
+  CarMake: 'AUDI',
+  CarModel: 'A3',
+  EngineSize: '1595',
+  VechileIdentificationNumber: 'WAUZZZ8P0XA123456',
+  RegistrationYear: '2003',
+  RegistrationDate: '17/11/2003',
+  Fuel: 'Gasolina',
+}
+
+const PORTUGAL: AutomotiveRecord = {
+  LicensePlate: 'AA-00-AA',
+  RegistrationYear: '2025',
+  CarMake: 'VOLKSWAGEN',
+  CarModel: 'ID.4',
+  EngineSize: '0',
+  FuelType: 'E',
+  GrossWeight: '2660',
+  NetWeight: '2124',
+  RegistrationDate: '01/4/2025',
+}
+
+/**
+ * The vendor's plate formats, copied from the path parameter patterns in its
+ * OpenAPI document. A plate that fails these is refused with a 406 before any
+ * registry is asked, so what leaves normalisePlate has to pass them.
+ */
+const VENDOR_PLATE_PATTERNS: Record<string, RegExp> = {
+  FR: /^[a-zA-Z]{2}[0-9]{3}[a-zA-Z]{2}$|^[0-9]{2,4}[a-zA-Z]{2,3}[0-9]{2}$/,
+  IT: /^[a-zA-Z]{2}[0-9]{3}[a-zA-Z]{2}$|^[a-zA-Z]{2}[a-zA-Z0-9]{6}$/,
+  ES: /^[0-9]{4}[a-zA-Z]{3}$|^[a-zA-Z]{1,2}[0-9]{1,6}$|^[a-zA-Z]{1,2}[0-9]{4}[a-zA-Z]{1,2}$/,
+  // The published pattern omits the 2005 to 2020 series (00-AA-00); the
+  // sandbox accepted it on 3 September 2026, so it is allowed here too.
+  PT: /^[0-9]{4}[a-zA-Z]{2}$|^[a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2}$|^[0-9]{2}[a-zA-Z]{2}[0-9]{2}$|^[0-9]{2}-[0-9]{2}-[a-zA-Z]{2}$|^[a-zA-Z]{2}-[0-9]{2}-[a-zA-Z]{2}$/,
+  GB: /^[a-zA-Z]{1}[0-9]{1,3}[a-zA-Z]{3}$|^[a-zA-Z]{2}[0-9]{2}[a-zA-Z]{3}$|^[a-zA-Z]{3}[0-9]{4}$/,
+}
+
+/** Recorded answers from the vendor's sandbox, one per endpoint, taken 3 September 2026. */
+const FIXTURES = path.join(__dirname, 'fixtures/openapi-automotive')
+function sandbox(name: string): AutomotiveRecord {
+  const body = JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8'))
+  expect(body.success).toBe(true)
+  return body.data as AutomotiveRecord
+}
+
+function envelope(data: unknown, status = 200, headers?: Record<string, string>) {
+  return Response.json({ data, success: true, message: '', error: null }, { status, headers })
+}
+
+function context(
+  fetchImpl: (url: string, init?: RequestInit) => Promise<Response>,
+  settings: Record<string, unknown> = { country: 'FR' }
+) {
+  const log = vi.fn(async () => undefined)
+  const ctx = {
+    connection: {
+      id: 'c1',
+      organizationId: 'org',
+      connectorId: 'openapi-automotive',
+      settings,
+      state: {},
+      externalAccountId: null,
+    },
+    credentials: { token: 'secret-token' },
+    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/Paris',
+    appUrl: 'https://app.test',
+  } as unknown as ConnectorContext
+  return { ctx, log, fetch: ctx.http.fetch as ReturnType<typeof vi.fn> }
+}
+
+describe('openapi automotive mapper', () => {
+  it('reads a French record, capacity and VIN from the SIV extension', () => {
+    expect(mapRecord(FRANCE)).toEqual({
+      make: 'Peugeot',
+      model: '207',
+      year: 2008,
+      vin: 'VF3WA5FWF34246147',
+      licensePlate: 'GQ478ZV',
+      fuelType: 'gasoline',
+      transmission: 'manual',
+      engineSize: '1.6 L',
+      vehicleClass: 'BERLINE',
+      firstRegistered: '2008-10-22',
+    })
+  })
+
+  it('reads a British record with gearbox, engine code and colour', () => {
+    expect(mapRecord(UNITED_KINGDOM)).toEqual({
+      make: 'Maserati',
+      model: 'Levante D V6 Auto',
+      year: 2017,
+      vin: 'ZN6TU61C00X248858',
+      licensePlate: 'LT17MLE',
+      color: 'Black',
+      fuelType: 'diesel',
+      transmission: 'automatic',
+      engineSize: '3.0 L',
+      engineCode: 'B630WM',
+      vehicleClass: 'SUV',
+    })
+  })
+
+  it('reads a Spanish record with its day-first date and misspelt VIN field', () => {
+    expect(mapRecord(SPAIN)).toMatchObject({
+      make: 'Audi',
+      model: 'A3',
+      year: 2003,
+      vin: 'WAUZZZ8P0XA123456',
+      fuelType: 'gasoline',
+      engineSize: '1.6 L',
+      firstRegistered: '2003-11-17',
+    })
+  })
+
+  it('reads a Portuguese electric car with weights and no engine size', () => {
+    const result = mapRecord(PORTUGAL)
+    expect(result).toMatchObject({
+      make: 'Volkswagen',
+      model: 'ID.4',
+      year: 2025,
+      fuelType: 'electric',
+      firstRegistered: '2025-04-01',
+      weights: { kerb: 2124, grossMax: 2660 },
+    })
+    expect(result.engineSize).toBeUndefined()
+    expect(result.vin).toBeUndefined()
+  })
+
+  it('leaves out what the registry did not say, keeping the plate asked for', () => {
+    expect(mapRecord({})).toEqual({})
+    expect(mapRecord({}, 'AB123CD')).toEqual({ licensePlate: 'AB123CD' })
+  })
+
+  it('names fuels in five languages and calls two fuels a hybrid', () => {
+    expect(fuelType('ESSENCE')).toBe('gasoline')
+    expect(fuelType('Benzina')).toBe('gasoline')
+    expect(fuelType('Petrol')).toBe('gasoline')
+    expect(fuelType('Gasóleo')).toBe('diesel')
+    expect(fuelType('Elettrica')).toBe('electric')
+    expect(fuelType('Hybrid Electric')).toBe('hybrid')
+    expect(fuelType('Petrol/Electric')).toBe('hybrid')
+    expect(fuelType('Híbrido enchufable')).toBe('hybrid')
+    expect(fuelType('GPL')).toBeUndefined()
+    expect(fuelType('')).toBeUndefined()
+  })
+
+  it('normalises gearboxes, capacities and dates', () => {
+    expect(transmission('AUTOMATIQUE')).toBe('automatic')
+    expect(transmission('Manuale')).toBe('manual')
+    expect(transmission('CVT')).toBe('cvt')
+    expect(transmission('')).toBeUndefined()
+    expect(engineSize('1598')).toBe('1.6 L')
+    expect(engineSize(1281)).toBe('1.3 L')
+    // French fiscal horsepower lives in EngineSize and is not a capacity.
+    expect(engineSize('7')).toBeUndefined()
+    expect(engineSize('0')).toBeUndefined()
+    expect(isoDate('2008-10-22')).toBe('2008-10-22')
+    expect(isoDate('17/11/2003')).toBe('2003-11-17')
+    expect(isoDate('1/4/2025')).toBe('2025-04-01')
+    expect(isoDate('22102008')).toBe('2008-10-22')
+    expect(isoDate('yesterday')).toBeUndefined()
+  })
+
+  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')
+  })
+
+  it('normalises plates the way each country prints them', () => {
+    expect(normalisePlate('ab-123-cd')).toBe('AB123CD')
+    expect(normalisePlate('90-27-QL')).toBe('9027QL')
+    expect(normalisePlate('LT17 MLE')).toBe('LT17MLE')
+    expect(normalisePlate(' 5776 cns ')).toBe('5776CNS')
+  })
+
+  it('turns plates as people type them into what the vendor accepts', () => {
+    const typed: Record<string, string[]> = {
+      // SIV since 2009, and the older FNI series still on the road.
+      FR: ['AB-123-CD', 'ab 123 cd', '123 ABC 12', '9876 ZZ 75'],
+      IT: ['ZR 567 ZY', 'zr567zy', 'MI 684033'],
+      ES: ['5776 CNS', '5776-CNS', 'M 1234 AB', 'B-123456'],
+      PT: ['90-27-QL', '9027QL', 'AA 00 AA', '00-AA-00'],
+      GB: ['LT17 MLE', 'lt17mle', 'A123 BCD', 'ABC 1234', 'A1 BCD'],
+    }
+    for (const [country, plates] of Object.entries(typed)) {
+      for (const plate of plates) {
+        expect(normalisePlate(plate), `${country} ${plate}`).toMatch(VENDOR_PLATE_PATTERNS[country])
+      }
+    }
+    // Two-letter Portuguese groups keep their meaning without hyphens; the vendor accepts both.
+    expect(normalisePlate('AA-00-AA')).toMatch(VENDOR_PLATE_PATTERNS.PT)
+  })
+})
+
+/**
+ * What the vendor's sandbox actually sends, country by country. The spec's
+ * examples and the live shapes differ in places (Spain spells its VIN field
+ * correctly on the wire, Italy puts the plate last, Portugal keeps hyphens in
+ * the plate), and these are the shapes the mapper has to keep reading.
+ */
+describe('openapi automotive sandbox contract', () => {
+  it('France: capacity and VIN come from the SIV extension, fiscal power is ignored', () => {
+    const record = sandbox('FR-car')
+    expect(record.EngineSize).toBe('5')
+    expect(record.ExtendedData?.EngineCC).toBe('1461')
+    expect(mapRecord(record)).toEqual({
+      make: 'Renault',
+      model: 'SCÉNIC III',
+      year: 2016,
+      vin: 'VF1JZ890H55864144',
+      licensePlate: 'EG258MA',
+      fuelType: 'diesel',
+      transmission: 'manual',
+      engineSize: '1.5 L',
+      vehicleClass: 'MONOSPACE COMPACT',
+      firstRegistered: '2016-06-24',
+    })
+  })
+
+  it('France, motorcycles: same shape, body type and gearbox included', () => {
+    expect(mapRecord(sandbox('FR-bike'))).toEqual({
+      make: 'BMW',
+      model: 'R 1200',
+      year: 2014,
+      vin: 'WB10A0101EZ151151',
+      licensePlate: 'DJ455BR',
+      fuelType: 'gasoline',
+      transmission: 'manual',
+      vehicleClass: 'TOUS TERRAINS',
+      firstRegistered: '2014-07-30',
+    })
+  })
+
+  it('Italy: capacity in cc, fuel in Italian, no VIN or date on the wire', () => {
+    const record = sandbox('IT-car')
+    expect(record.Vin).toBe('')
+    expect(mapRecord(record)).toEqual({
+      make: 'Citroen',
+      model: 'C4 Cactus',
+      year: 2018,
+      licensePlate: 'FS918BG',
+      fuelType: 'diesel',
+      engineSize: '1.6 L',
+    })
+  })
+
+  it('United Kingdom: the richest record, with gearbox, engine code and colour', () => {
+    expect(mapRecord(sandbox('UK-car'))).toEqual({
+      make: 'Maserati',
+      model: 'Levante D V6 Auto',
+      year: 2017,
+      vin: 'ZN6TU61C00X248858',
+      licensePlate: 'CT17MLE',
+      color: 'Black',
+      fuelType: 'diesel',
+      transmission: 'automatic',
+      engineSize: '3.0 L',
+      engineCode: 'B630WM',
+      vehicleClass: 'SUV',
+    })
+  })
+
+  it('Spain: day-first date, fuel under "Fuel", VIN field spelt correctly and empty', () => {
+    const record = sandbox('ES-car')
+    expect(record.RegistrationDate).toMatch(/^\d{2}\/\d{2}\/\d{4}$/)
+    expect(record).toHaveProperty('VehicleIdentificationNumber')
+    expect(mapRecord(record)).toEqual({
+      make: 'Renault',
+      model: 'MEGANE',
+      year: 2010,
+      licensePlate: '5428GXS',
+      fuelType: 'diesel',
+      engineSize: '1.5 L',
+      firstRegistered: '2010-07-06',
+    })
+  })
+
+  it('Portugal: fuel as a letter, weights in kilograms, plate with hyphens, no capacity for an EV', () => {
+    const record = sandbox('PT-car')
+    expect(record.FuelType).toBe('E')
+    expect(record.EngineSize).toBe('0')
+    expect(mapRecord(record)).toEqual({
+      make: 'Volkswagen',
+      model: 'ID.4',
+      year: 2025,
+      licensePlate: 'AA-00-AA',
+      fuelType: 'electric',
+      firstRegistered: '2025-04-01',
+      weights: { kerb: 1979, grossMax: 1979 },
+    })
+  })
+})
+
+describe('openapi automotive connector', () => {
+  it('asks the country endpoint with a Bearer token and maps a match', async () => {
+    const { ctx, fetch, log } = context(async () => envelope(FRANCE))
+    const result = await connector.lookupVehicle?.(ctx, { plate: 'gq-478-zv' })
+    expect(result?.make).toBe('Peugeot')
+    const [url, init] = fetch.mock.calls[0]
+    expect(url).toBe('https://automotive.openapi.com/FR-car/GQ478ZV')
+    expect((init as RequestInit).headers).toMatchObject({
+      Authorization: 'Bearer secret-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('GQ478ZV')
+  })
+
+  it('picks the endpoint from the country setting', async () => {
+    const { ctx, fetch } = context(async () => envelope(UNITED_KINGDOM), { country: 'GB' })
+    await connector.lookupVehicle?.(ctx, { plate: 'LT17 MLE' })
+    expect(fetch.mock.calls[0][0]).toBe('https://automotive.openapi.com/UK-car/LT17MLE')
+  })
+
+  it('refuses to guess when no country is set', async () => {
+    const { ctx, fetch } = context(async () => envelope(FRANCE), {})
+    await expect(connector.lookupVehicle?.(ctx, { plate: 'GQ478ZV' })).rejects.toThrow(
+      /choose a country/
+    )
+    expect(fetch).not.toHaveBeenCalled()
+  })
+
+  it('finds nothing for a VIN alone and spends no lookup on it', async () => {
+    const { ctx, fetch } = context(async () => envelope(FRANCE))
+    expect(await connector.lookupVehicle?.(ctx, { vin: 'VF3WA5FWF34246147' })).toBeNull()
+    expect(fetch).not.toHaveBeenCalled()
+  })
+
+  it('returns null on 404 and on an empty answer', async () => {
+    const notFound = context(async () =>
+      Response.json({ data: [], success: true, message: 'car not found' }, { status: 404 })
+    )
+    expect(await connector.lookupVehicle?.(notFound.ctx, { plate: 'GQ478ZV' })).toBeNull()
+    const empty = context(async () => envelope([]))
+    expect(await connector.lookupVehicle?.(empty.ctx, { plate: 'GQ478ZV' })).toBeNull()
+  })
+
+  it('tries the motorcycle endpoint after a miss only when asked to', async () => {
+    const bike: AutomotiveRecord = { CarMake: 'BMW', CarModel: 'R 1200', FuelType: 'ESSENCE' }
+    const answer = async (url: string) =>
+      url.includes('FR-bike')
+        ? envelope(bike)
+        : Response.json({ data: [], success: true, message: 'car not found' }, { status: 404 })
+    const on = context(answer, { country: 'FR', bikes: true })
+    const found = await connector.lookupVehicle?.(on.ctx, { plate: 'DJ455BR' })
+    expect(found?.model).toBe('R 1200')
+    expect(on.fetch.mock.calls.map((c) => c[0])).toEqual([
+      'https://automotive.openapi.com/FR-car/DJ455BR',
+      'https://automotive.openapi.com/FR-bike/DJ455BR',
+    ])
+    const off = context(answer, { country: 'FR' })
+    expect(await connector.lookupVehicle?.(off.ctx, { plate: 'DJ455BR' })).toBeNull()
+    expect(off.fetch).toHaveBeenCalledTimes(1)
+    // Portugal has no motorcycle endpoint, so the setting changes nothing there.
+    const pt = context(answer, { country: 'PT', bikes: true })
+    expect(await connector.lookupVehicle?.(pt.ctx, { plate: '9027QL' })).toBeNull()
+    expect(pt.fetch).toHaveBeenCalledTimes(1)
+  })
+
+  it('polls the status endpoint while the vendor is still fetching', async () => {
+    const pending = { state: 'PENDING', id: '66a8ed9b82cf2ae627012068' }
+    let polls = 0
+    const { ctx, fetch } = context(async (url) => {
+      if (url.includes('check_id')) {
+        polls += 1
+        return polls < 2 ? envelope(pending, 200, { 'Retry-After': '0' }) : envelope(UNITED_KINGDOM)
+      }
+      return envelope(pending, 302, { 'Retry-After': '0' })
+    })
+    ctx.connection.settings = { country: 'GB' }
+    const result = await connector.lookupVehicle?.(ctx, { plate: 'LT17MLE' })
+    expect(result?.make).toBe('Maserati')
+    expect(fetch.mock.calls.map((c) => c[0])).toEqual([
+      'https://automotive.openapi.com/UK-car/LT17MLE',
+      'https://automotive.openapi.com/check_id/66a8ed9b82cf2ae627012068',
+      'https://automotive.openapi.com/check_id/66a8ed9b82cf2ae627012068',
+    ])
+  })
+
+  it('gives up on a lookup that stays pending', async () => {
+    const pending = { state: 'PENDING', id: 'abc' }
+    const { ctx, fetch } = context(async () => envelope(pending, 200, { 'Retry-After': '0' }))
+    await expect(connector.lookupVehicle?.(ctx, { plate: 'GQ478ZV' })).rejects.toThrow(
+      /still fetching/
+    )
+    expect(fetch.mock.calls.length).toBe(6)
+  })
+
+  it('explains a rejected token, an empty wallet and a bad plate format', async () => {
+    const denied = context(async () => Response.json({ success: false }, { status: 401 }))
+    await expect(connector.lookupVehicle?.(denied.ctx, { plate: 'GQ478ZV' })).rejects.toThrow(
+      /rejected the token/
+    )
+    const broke = context(async () => Response.json({ success: false }, { status: 402 }))
+    await expect(connector.lookupVehicle?.(broke.ctx, { plate: 'GQ478ZV' })).rejects.toThrow(
+      /no credit/
+    )
+    const format = context(async () => Response.json({ success: false }, { status: 406 }))
+    await expect(connector.lookupVehicle?.(format.ctx, { plate: 'GQ478ZV' })).rejects.toThrow(
+      /not a valid plate/
+    )
+  })
+
+  it('proves a token against the status endpoint without buying a lookup', async () => {
+    const good = context(async () =>
+      Response.json({ success: false, message: 'id not valid', error: 801 }, { status: 406 })
+    )
+    expect(await connector.test(good.ctx)).toEqual({ ok: true })
+    expect(good.fetch.mock.calls[0][0]).toBe(
+      'https://automotive.openapi.com/check_id/000000000000000000000000'
+    )
+    const bad = context(async () => Response.json({ success: false }, { status: 401 }))
+    expect((await connector.test(bad.ctx)).ok).toBe(false)
+    const noToken = context(async () => envelope({}))
+    noToken.ctx.credentials = {}
+    expect((await connector.test(noToken.ctx)).ok).toBe(false)
+    expect(noToken.fetch).not.toHaveBeenCalled()
+    const noCountry = context(async () => envelope({}), {})
+    expect((await connector.test(noCountry.ctx)).message).toMatch(/country/)
+    expect(noCountry.fetch).not.toHaveBeenCalled()
+  })
+})

+ 48 - 0
src/integrations/openapi-automotive/manifest.ts

@@ -0,0 +1,48 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * Openapi.com's Automotive API: technical vehicle data by plate for France,
+ * Italy, Spain, Portugal and the United Kingdom, from each country's
+ * registration data through one token and one response shape.
+ *
+ * The token is the workshop's own, bought on console.openapi.com and billed
+ * per lookup, so the platform never carries a bill it cannot attribute. The
+ * plate formats of France and Italy are identical, so the country cannot be
+ * read off the plate and is a setting instead. No owner data comes back.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'openapi-automotive',
+  name: 'Openapi Automotive',
+  category: 'registry',
+  countries: ['FR', 'IT', 'ES', 'PT', 'GB'],
+  logo: '/images/integrations/openapi-automotive.svg',
+  docs: '/docs/integrations/openapi-automotive',
+  auth: {
+    type: 'api-key',
+    fields: [{ key: 'token', label: 'token', type: 'password', required: true, help: 'tokenHelp' }],
+  },
+  capabilities: ['vehicle.lookup'],
+  settings: [
+    {
+      key: 'country',
+      type: 'select',
+      label: 'country',
+      help: 'countryHelp',
+      required: true,
+      options: [
+        { value: 'FR', label: 'France' },
+        { value: 'IT', label: 'Italia' },
+        { value: 'ES', label: 'España' },
+        { value: 'PT', label: 'Portugal' },
+        { value: 'GB', label: 'United Kingdom' },
+      ],
+    },
+    {
+      key: 'bikes',
+      type: 'boolean',
+      label: 'bikes',
+      help: 'bikesHelp',
+      default: false,
+    },
+  ],
+}

+ 374 - 0
src/integrations/openapi-automotive/server.ts

@@ -0,0 +1,374 @@
+import type {
+  ConnectorContext,
+  ConnectorServer,
+  VehicleLookupQuery,
+  VehicleLookupResult,
+} from '@/features/integrations/Lib/types'
+import { manifest } from './manifest'
+
+/**
+ * Openapi.com Automotive: one GET per vehicle at /<country>-car/<plate>,
+ * with the workshop's token as a Bearer. Every 200 is billed to the
+ * workshop's wallet, a 404 says no such vehicle, 402 an empty wallet and 406
+ * a plate the country's format rejects. Some lookups come back 302 with a
+ * request id instead of a record, and /check_id/<id> is polled until the
+ * record is ready.
+ *
+ * Plates are personal data in every country served, so nothing here writes
+ * one into the connection log; the log records outcomes only.
+ */
+const BASE_URL = 'https://automotive.openapi.com'
+
+/** How long the form waits on a lookup that the vendor answers asynchronously. */
+const MAX_POLLS = 5
+const DEFAULT_POLL_MS = 1000
+const MAX_POLL_MS = 3000
+
+/** A request id shaped like the vendor's but never issued, for proving a token without a lookup. */
+const PROBE_ID = '000000000000000000000000'
+
+export type Country = 'FR' | 'IT' | 'ES' | 'PT' | 'GB'
+
+const ENDPOINTS: Record<Country, { car: string; bike?: string }> = {
+  FR: { car: 'FR-car', bike: 'FR-bike' },
+  IT: { car: 'IT-car', bike: 'IT-bike' },
+  ES: { car: 'ES-car', bike: 'ES-bike' },
+  PT: { car: 'PT-car' },
+  GB: { car: 'UK-car', bike: 'UK-bike' },
+}
+
+export interface Envelope<T> {
+  success?: boolean
+  message?: string
+  error?: number | null
+  data?: T | T[] | null
+}
+
+/** What a lookup returns while the vendor is still fetching it. */
+export interface Pending {
+  state?: string
+  id?: string
+}
+
+/**
+ * The union of what the five car endpoints return. Each country fills a
+ * different subset, spells the VIN field differently, and puts the engine
+ * in a different unit, so every path is optional and the mapper reads
+ * whichever is there.
+ */
+export interface AutomotiveRecord {
+  LicensePlate?: string
+  Description?: string
+  RegistrationYear?: string
+  RegistrationDate?: string
+  CarMake?: string
+  CarModel?: string
+  MakeDescription?: string
+  ModelDescription?: string
+  /** Cubic centimetres in most countries; France puts its fiscal horsepower here. */
+  EngineSize?: string | number
+  FuelType?: string
+  Fuel?: string
+  Transmission?: string
+  Colour?: string
+  BodyStyle?: string
+  VehicleType?: string
+  EngineCode?: string
+  Vin?: string
+  VehicleIdentificationNumber?: string
+  /** Spain and Portugal, spelt as the vendor spells it. */
+  VechileIdentificationNumber?: string
+  GrossWeight?: string
+  NetWeight?: string
+  /** France only: the SIV record behind the summary. */
+  ExtendedData?: {
+    EngineCC?: string
+    boiteDeVitesse?: string
+    numSerieMoteur?: string
+    datePremiereMiseCirculation?: string
+    genre?: string
+  }
+}
+
+function isPending(data: unknown): data is Pending {
+  return (
+    typeof data === 'object' &&
+    data !== null &&
+    typeof (data as Pending).state === 'string' &&
+    typeof (data as Pending).id === 'string'
+  )
+}
+
+function text(value: unknown): string | undefined {
+  if (typeof value === 'number') return String(value)
+  if (typeof value !== 'string') return undefined
+  const trimmed = value.trim()
+  return trimmed ? trimmed : undefined
+}
+
+/** "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
+ * does not know leaves the field alone rather than guessing.
+ */
+export function fuelType(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  const v = value.toLowerCase()
+  const hybrid = /hybrid|hybride|ibrid|híbrid|hibrid/.test(v)
+  const electric = /electri|électri|elettri|eléctri|elétri/.test(v) || v === 'e'
+  const diesel = /diesel|gasoil|gazole|gasóleo|gasoleo|gas-oil/.test(v) || v === 'd'
+  const gasoline = /essence|benzin|gasolina|petrol|gasoline|sans plomb/.test(v) || v === 'g'
+  if (hybrid || (electric && (diesel || gasoline))) return 'hybrid'
+  if (electric) return 'electric'
+  if (diesel) return 'diesel'
+  if (gasoline) return 'gasoline'
+  return undefined
+}
+
+export function transmission(value: string | undefined): string | undefined {
+  if (!value) return undefined
+  const v = value.toLowerCase()
+  if (/cvt|variat|variabile|continu/.test(v)) return 'cvt'
+  if (/auto/.test(v)) return 'automatic'
+  if (/manu|m[ée]cani|meccani|mecáni/.test(v)) return 'manual'
+  return undefined
+}
+
+/**
+ * Displacement as the form shows it, from a value that is cubic centimetres
+ * when it is big enough to be. France's top-level EngineSize is fiscal
+ * horsepower, a small number, and is left out unless the SIV record gives
+ * the real capacity.
+ */
+export function engineSize(value: string | number | undefined): string | undefined {
+  const cc = Number(text(value))
+  if (!Number.isFinite(cc) || cc < 100) return undefined
+  return `${(cc / 1000).toFixed(1)} L`
+}
+
+/** ISO date from the formats the five countries use: 2008-10-22, 17/11/2003, 1/4/2025 and 22102008. */
+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')}`
+  m = /^(\d{2})(\d{2})(\d{4})$/.exec(v)
+  if (m) return `${m[3]}-${m[2]}-${m[1]}`
+  return undefined
+}
+
+function vin(record: AutomotiveRecord): string | undefined {
+  for (const candidate of [
+    record.Vin,
+    record.VehicleIdentificationNumber,
+    record.VechileIdentificationNumber,
+    record.ExtendedData?.numSerieMoteur,
+  ]) {
+    const v = text(candidate)?.toUpperCase()
+    if (v && /^[A-HJ-NPR-Z0-9]{11,17}$/.test(v)) return v
+  }
+  return undefined
+}
+
+function kilograms(value: string | undefined): number | undefined {
+  const n = Number(text(value))
+  return Number.isFinite(n) && n > 0 ? n : undefined
+}
+
+/** The vendor's record in the app's vocabulary. Exported for the contract test. */
+export function mapRecord(record: AutomotiveRecord, plate?: string): VehicleLookupResult {
+  const make = text(record.CarMake) ?? text(record.MakeDescription)
+  const firstRegistered =
+    isoDate(text(record.RegistrationDate)) ??
+    isoDate(text(record.ExtendedData?.datePremiereMiseCirculation))
+  const yearText = text(record.RegistrationYear) ?? firstRegistered?.slice(0, 4)
+  const year = Number(yearText)
+  const colour = text(record.Colour)
+  const kerb = kilograms(record.NetWeight)
+  const grossMax = kilograms(record.GrossWeight)
+
+  const result: VehicleLookupResult = {
+    make: make ? titleCase(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,
+    fuelType: fuelType(text(record.FuelType) ?? text(record.Fuel)),
+    transmission: transmission(
+      text(record.Transmission) ?? text(record.ExtendedData?.boiteDeVitesse)
+    ),
+    engineSize: engineSize(record.ExtendedData?.EngineCC) ?? engineSize(record.EngineSize),
+    engineCode: text(record.EngineCode),
+    vehicleClass: text(record.BodyStyle),
+    firstRegistered,
+    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 are letters and digits; people type them with the spaces and hyphens their country prints. */
+export function normalisePlate(plate: string): string {
+  return plate.replace(/[\s-]+/g, '').toUpperCase()
+}
+
+function token(ctx: ConnectorContext): string {
+  return typeof ctx.credentials.token === 'string' ? ctx.credentials.token.trim() : ''
+}
+
+function headers(ctx: ConnectorContext): Record<string, string> {
+  return { Authorization: `Bearer ${token(ctx)}`, Accept: 'application/json' }
+}
+
+/** The country whose registry this connection searches, or null while the setting is unset. */
+export function countryOf(ctx: ConnectorContext): Country | null {
+  const value = ctx.connection.settings.country
+  return typeof value === 'string' && value in ENDPOINTS ? (value as Country) : null
+}
+
+function sleep(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function pollDelayMs(res: Response): number {
+  const header = Number(res.headers.get('retry-after'))
+  if (Number.isFinite(header) && header >= 0) return Math.min(header * 1000, MAX_POLL_MS)
+  return DEFAULT_POLL_MS
+}
+
+/**
+ * The vendor's reasons, in words the person at the form can act on. Every
+ * status the spec lists is named; anything else is reported as the HTTP
+ * status it was.
+ */
+async function reject(ctx: ConnectorContext, res: Response, what: string): Promise<never> {
+  if (res.status === 401 || res.status === 403) {
+    await ctx.log('error', `${what}: token rejected`, { status: res.status })
+    throw new Error('Openapi rejected the token')
+  }
+  if (res.status === 402) {
+    await ctx.log('error', `${what}: no credit left`, { status: res.status })
+    throw new Error('Openapi: the wallet has no credit left, top it up on console.openapi.com')
+  }
+  if (res.status === 406) {
+    await ctx.log('info', `${what}: plate format refused`, { status: res.status })
+    throw new Error('Openapi: that is not a valid plate for the chosen country')
+  }
+  if (res.status === 417) {
+    await ctx.log('warn', `${what}: service unavailable`, { status: res.status })
+    throw new Error('Openapi: the registry is not available at the moment, try again later')
+  }
+  await ctx.log('error', `${what} failed`, { status: res.status })
+  throw new Error(`Openapi answered HTTP ${res.status}`)
+}
+
+/** The record out of an envelope, or null when the vendor says there is none. */
+function recordOf(body: Envelope<AutomotiveRecord | Pending>): AutomotiveRecord | Pending | null {
+  const data = Array.isArray(body.data) ? body.data[0] : body.data
+  if (!data || typeof data !== 'object') return null
+  return data
+}
+
+/**
+ * One vehicle from one endpoint. A 302 or a 200 carrying a request id means
+ * the vendor is still fetching, and the status endpoint is asked again after
+ * the wait it names, a handful of times, before the lookup gives up.
+ */
+async function fetchRecord(
+  ctx: ConnectorContext,
+  endpoint: string,
+  plate: string
+): Promise<AutomotiveRecord | null> {
+  let res = await ctx.http.fetch(`${BASE_URL}/${endpoint}/${encodeURIComponent(plate)}`, {
+    headers: headers(ctx),
+  })
+  for (let poll = 0; ; poll++) {
+    if (res.status === 404) return null
+    if (!res.ok && res.status !== 302) await reject(ctx, res, 'Vehicle lookup')
+    const body = (await res.json()) as Envelope<AutomotiveRecord | Pending>
+    const data = recordOf(body)
+    if (!data) return null
+    if (!isPending(data)) return data
+    if (poll >= MAX_POLLS) {
+      await ctx.log('warn', 'Vehicle lookup: still pending after polling', { polls: poll })
+      throw new Error('Openapi is still fetching the vehicle, try again in a moment')
+    }
+    await sleep(pollDelayMs(res))
+    res = await ctx.http.fetch(`${BASE_URL}/check_id/${encodeURIComponent(data.id as string)}`, {
+      headers: headers(ctx),
+    })
+  }
+}
+
+/** One vehicle by plate. This registry has no VIN search, so a VIN alone finds nothing. */
+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('Openapi Automotive: choose a country on the integration page')
+  const endpoints = ENDPOINTS[country]
+  const car = await fetchRecord(ctx, endpoints.car, plate)
+  if (car) {
+    await ctx.log('info', 'Vehicle lookup: match', { endpoint: endpoints.car })
+    return mapRecord(car, plate)
+  }
+  if (ctx.connection.settings.bikes === true && endpoints.bike) {
+    const bike = await fetchRecord(ctx, endpoints.bike, plate)
+    if (bike) {
+      await ctx.log('info', 'Vehicle lookup: match', { endpoint: endpoints.bike })
+      return mapRecord(bike, plate)
+    }
+  }
+  await ctx.log('info', 'Vehicle lookup: no match', { country })
+  return null
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    if (!token(ctx)) return { ok: false, message: 'Openapi Automotive: a token is required' }
+    if (!countryOf(ctx)) {
+      return { ok: false, message: 'Openapi Automotive: choose the country to search' }
+    }
+    // There is no ping endpoint and every lookup is billed, so the request
+    // status endpoint is asked about an id that was never issued: a good
+    // token gets "not found" or "not valid", a bad one is turned away.
+    const res = await ctx.http.fetch(`${BASE_URL}/check_id/${PROBE_ID}`, { headers: headers(ctx) })
+    if (res.status === 401 || res.status === 403) {
+      return { ok: false, message: 'Openapi rejected the token' }
+    }
+    if (!res.ok && ![400, 404, 406].includes(res.status)) {
+      return { ok: false, message: `Openapi answered HTTP ${res.status}` }
+    }
+    return { ok: true }
+  },
+
+  lookupVehicle,
+
+  jobs: {},
+}

+ 2 - 0
src/integrations/registry.ts

@@ -12,6 +12,7 @@ import { manifest as amazonSes } from './amazon-ses/manifest'
 import { manifest as googleCalendar } from './google-calendar/manifest'
 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 resend } from './resend/manifest'
 import { manifest as sendgrid } from './sendgrid/manifest'
@@ -35,6 +36,7 @@ const ENTRIES: readonly RegistryEntry[] = [
   { manifest: microsoft365, load: () => import('./microsoft-365/server') },
   { manifest: zoom, load: () => import('./zoom/server') },
   { manifest: vegvesen, load: () => import('./vegvesen/server') },
+  { manifest: openapiAutomotive, load: () => import('./openapi-automotive/server') },
   { manifest: twilioSms, load: () => import('./twilio-sms/server') },
   { manifest: vonageSms, load: () => import('./vonage-sms/server') },
   { manifest: telnyxSms, load: () => import('./telnyx-sms/server') },