Parcourir la source

Add offline licence tokens for self-hosted installs (#485)

* offline license

* Keep the stored licence when a key or token is rejected

* Drop an unused import
Bernt Christian Egeland il y a 1 semaine
Parent
commit
d726995217

+ 28 - 4
messages/de/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Aktiv",
     "active": "Aktiv",
     "inactive": "Inaktiv",
     "inactive": "Inaktiv",
     "lastChecked": "Zuletzt gepruft: {date}",
     "lastChecked": "Zuletzt gepruft: {date}",
-    "enterLicenseKey": "Lizenzschlüssel eingeben",
+    "enterLicenseKey": "Lizenzschlüssel oder Offline-Lizenztoken eingeben",
     "validate": "Validieren",
     "validate": "Validieren",
     "validated": "Lizenz erfolgreich validiert",
     "validated": "Lizenz erfolgreich validiert",
     "invalid": "Ungültiger Lizenzschlüssel",
     "invalid": "Ungültiger Lizenzschlüssel",
+    "offlineInvalid": "Dies ist kein gültiges Offline-Lizenz-Token für diese Werkstatt. Die auf diesem Server gespeicherte Lizenz ist unverändert.",
     "failedValidate": "Lizenz konnte nicht validiert werden",
     "failedValidate": "Lizenz konnte nicht validiert werden",
-    "keyHint": "Jeder Lizenzschlüssel ist an diese Organisation gebunden und kann nicht für mehrere Organisationen verwendet werden.",
+    "keyHint": "Jeder Lizenzschlüssel und jedes Offline-Lizenztoken ist an diese Organisation gebunden und kann nicht für mehrere Organisationen verwendet werden.",
     "expired": "Abgelaufen",
     "expired": "Abgelaufen",
     "unverified": "Nicht überprüft",
     "unverified": "Nicht überprüft",
     "expiresOn": "Läuft ab: {date}",
     "expiresOn": "Läuft ab: {date}",
     "unreachable": "torqvoice.com war nicht erreichbar. Die auf diesem Server gespeicherte Lizenz bleibt unverändert.",
     "unreachable": "torqvoice.com war nicht erreichbar. Die auf diesem Server gespeicherte Lizenz bleibt unverändert.",
     "unverifiedHint": "Dieser Server konnte die Lizenz seit {days} Tagen nicht bei torqvoice.com bestätigen. Nach {max} Tagen ohne erfolgreiche Prüfung kehrt das Torqvoice-Branding zurück.",
     "unverifiedHint": "Dieser Server konnte die Lizenz seit {days} Tagen nicht bei torqvoice.com bestätigen. Nach {max} Tagen ohne erfolgreiche Prüfung kehrt das Torqvoice-Branding zurück.",
     "rejected": "torqvoice.com hat diesen Schlüssel nicht akzeptiert: {reason}",
     "rejected": "torqvoice.com hat diesen Schlüssel nicht akzeptiert: {reason}",
-    "signedHint": "Die Validierung wird von torqvoice.com signiert und täglich erneuert, daher braucht der Server ausgehenden Zugriff auf torqvoice.com.",
+    "signedHint": "Abo-Schlüssel werden von torqvoice.com signiert und täglich erneut geprüft, daher braucht der Server ausgehenden Zugriff auf torqvoice.com. Ein Offline-Lizenztoken wird auf diesem Server geprüft und braucht keinen Zugriff.",
     "makeItYours": "Eigene Marke zeigen",
     "makeItYours": "Eigene Marke zeigen",
     "makeItYoursHint": "Das Torqvoice-Zeichen ist weg. Zwei Schritte noch, dann sieht die App nach dem eigenen Betrieb aus.",
     "makeItYoursHint": "Das Torqvoice-Zeichen ist weg. Zwei Schritte noch, dann sieht die App nach dem eigenen Betrieb aus.",
     "linkAppName": "App-Namen festlegen",
     "linkAppName": "App-Namen festlegen",
     "linkAppIcon": "Eigenes Favicon und App-Symbol hinzufügen",
     "linkAppIcon": "Eigenes Favicon und App-Symbol hinzufügen",
     "adminOnlyHint": "Nur ein Super-Admin kann App-Namen und App-Symbol ändern. Wenden Sie sich an die Person, die diese Installation betreibt.",
     "adminOnlyHint": "Nur ein Super-Admin kann App-Namen und App-Symbol ändern. Wenden Sie sich an die Person, die diese Installation betreibt.",
     "makeItYoursLocked": "Eine White-Label-Lizenz schaltet App-Namen und App-Symbol frei.",
     "makeItYoursLocked": "Eine White-Label-Lizenz schaltet App-Namen und App-Symbol frei.",
-    "needsLicense": "Lizenz erforderlich"
+    "needsLicense": "Lizenz erforderlich",
+    "mismatch": "Passt nicht zu dieser Werkstatt",
+    "perpetual": "Unbefristet",
+    "licensedTo": "Lizenziert für {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Diese Lizenz wird auf diesem Server geprüft und muss nie Kontakt zu torqvoice.com aufnehmen.",
+    "perpetualWorkshops": "Gilt für {count, plural, one {# Werkstatt} other {# Werkstätten}} in dieser Installation.",
+    "mismatchExplain": "Diese Lizenz wurde für die Organisationsnummer {licensed} ausgestellt, die Organisationsnummer dieser Werkstatt ist aber {current}.",
+    "mismatchExplainNotSet": "Diese Lizenz wurde für die Organisationsnummer {licensed} ausgestellt, für diese Werkstatt ist aber keine Organisationsnummer hinterlegt.",
+    "openCompanySettings": "Unternehmenseinstellungen öffnen",
+    "notSet": "Nicht hinterlegt",
+    "offlineActivated": "Offline-Lizenz aktiviert",
+    "offlineTitle": "Offline-Lizenz",
+    "offlineDescription": "Ein einmaliger Kauf, der das Torqvoice-Branding für genau diese Werkstatt entfernt und nie Kontakt zu torqvoice.com aufnimmt.",
+    "offlineBinding": "Die Lizenz ist an die Organisationsnummer Ihres Unternehmens gebunden. Eine Umbenennung der Werkstatt ist kein Problem, eine neue juristische Person braucht aber eine neue Lizenz.",
+    "offlineOrganizationId": "Organisations-ID",
+    "offlineWorkshop": "Werkstatt",
+    "offlineOrgNumber": "Organisationsnummer",
+    "offlineOrgNumberRequired": "Bevor eine Offline-Lizenz ausgestellt werden kann, muss eine Organisationsnummer hinterlegt sein.",
+    "requestCode": "Lizenzanforderungscode",
+    "copyRequestCode": "Kopieren",
+    "requestCodeCopied": "Anforderungscode kopiert",
+    "requestCodeCopyFailed": "Anforderungscode konnte nicht kopiert werden",
+    "offlineOrderHint": "Bestellen Sie mit diesem Code auf torqvoice.com.",
+    "orderOffline": "Offline-Lizenz bestellen"
   },
   },
   "about": {
   "about": {
     "title": "Uber Torqvoice",
     "title": "Uber Torqvoice",

+ 28 - 4
messages/en/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Active",
     "active": "Active",
     "inactive": "Inactive",
     "inactive": "Inactive",
     "lastChecked": "Last checked: {date}",
     "lastChecked": "Last checked: {date}",
-    "enterLicenseKey": "Enter license key",
+    "enterLicenseKey": "Enter a licence key or an offline licence token",
     "validate": "Validate",
     "validate": "Validate",
     "validated": "License validated successfully",
     "validated": "License validated successfully",
     "invalid": "Invalid license key",
     "invalid": "Invalid license key",
+    "offlineInvalid": "This is not a valid offline licence token for this workshop. The licence stored on this server is unchanged.",
     "failedValidate": "Failed to validate license",
     "failedValidate": "Failed to validate license",
-    "keyHint": "Each license key is tied to this organization and cannot be shared across multiple organizations.",
+    "keyHint": "Each licence key or offline licence token is tied to this organization and cannot be shared across multiple organizations.",
     "expired": "Expired",
     "expired": "Expired",
     "unverified": "Unverified",
     "unverified": "Unverified",
     "expiresOn": "Expires: {date}",
     "expiresOn": "Expires: {date}",
     "unreachable": "torqvoice.com could not be reached. The licence stored on this server is unchanged.",
     "unreachable": "torqvoice.com could not be reached. The licence stored on this server is unchanged.",
     "unverifiedHint": "This server has not been able to confirm the licence with torqvoice.com for {days} days. Torqvoice branding returns after {max} days without a successful check.",
     "unverifiedHint": "This server has not been able to confirm the licence with torqvoice.com for {days} days. Torqvoice branding returns after {max} days without a successful check.",
     "rejected": "torqvoice.com did not accept this key: {reason}",
     "rejected": "torqvoice.com did not accept this key: {reason}",
-    "signedHint": "Validation is signed by torqvoice.com and re-checked daily, so the server needs outbound access to torqvoice.com.",
+    "signedHint": "Subscription keys are signed by torqvoice.com and re-checked daily, so the server needs outbound access to torqvoice.com. An offline licence token is verified on this server and needs no access.",
     "makeItYours": "Make it yours",
     "makeItYours": "Make it yours",
     "makeItYoursHint": "The Torqvoice mark is gone. Two more steps make the app look like yours.",
     "makeItYoursHint": "The Torqvoice mark is gone. Two more steps make the app look like yours.",
     "linkAppName": "Set your app name",
     "linkAppName": "Set your app name",
     "linkAppIcon": "Add your own favicon and app icon",
     "linkAppIcon": "Add your own favicon and app icon",
     "adminOnlyHint": "Only a super admin can change the app name and icon. Ask the person who runs this installation.",
     "adminOnlyHint": "Only a super admin can change the app name and icon. Ask the person who runs this installation.",
     "makeItYoursLocked": "A white-label licence unlocks the app name and icon.",
     "makeItYoursLocked": "A white-label licence unlocks the app name and icon.",
-    "needsLicense": "Needs a licence"
+    "needsLicense": "Needs a licence",
+    "mismatch": "Does not match this workshop",
+    "perpetual": "Perpetual",
+    "licensedTo": "Licensed to {name} ({registrationNumber}), {country}",
+    "perpetualHint": "This licence is verified on this server and never needs to contact torqvoice.com.",
+    "perpetualWorkshops": "Covers {count, plural, one {# workshop} other {# workshops}} on this installation.",
+    "mismatchExplain": "This licence was issued to organization number {licensed}, but this workshop's organization number is {current}.",
+    "mismatchExplainNotSet": "This licence was issued to organization number {licensed}, but this workshop has no organization number set.",
+    "openCompanySettings": "Open Company settings",
+    "notSet": "Not set",
+    "offlineActivated": "Offline licence activated",
+    "offlineTitle": "Offline licence",
+    "offlineDescription": "A one-time purchase that removes Torqvoice branding for this one workshop and never contacts torqvoice.com.",
+    "offlineBinding": "The licence is bound to your business's organization number. Renaming the workshop is fine, but a new legal entity needs a new licence.",
+    "offlineOrganizationId": "Organization id",
+    "offlineWorkshop": "Workshop",
+    "offlineOrgNumber": "Organization number",
+    "offlineOrgNumberRequired": "An organization number is required before an offline licence can be issued.",
+    "requestCode": "Licence request code",
+    "copyRequestCode": "Copy",
+    "requestCodeCopied": "Request code copied",
+    "requestCodeCopyFailed": "Could not copy the request code",
+    "offlineOrderHint": "Order at torqvoice.com with this code.",
+    "orderOffline": "Order offline licence"
   },
   },
   "about": {
   "about": {
     "title": "About Torqvoice",
     "title": "About Torqvoice",

+ 28 - 4
messages/es/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Activa",
     "active": "Activa",
     "inactive": "Inactiva",
     "inactive": "Inactiva",
     "lastChecked": "Última verificación: {date}",
     "lastChecked": "Última verificación: {date}",
-    "enterLicenseKey": "Ingrese clave de licencia",
+    "enterLicenseKey": "Introduce una clave de licencia o un token de licencia offline",
     "validate": "Validar",
     "validate": "Validar",
     "validated": "Licencia validada exitosamente",
     "validated": "Licencia validada exitosamente",
     "invalid": "Clave de licencia invalida",
     "invalid": "Clave de licencia invalida",
+    "offlineInvalid": "Este no es un token de licencia sin conexión válido para este taller. La licencia guardada en este servidor no ha cambiado.",
     "failedValidate": "Error al validar la licencia",
     "failedValidate": "Error al validar la licencia",
-    "keyHint": "Cada clave de licencia esta vinculada a esta organización y no puede compartirse entre varias organizaciones.",
+    "keyHint": "Cada clave de licencia o token de licencia offline está vinculado a esta organización y no se puede compartir entre varias organizaciones.",
     "expired": "Expirada",
     "expired": "Expirada",
     "unverified": "Sin verificar",
     "unverified": "Sin verificar",
     "expiresOn": "Expira: {date}",
     "expiresOn": "Expira: {date}",
     "unreachable": "No se pudo conectar con torqvoice.com. La licencia guardada en este servidor no ha cambiado.",
     "unreachable": "No se pudo conectar con torqvoice.com. La licencia guardada en este servidor no ha cambiado.",
     "unverifiedHint": "Este servidor no ha podido confirmar la licencia con torqvoice.com desde hace {days} días. La marca Torqvoice vuelve tras {max} días sin una comprobación correcta.",
     "unverifiedHint": "Este servidor no ha podido confirmar la licencia con torqvoice.com desde hace {days} días. La marca Torqvoice vuelve tras {max} días sin una comprobación correcta.",
     "rejected": "torqvoice.com no aceptó esta clave: {reason}",
     "rejected": "torqvoice.com no aceptó esta clave: {reason}",
-    "signedHint": "La validación está firmada por torqvoice.com y se repite a diario, por lo que el servidor necesita acceso saliente a torqvoice.com.",
+    "signedHint": "Las claves de suscripción las firma torqvoice.com y se vuelven a comprobar a diario, así que el servidor necesita acceso saliente a torqvoice.com. Un token de licencia offline se verifica en este servidor y no necesita acceso.",
     "makeItYours": "Hazlo tuyo",
     "makeItYours": "Hazlo tuyo",
     "makeItYoursHint": "La marca Torqvoice ya no aparece. Dos pasos más y la aplicación lucirá como tuya.",
     "makeItYoursHint": "La marca Torqvoice ya no aparece. Dos pasos más y la aplicación lucirá como tuya.",
     "linkAppName": "Definir el nombre de la aplicación",
     "linkAppName": "Definir el nombre de la aplicación",
     "linkAppIcon": "Añadir tu propio favicon e icono de la aplicación",
     "linkAppIcon": "Añadir tu propio favicon e icono de la aplicación",
     "adminOnlyHint": "Solo un superadministrador puede cambiar el nombre y el icono de la aplicación. Pide ayuda a quien gestiona esta instalación.",
     "adminOnlyHint": "Solo un superadministrador puede cambiar el nombre y el icono de la aplicación. Pide ayuda a quien gestiona esta instalación.",
     "makeItYoursLocked": "Una licencia white-label desbloquea el nombre y el icono de la aplicación.",
     "makeItYoursLocked": "Una licencia white-label desbloquea el nombre y el icono de la aplicación.",
-    "needsLicense": "Requiere licencia"
+    "needsLicense": "Requiere licencia",
+    "mismatch": "No corresponde a este taller",
+    "perpetual": "Perpetua",
+    "licensedTo": "Con licencia para {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Esta licencia se verifica en este servidor y nunca necesita contactar con torqvoice.com.",
+    "perpetualWorkshops": "Cubre {count, plural, one {# taller} other {# talleres}} en esta instalación.",
+    "mismatchExplain": "Esta licencia se emitió para el número de organización {licensed}, pero el número de organización de este taller es {current}.",
+    "mismatchExplainNotSet": "Esta licencia se emitió para el número de organización {licensed}, pero este taller no tiene ningún número de organización configurado.",
+    "openCompanySettings": "Abrir los ajustes de Empresa",
+    "notSet": "Sin configurar",
+    "offlineActivated": "Licencia offline activada",
+    "offlineTitle": "Licencia offline",
+    "offlineDescription": "Un pago único que elimina la marca Torqvoice para este taller y nunca contacta con torqvoice.com.",
+    "offlineBinding": "La licencia está vinculada al número de organización de tu empresa. Puedes cambiar el nombre del taller sin problema, pero una nueva entidad jurídica necesita una nueva licencia.",
+    "offlineOrganizationId": "ID de organización",
+    "offlineWorkshop": "Taller",
+    "offlineOrgNumber": "Número de organización",
+    "offlineOrgNumberRequired": "Hace falta un número de organización para poder emitir una licencia offline.",
+    "requestCode": "Código de solicitud de licencia",
+    "copyRequestCode": "Copiar",
+    "requestCodeCopied": "Código de solicitud copiado",
+    "requestCodeCopyFailed": "No se pudo copiar el código de solicitud",
+    "offlineOrderHint": "Haz el pedido en torqvoice.com con este código.",
+    "orderOffline": "Pedir licencia offline"
   },
   },
   "about": {
   "about": {
     "title": "Acerca de Torqvoice",
     "title": "Acerca de Torqvoice",

+ 28 - 4
messages/fr/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Active",
     "active": "Active",
     "inactive": "Inactive",
     "inactive": "Inactive",
     "lastChecked": "Dernière vérification : {date}",
     "lastChecked": "Dernière vérification : {date}",
-    "enterLicenseKey": "Entrez la cle de licence",
+    "enterLicenseKey": "Saisissez une clé de licence ou un jeton de licence hors ligne",
     "validate": "Valider",
     "validate": "Valider",
     "validated": "Licence validee avec succès",
     "validated": "Licence validee avec succès",
     "invalid": "Cle de licence invalide",
     "invalid": "Cle de licence invalide",
+    "offlineInvalid": "Ce n'est pas un jeton de licence hors ligne valide pour cet atelier. La licence enregistrée sur ce serveur est inchangée.",
     "failedValidate": "Échec de la validation de la licence",
     "failedValidate": "Échec de la validation de la licence",
-    "keyHint": "Chaque cle de licence est liée a cette organisation et ne peut pas être partagee entre plusieurs organisations.",
+    "keyHint": "Chaque clé de licence ou jeton de licence hors ligne est lié à cette organisation et ne peut pas être partagé entre plusieurs organisations.",
     "expired": "Expirée",
     "expired": "Expirée",
     "unverified": "Non vérifiée",
     "unverified": "Non vérifiée",
     "expiresOn": "Expire le : {date}",
     "expiresOn": "Expire le : {date}",
     "unreachable": "Impossible de joindre torqvoice.com. La licence enregistrée sur ce serveur reste inchangée.",
     "unreachable": "Impossible de joindre torqvoice.com. La licence enregistrée sur ce serveur reste inchangée.",
     "unverifiedHint": "Ce serveur n'a pas pu confirmer la licence auprès de torqvoice.com depuis {days} jours. La marque Torqvoice réapparaît après {max} jours sans vérification réussie.",
     "unverifiedHint": "Ce serveur n'a pas pu confirmer la licence auprès de torqvoice.com depuis {days} jours. La marque Torqvoice réapparaît après {max} jours sans vérification réussie.",
     "rejected": "torqvoice.com n'a pas accepté cette clé : {reason}",
     "rejected": "torqvoice.com n'a pas accepté cette clé : {reason}",
-    "signedHint": "La validation est signée par torqvoice.com et renouvelée chaque jour ; le serveur doit donc pouvoir joindre torqvoice.com.",
+    "signedHint": "Les clés d'abonnement sont signées par torqvoice.com et revérifiées chaque jour, le serveur a donc besoin d'un accès sortant à torqvoice.com. Un jeton de licence hors ligne est vérifié sur ce serveur et ne nécessite aucun accès.",
     "makeItYours": "Faites-le vôtre",
     "makeItYours": "Faites-le vôtre",
     "makeItYoursHint": "La marque Torqvoice a disparu. Deux étapes de plus et l'application sera à votre image.",
     "makeItYoursHint": "La marque Torqvoice a disparu. Deux étapes de plus et l'application sera à votre image.",
     "linkAppName": "Définir le nom de l'application",
     "linkAppName": "Définir le nom de l'application",
     "linkAppIcon": "Ajouter votre propre favicon et icône d'application",
     "linkAppIcon": "Ajouter votre propre favicon et icône d'application",
     "adminOnlyHint": "Seul un super-administrateur peut modifier le nom et l'icône de l'application. Adressez-vous à la personne qui gère cette installation.",
     "adminOnlyHint": "Seul un super-administrateur peut modifier le nom et l'icône de l'application. Adressez-vous à la personne qui gère cette installation.",
     "makeItYoursLocked": "Une licence white-label débloque le nom et l'icône de l'application.",
     "makeItYoursLocked": "Une licence white-label débloque le nom et l'icône de l'application.",
-    "needsLicense": "Licence requise"
+    "needsLicense": "Licence requise",
+    "mismatch": "Ne correspond pas à cet atelier",
+    "perpetual": "Perpétuelle",
+    "licensedTo": "Licence accordée à {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Cette licence est vérifiée sur ce serveur et n'a jamais besoin de contacter torqvoice.com.",
+    "perpetualWorkshops": "Couvre {count, plural, one {# atelier} other {# ateliers}} sur cette installation.",
+    "mismatchExplain": "Cette licence a été émise pour le numéro d'organisation {licensed}, mais le numéro d'organisation de cet atelier est {current}.",
+    "mismatchExplainNotSet": "Cette licence a été émise pour le numéro d'organisation {licensed}, mais aucun numéro d'organisation n'est renseigné pour cet atelier.",
+    "openCompanySettings": "Ouvrir les paramètres Entreprise",
+    "notSet": "Non renseigné",
+    "offlineActivated": "Licence hors ligne activée",
+    "offlineTitle": "Licence hors ligne",
+    "offlineDescription": "Un achat unique qui supprime la marque Torqvoice pour cet atelier et ne contacte jamais torqvoice.com.",
+    "offlineBinding": "La licence est liée au numéro d'organisation de votre entreprise. Renommer l'atelier ne pose aucun problème, mais une nouvelle entité juridique nécessite une nouvelle licence.",
+    "offlineOrganizationId": "Identifiant de l'organisation",
+    "offlineWorkshop": "Atelier",
+    "offlineOrgNumber": "Numéro d'organisation",
+    "offlineOrgNumberRequired": "Un numéro d'organisation est requis avant qu'une licence hors ligne puisse être émise.",
+    "requestCode": "Code de demande de licence",
+    "copyRequestCode": "Copier",
+    "requestCodeCopied": "Code de demande copié",
+    "requestCodeCopyFailed": "Impossible de copier le code de demande",
+    "offlineOrderHint": "Commandez sur torqvoice.com avec ce code.",
+    "orderOffline": "Commander une licence hors ligne"
   },
   },
   "about": {
   "about": {
     "title": "A propos de Torqvoice",
     "title": "A propos de Torqvoice",

+ 28 - 4
messages/it/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Attiva",
     "active": "Attiva",
     "inactive": "Inattiva",
     "inactive": "Inattiva",
     "lastChecked": "Ultimo controllo: {date}",
     "lastChecked": "Ultimo controllo: {date}",
-    "enterLicenseKey": "Inserisci chiave di licenza",
+    "enterLicenseKey": "Inserisci una chiave di licenza o un token di licenza offline",
     "validate": "Valida",
     "validate": "Valida",
     "validated": "Licenza validata con successo",
     "validated": "Licenza validata con successo",
     "invalid": "Chiave di licenza non valida",
     "invalid": "Chiave di licenza non valida",
+    "offlineInvalid": "Questo non è un token di licenza offline valido per questa officina. La licenza salvata su questo server è invariata.",
     "failedValidate": "Validazione licenza non riuscita",
     "failedValidate": "Validazione licenza non riuscita",
-    "keyHint": "Ogni chiave di licenza e legata a questa organizzazione e non puo essere condivisa tra piu organizzazioni.",
+    "keyHint": "Ogni chiave di licenza o token di licenza offline è legato a questa organizzazione e non può essere condiviso tra più organizzazioni.",
     "expired": "Scaduta",
     "expired": "Scaduta",
     "unverified": "Non verificata",
     "unverified": "Non verificata",
     "expiresOn": "Scade: {date}",
     "expiresOn": "Scade: {date}",
     "unreachable": "Impossibile raggiungere torqvoice.com. La licenza salvata su questo server resta invariata.",
     "unreachable": "Impossibile raggiungere torqvoice.com. La licenza salvata su questo server resta invariata.",
     "unverifiedHint": "Questo server non riesce a confermare la licenza con torqvoice.com da {days} giorni. Il marchio Torqvoice torna dopo {max} giorni senza una verifica riuscita.",
     "unverifiedHint": "Questo server non riesce a confermare la licenza con torqvoice.com da {days} giorni. Il marchio Torqvoice torna dopo {max} giorni senza una verifica riuscita.",
     "rejected": "torqvoice.com non ha accettato questa chiave: {reason}",
     "rejected": "torqvoice.com non ha accettato questa chiave: {reason}",
-    "signedHint": "La convalida è firmata da torqvoice.com e ripetuta ogni giorno, quindi il server deve poter raggiungere torqvoice.com.",
+    "signedHint": "Le chiavi di abbonamento sono firmate da torqvoice.com e ricontrollate ogni giorno, quindi il server ha bisogno di accesso in uscita a torqvoice.com. Un token di licenza offline viene verificato su questo server e non richiede alcun accesso.",
     "makeItYours": "Rendilo tuo",
     "makeItYours": "Rendilo tuo",
     "makeItYoursHint": "Il marchio Torqvoice non c'è più. Altri due passaggi e l'app avrà il tuo aspetto.",
     "makeItYoursHint": "Il marchio Torqvoice non c'è più. Altri due passaggi e l'app avrà il tuo aspetto.",
     "linkAppName": "Imposta il nome dell'app",
     "linkAppName": "Imposta il nome dell'app",
     "linkAppIcon": "Aggiungi la tua favicon e l'icona dell'app",
     "linkAppIcon": "Aggiungi la tua favicon e l'icona dell'app",
     "adminOnlyHint": "Solo un super amministratore può cambiare nome e icona dell'app. Rivolgiti a chi gestisce questa installazione.",
     "adminOnlyHint": "Solo un super amministratore può cambiare nome e icona dell'app. Rivolgiti a chi gestisce questa installazione.",
     "makeItYoursLocked": "Una licenza white-label sblocca nome e icona dell'app.",
     "makeItYoursLocked": "Una licenza white-label sblocca nome e icona dell'app.",
-    "needsLicense": "Richiede una licenza"
+    "needsLicense": "Richiede una licenza",
+    "mismatch": "Non corrisponde a questa officina",
+    "perpetual": "Perpetua",
+    "licensedTo": "Licenza intestata a {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Questa licenza viene verificata su questo server e non ha mai bisogno di contattare torqvoice.com.",
+    "perpetualWorkshops": "Copre {count, plural, one {# officina} other {# officine}} su questa installazione.",
+    "mismatchExplain": "Questa licenza è stata emessa per il numero organizzazione {licensed}, ma il numero organizzazione di questa officina è {current}.",
+    "mismatchExplainNotSet": "Questa licenza è stata emessa per il numero organizzazione {licensed}, ma per questa officina non è impostato alcun numero organizzazione.",
+    "openCompanySettings": "Apri le impostazioni Azienda",
+    "notSet": "Non impostato",
+    "offlineActivated": "Licenza offline attivata",
+    "offlineTitle": "Licenza offline",
+    "offlineDescription": "Un acquisto una tantum che rimuove il marchio Torqvoice per questa officina e non contatta mai torqvoice.com.",
+    "offlineBinding": "La licenza è legata al numero organizzazione della tua attività. Puoi rinominare l'officina senza problemi, ma una nuova entità giuridica ha bisogno di una nuova licenza.",
+    "offlineOrganizationId": "ID organizzazione",
+    "offlineWorkshop": "Officina",
+    "offlineOrgNumber": "Numero organizzazione",
+    "offlineOrgNumberRequired": "Serve un numero organizzazione prima di poter emettere una licenza offline.",
+    "requestCode": "Codice di richiesta licenza",
+    "copyRequestCode": "Copia",
+    "requestCodeCopied": "Codice di richiesta copiato",
+    "requestCodeCopyFailed": "Impossibile copiare il codice di richiesta",
+    "offlineOrderHint": "Ordina su torqvoice.com con questo codice.",
+    "orderOffline": "Ordina la licenza offline"
   },
   },
   "about": {
   "about": {
     "title": "Informazioni su Torqvoice",
     "title": "Informazioni su Torqvoice",

+ 28 - 4
messages/lt/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Aktyvi",
     "active": "Aktyvi",
     "inactive": "Neaktyvi",
     "inactive": "Neaktyvi",
     "lastChecked": "Paskutinis tikrinimas: {date}",
     "lastChecked": "Paskutinis tikrinimas: {date}",
-    "enterLicenseKey": "Įveskite licencijos raktą",
+    "enterLicenseKey": "Įveskite licencijos raktą arba neprisijungusios licencijos žetoną",
     "validate": "Patvirtinti",
     "validate": "Patvirtinti",
     "validated": "Licencija sėkmingai patvirtinta",
     "validated": "Licencija sėkmingai patvirtinta",
     "invalid": "Neteisingas licencijos raktas",
     "invalid": "Neteisingas licencijos raktas",
+    "offlineInvalid": "Tai nėra galiojantis šių dirbtuvių licencijos neprisijungus žetonas. Šiame serveryje išsaugota licencija nepakeista.",
     "failedValidate": "Nepavyko patvirtinti licencijos",
     "failedValidate": "Nepavyko patvirtinti licencijos",
-    "keyHint": "Kiekvienas licencijos raktas susietas su šia organizacija ir negali būti naudojamas keliose organizacijose.",
+    "keyHint": "Kiekvienas licencijos raktas ar neprisijungusios licencijos žetonas susietas su šia organizacija ir negali būti naudojamas keliose organizacijose.",
     "expired": "Pasibaigusi",
     "expired": "Pasibaigusi",
     "unverified": "Nepatikrinta",
     "unverified": "Nepatikrinta",
     "expiresOn": "Galioja iki: {date}",
     "expiresOn": "Galioja iki: {date}",
     "unreachable": "Nepavyko pasiekti torqvoice.com. Šiame serveryje saugoma licencija nepakito.",
     "unreachable": "Nepavyko pasiekti torqvoice.com. Šiame serveryje saugoma licencija nepakito.",
     "unverifiedHint": "Šis serveris jau {days} d. negali patvirtinti licencijos su torqvoice.com. Po {max} d. be sėkmingo patikrinimo Torqvoice prekės ženklas grįžta.",
     "unverifiedHint": "Šis serveris jau {days} d. negali patvirtinti licencijos su torqvoice.com. Po {max} d. be sėkmingo patikrinimo Torqvoice prekės ženklas grįžta.",
     "rejected": "torqvoice.com nepriėmė šio rakto: {reason}",
     "rejected": "torqvoice.com nepriėmė šio rakto: {reason}",
-    "signedHint": "Patikrinimą pasirašo torqvoice.com ir jis kartojamas kasdien, todėl serveriui reikia išeinančio ryšio su torqvoice.com.",
+    "signedHint": "Prenumeratos raktus pasirašo torqvoice.com ir jie tikrinami kasdien, todėl serveriui reikia išeinančio ryšio su torqvoice.com. Neprisijungusios licencijos žetonas tikrinamas šiame serveryje, ir ryšio jam nereikia.",
     "makeItYours": "Padarykite savo",
     "makeItYours": "Padarykite savo",
     "makeItYoursHint": "Torqvoice ženklo nebėra. Dar du žingsniai, ir programa atrodys kaip jūsų.",
     "makeItYoursHint": "Torqvoice ženklo nebėra. Dar du žingsniai, ir programa atrodys kaip jūsų.",
     "linkAppName": "Nustatyti programos pavadinimą",
     "linkAppName": "Nustatyti programos pavadinimą",
     "linkAppIcon": "Pridėti savo favicon ir programos piktogramą",
     "linkAppIcon": "Pridėti savo favicon ir programos piktogramą",
     "adminOnlyHint": "Programos pavadinimą ir piktogramą gali keisti tik super administratorius. Kreipkitės į šios instaliacijos valdytoją.",
     "adminOnlyHint": "Programos pavadinimą ir piktogramą gali keisti tik super administratorius. Kreipkitės į šios instaliacijos valdytoją.",
     "makeItYoursLocked": "White-label licencija atrakina programos pavadinimą ir piktogramą.",
     "makeItYoursLocked": "White-label licencija atrakina programos pavadinimą ir piktogramą.",
-    "needsLicense": "Reikia licencijos"
+    "needsLicense": "Reikia licencijos",
+    "mismatch": "Neatitinka šių dirbtuvių",
+    "perpetual": "Neterminuota",
+    "licensedTo": "Licencijos turėtojas: {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Ši licencija tikrinama šiame serveryje ir niekada nesusisiekia su torqvoice.com.",
+    "perpetualWorkshops": "Galioja {count, plural, one {# dirbtuvėms} few {# dirbtuvėms} many {# dirbtuvių} other {# dirbtuvių}} šioje instaliacijoje.",
+    "mismatchExplain": "Ši licencija išduota įmonės kodui {licensed}, tačiau šių dirbtuvių įmonės kodas yra {current}.",
+    "mismatchExplainNotSet": "Ši licencija išduota įmonės kodui {licensed}, tačiau šioms dirbtuvėms įmonės kodas nenustatytas.",
+    "openCompanySettings": "Atidaryti įmonės nustatymus",
+    "notSet": "Nenustatyta",
+    "offlineActivated": "Neprisijungusi licencija aktyvuota",
+    "offlineTitle": "Neprisijungusi licencija",
+    "offlineDescription": "Vienkartinis pirkinys, kuris pašalina Torqvoice ženklą šioms vienoms dirbtuvėms ir niekada nesusisiekia su torqvoice.com.",
+    "offlineBinding": "Licencija susieta su jūsų įmonės kodu. Dirbtuvių pavadinimą keisti galima, tačiau naujam juridiniam asmeniui reikia naujos licencijos.",
+    "offlineOrganizationId": "Organizacijos ID",
+    "offlineWorkshop": "Dirbtuvės",
+    "offlineOrgNumber": "Įmonės kodas",
+    "offlineOrgNumberRequired": "Kad būtų galima išduoti neprisijungusią licenciją, reikia nurodyti įmonės kodą.",
+    "requestCode": "Licencijos užklausos kodas",
+    "copyRequestCode": "Kopijuoti",
+    "requestCodeCopied": "Užklausos kodas nukopijuotas",
+    "requestCodeCopyFailed": "Nepavyko nukopijuoti užklausos kodo",
+    "offlineOrderHint": "Užsisakykite torqvoice.com svetainėje naudodami šį kodą.",
+    "orderOffline": "Užsakyti neprisijungusią licenciją"
   },
   },
   "about": {
   "about": {
     "title": "Apie Torqvoice",
     "title": "Apie Torqvoice",

+ 28 - 4
messages/nb/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Aktiv",
     "active": "Aktiv",
     "inactive": "Inaktiv",
     "inactive": "Inaktiv",
     "lastChecked": "Sist sjekket: {date}",
     "lastChecked": "Sist sjekket: {date}",
-    "enterLicenseKey": "Skriv inn lisensnøkkel",
+    "enterLicenseKey": "Skriv inn en lisensnøkkel eller et offline-lisenstoken",
     "validate": "Valider",
     "validate": "Valider",
     "validated": "Lisens validert",
     "validated": "Lisens validert",
     "invalid": "Ugyldig lisensnøkkel",
     "invalid": "Ugyldig lisensnøkkel",
+    "offlineInvalid": "Dette er ikke et gyldig offline-lisenstoken for dette verkstedet. Lisensen som er lagret på denne serveren er uendret.",
     "failedValidate": "Kunne ikke validere lisens",
     "failedValidate": "Kunne ikke validere lisens",
-    "keyHint": "Hver lisensnøkkel er knyttet til denne organisasjonen og kan ikke deles mellom flere organisasjoner.",
+    "keyHint": "Hver lisensnøkkel og hvert offline-lisenstoken er knyttet til denne organisasjonen og kan ikke deles mellom flere organisasjoner.",
     "expired": "Utløpt",
     "expired": "Utløpt",
     "unverified": "Ikke bekreftet",
     "unverified": "Ikke bekreftet",
     "expiresOn": "Utløper: {date}",
     "expiresOn": "Utløper: {date}",
     "unreachable": "torqvoice.com kunne ikke nås. Lisensen som er lagret på denne serveren er uendret.",
     "unreachable": "torqvoice.com kunne ikke nås. Lisensen som er lagret på denne serveren er uendret.",
     "unverifiedHint": "Denne serveren har ikke kunnet bekrefte lisensen hos torqvoice.com på {days} dager. Torqvoice-merkingen kommer tilbake etter {max} dager uten en vellykket sjekk.",
     "unverifiedHint": "Denne serveren har ikke kunnet bekrefte lisensen hos torqvoice.com på {days} dager. Torqvoice-merkingen kommer tilbake etter {max} dager uten en vellykket sjekk.",
     "rejected": "torqvoice.com godtok ikke denne nøkkelen: {reason}",
     "rejected": "torqvoice.com godtok ikke denne nøkkelen: {reason}",
-    "signedHint": "Valideringen signeres av torqvoice.com og gjentas daglig, så serveren trenger utgående tilgang til torqvoice.com.",
+    "signedHint": "Abonnementsnøkler signeres av torqvoice.com og sjekkes på nytt daglig, så serveren trenger utgående tilgang til torqvoice.com. Et offline-lisenstoken verifiseres på denne serveren og trenger ingen tilgang.",
     "makeItYours": "Gjør den til din",
     "makeItYours": "Gjør den til din",
     "makeItYoursHint": "Torqvoice-merket er borte. To steg til, så ser appen ut som din.",
     "makeItYoursHint": "Torqvoice-merket er borte. To steg til, så ser appen ut som din.",
     "linkAppName": "Velg appnavn",
     "linkAppName": "Velg appnavn",
     "linkAppIcon": "Legg til eget favikon og appikon",
     "linkAppIcon": "Legg til eget favikon og appikon",
     "adminOnlyHint": "Bare en superadministrator kan endre appnavn og appikon. Spør den som drifter denne installasjonen.",
     "adminOnlyHint": "Bare en superadministrator kan endre appnavn og appikon. Spør den som drifter denne installasjonen.",
     "makeItYoursLocked": "En white-label-lisens låser opp appnavn og appikon.",
     "makeItYoursLocked": "En white-label-lisens låser opp appnavn og appikon.",
-    "needsLicense": "Krever lisens"
+    "needsLicense": "Krever lisens",
+    "mismatch": "Passer ikke med dette verkstedet",
+    "perpetual": "Evigvarende",
+    "licensedTo": "Lisensiert til {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Denne lisensen verifiseres på denne serveren og trenger aldri å kontakte torqvoice.com.",
+    "perpetualWorkshops": "Gjelder {count, plural, one {# verksted} other {# verksteder}} på denne installasjonen.",
+    "mismatchExplain": "Denne lisensen ble utstedt til organisasjonsnummer {licensed}, men organisasjonsnummeret til dette verkstedet er {current}.",
+    "mismatchExplainNotSet": "Denne lisensen ble utstedt til organisasjonsnummer {licensed}, men dette verkstedet har ikke satt noe organisasjonsnummer.",
+    "openCompanySettings": "Åpne firmainnstillingene",
+    "notSet": "Ikke satt",
+    "offlineActivated": "Offline-lisens aktivert",
+    "offlineTitle": "Offline-lisens",
+    "offlineDescription": "Et engangskjøp som fjerner Torqvoice-merket for dette ene verkstedet og aldri kontakter torqvoice.com.",
+    "offlineBinding": "Lisensen er knyttet til organisasjonsnummeret til bedriften din. Det går fint å gi verkstedet nytt navn, men en ny juridisk enhet trenger en ny lisens.",
+    "offlineOrganizationId": "Organisasjons-ID",
+    "offlineWorkshop": "Verksted",
+    "offlineOrgNumber": "Organisasjonsnummer",
+    "offlineOrgNumberRequired": "Et organisasjonsnummer må være satt før en offline-lisens kan utstedes.",
+    "requestCode": "Lisensforespørselskode",
+    "copyRequestCode": "Kopier",
+    "requestCodeCopied": "Forespørselskoden er kopiert",
+    "requestCodeCopyFailed": "Kunne ikke kopiere forespørselskoden",
+    "offlineOrderHint": "Bestill på torqvoice.com med denne koden.",
+    "orderOffline": "Bestill offline-lisens"
   },
   },
   "about": {
   "about": {
     "title": "Om Torqvoice",
     "title": "Om Torqvoice",

+ 28 - 4
messages/nl/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Actief",
     "active": "Actief",
     "inactive": "Inactief",
     "inactive": "Inactief",
     "lastChecked": "Laatst gecontroleerd: {date}",
     "lastChecked": "Laatst gecontroleerd: {date}",
-    "enterLicenseKey": "Licentiesleutel invoeren",
+    "enterLicenseKey": "Voer een licentiesleutel of een offline licentietoken in",
     "validate": "Valideren",
     "validate": "Valideren",
     "validated": "Licentie succesvol gevalideerd",
     "validated": "Licentie succesvol gevalideerd",
     "invalid": "Ongeldige licentiesleutel",
     "invalid": "Ongeldige licentiesleutel",
+    "offlineInvalid": "Dit is geen geldig offlinelicentietoken voor deze werkplaats. De licentie die op deze server is opgeslagen is ongewijzigd.",
     "failedValidate": "Licentie valideren mislukt",
     "failedValidate": "Licentie valideren mislukt",
-    "keyHint": "Elke licentiesleutel is gekoppeld aan deze organisatie en kan niet worden gedeeld tussen meerdere organisaties.",
+    "keyHint": "Elke licentiesleutel of offline licentietoken is gekoppeld aan deze organisatie en kan niet tussen meerdere organisaties worden gedeeld.",
     "expired": "Verlopen",
     "expired": "Verlopen",
     "unverified": "Niet geverifieerd",
     "unverified": "Niet geverifieerd",
     "expiresOn": "Verloopt: {date}",
     "expiresOn": "Verloopt: {date}",
     "unreachable": "torqvoice.com was niet bereikbaar. De licentie op deze server is ongewijzigd.",
     "unreachable": "torqvoice.com was niet bereikbaar. De licentie op deze server is ongewijzigd.",
     "unverifiedHint": "Deze server kan de licentie al {days} dagen niet bevestigen bij torqvoice.com. Na {max} dagen zonder geslaagde controle keert de Torqvoice-branding terug.",
     "unverifiedHint": "Deze server kan de licentie al {days} dagen niet bevestigen bij torqvoice.com. Na {max} dagen zonder geslaagde controle keert de Torqvoice-branding terug.",
     "rejected": "torqvoice.com heeft deze sleutel niet geaccepteerd: {reason}",
     "rejected": "torqvoice.com heeft deze sleutel niet geaccepteerd: {reason}",
-    "signedHint": "De validatie wordt door torqvoice.com ondertekend en dagelijks herhaald, dus de server heeft uitgaande toegang tot torqvoice.com nodig.",
+    "signedHint": "Abonnementssleutels worden ondertekend door torqvoice.com en dagelijks opnieuw gecontroleerd, dus de server heeft uitgaande toegang tot torqvoice.com nodig. Een offline licentietoken wordt op deze server geverifieerd en heeft geen toegang nodig.",
     "makeItYours": "Maak het van jou",
     "makeItYours": "Maak het van jou",
     "makeItYoursHint": "Het Torqvoice-merk is weg. Nog twee stappen en de app ziet eruit als die van jou.",
     "makeItYoursHint": "Het Torqvoice-merk is weg. Nog twee stappen en de app ziet eruit als die van jou.",
     "linkAppName": "Appnaam instellen",
     "linkAppName": "Appnaam instellen",
     "linkAppIcon": "Eigen favicon en app-pictogram toevoegen",
     "linkAppIcon": "Eigen favicon en app-pictogram toevoegen",
     "adminOnlyHint": "Alleen een superbeheerder kan de appnaam en het app-pictogram wijzigen. Vraag het aan degene die deze installatie beheert.",
     "adminOnlyHint": "Alleen een superbeheerder kan de appnaam en het app-pictogram wijzigen. Vraag het aan degene die deze installatie beheert.",
     "makeItYoursLocked": "Een white-label-licentie ontgrendelt de appnaam en het app-pictogram.",
     "makeItYoursLocked": "Een white-label-licentie ontgrendelt de appnaam en het app-pictogram.",
-    "needsLicense": "Licentie nodig"
+    "needsLicense": "Licentie nodig",
+    "mismatch": "Past niet bij deze werkplaats",
+    "perpetual": "Eeuwigdurend",
+    "licensedTo": "Gelicentieerd aan {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Deze licentie wordt op deze server geverifieerd en hoeft nooit contact op te nemen met torqvoice.com.",
+    "perpetualWorkshops": "Geldt voor {count, plural, one {# werkplaats} other {# werkplaatsen}} op deze installatie.",
+    "mismatchExplain": "Deze licentie is uitgegeven voor organisatienummer {licensed}, maar het organisatienummer van deze werkplaats is {current}.",
+    "mismatchExplainNotSet": "Deze licentie is uitgegeven voor organisatienummer {licensed}, maar voor deze werkplaats is geen organisatienummer ingesteld.",
+    "openCompanySettings": "Bedrijfsinstellingen openen",
+    "notSet": "Niet ingesteld",
+    "offlineActivated": "Offline licentie geactiveerd",
+    "offlineTitle": "Offline licentie",
+    "offlineDescription": "Een eenmalige aankoop die de Torqvoice-branding voor deze ene werkplaats verwijdert en nooit contact opneemt met torqvoice.com.",
+    "offlineBinding": "De licentie is gekoppeld aan het organisatienummer van je bedrijf. De werkplaats een andere naam geven kan gewoon, maar een nieuwe rechtspersoon heeft een nieuwe licentie nodig.",
+    "offlineOrganizationId": "Organisatie-ID",
+    "offlineWorkshop": "Werkplaats",
+    "offlineOrgNumber": "Organisatienummer",
+    "offlineOrgNumberRequired": "Er is een organisatienummer nodig voordat een offline licentie kan worden uitgegeven.",
+    "requestCode": "Licentieaanvraagcode",
+    "copyRequestCode": "Kopiëren",
+    "requestCodeCopied": "Aanvraagcode gekopieerd",
+    "requestCodeCopyFailed": "Kon de aanvraagcode niet kopiëren",
+    "offlineOrderHint": "Bestel op torqvoice.com met deze code.",
+    "orderOffline": "Offline licentie bestellen"
   },
   },
   "about": {
   "about": {
     "title": "Over Torqvoice",
     "title": "Over Torqvoice",

+ 28 - 4
messages/pl/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Aktywna",
     "active": "Aktywna",
     "inactive": "Nieaktywna",
     "inactive": "Nieaktywna",
     "lastChecked": "Ostatnio sprawdzono: {date}",
     "lastChecked": "Ostatnio sprawdzono: {date}",
-    "enterLicenseKey": "Wprowadź klucz licencji",
+    "enterLicenseKey": "Wpisz klucz licencyjny lub token licencji offline",
     "validate": "Zweryfikuj",
     "validate": "Zweryfikuj",
     "validated": "Licencja zweryfikowana pomyślnie",
     "validated": "Licencja zweryfikowana pomyślnie",
     "invalid": "Nieprawidlowy klucz licencji",
     "invalid": "Nieprawidlowy klucz licencji",
+    "offlineInvalid": "To nie jest ważny token licencji offline dla tego warsztatu. Licencja zapisana na tym serwerze pozostaje bez zmian.",
     "failedValidate": "Nie udało się zweryfikować licencji",
     "failedValidate": "Nie udało się zweryfikować licencji",
-    "keyHint": "Każdy klucz licencji jest powiazany z ta organizacja i nie może być wspoldzielony między wieloma organizacjami.",
+    "keyHint": "Każdy klucz licencyjny lub token licencji offline jest powiązany z tą organizacją i nie może być współdzielony między wieloma organizacjami.",
     "expired": "Wygasła",
     "expired": "Wygasła",
     "unverified": "Niezweryfikowana",
     "unverified": "Niezweryfikowana",
     "expiresOn": "Wygasa: {date}",
     "expiresOn": "Wygasa: {date}",
     "unreachable": "Nie udało się połączyć z torqvoice.com. Licencja zapisana na tym serwerze pozostaje bez zmian.",
     "unreachable": "Nie udało się połączyć z torqvoice.com. Licencja zapisana na tym serwerze pozostaje bez zmian.",
     "unverifiedHint": "Ten serwer od {days} dni nie może potwierdzić licencji w torqvoice.com. Po {max} dniach bez udanego sprawdzenia oznaczenie Torqvoice wraca.",
     "unverifiedHint": "Ten serwer od {days} dni nie może potwierdzić licencji w torqvoice.com. Po {max} dniach bez udanego sprawdzenia oznaczenie Torqvoice wraca.",
     "rejected": "torqvoice.com nie przyjął tego klucza: {reason}",
     "rejected": "torqvoice.com nie przyjął tego klucza: {reason}",
-    "signedHint": "Walidacja jest podpisywana przez torqvoice.com i powtarzana codziennie, więc serwer potrzebuje wychodzącego dostępu do torqvoice.com.",
+    "signedHint": "Klucze subskrypcji są podpisywane przez torqvoice.com i sprawdzane codziennie, więc serwer potrzebuje dostępu wychodzącego do torqvoice.com. Token licencji offline jest weryfikowany na tym serwerze i nie potrzebuje dostępu.",
     "makeItYours": "Zrób to po swojemu",
     "makeItYours": "Zrób to po swojemu",
     "makeItYoursHint": "Znak Torqvoice zniknął. Jeszcze dwa kroki, a aplikacja będzie wyglądać jak Twoja.",
     "makeItYoursHint": "Znak Torqvoice zniknął. Jeszcze dwa kroki, a aplikacja będzie wyglądać jak Twoja.",
     "linkAppName": "Ustaw nazwę aplikacji",
     "linkAppName": "Ustaw nazwę aplikacji",
     "linkAppIcon": "Dodaj własny favicon i ikonę aplikacji",
     "linkAppIcon": "Dodaj własny favicon i ikonę aplikacji",
     "adminOnlyHint": "Tylko superadministrator może zmienić nazwę i ikonę aplikacji. Zwróć się do osoby prowadzącej tę instalację.",
     "adminOnlyHint": "Tylko superadministrator może zmienić nazwę i ikonę aplikacji. Zwróć się do osoby prowadzącej tę instalację.",
     "makeItYoursLocked": "Licencja white-label odblokowuje nazwę i ikonę aplikacji.",
     "makeItYoursLocked": "Licencja white-label odblokowuje nazwę i ikonę aplikacji.",
-    "needsLicense": "Wymaga licencji"
+    "needsLicense": "Wymaga licencji",
+    "mismatch": "Nie pasuje do tego warsztatu",
+    "perpetual": "Bezterminowa",
+    "licensedTo": "Licencjobiorca: {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Ta licencja jest weryfikowana na tym serwerze i nigdy nie musi łączyć się z torqvoice.com.",
+    "perpetualWorkshops": "Obejmuje {count, plural, one {# warsztat} few {# warsztaty} many {# warsztatów} other {# warsztatu}} w tej instalacji.",
+    "mismatchExplain": "Ta licencja została wystawiona na numer organizacji {licensed}, ale numer organizacji tego warsztatu to {current}.",
+    "mismatchExplainNotSet": "Ta licencja została wystawiona na numer organizacji {licensed}, ale ten warsztat nie ma ustawionego numeru organizacji.",
+    "openCompanySettings": "Otwórz ustawienia firmy",
+    "notSet": "Nie ustawiono",
+    "offlineActivated": "Licencja offline aktywowana",
+    "offlineTitle": "Licencja offline",
+    "offlineDescription": "Jednorazowy zakup, który usuwa oznaczenia Torqvoice dla tego jednego warsztatu i nigdy nie łączy się z torqvoice.com.",
+    "offlineBinding": "Licencja jest powiązana z numerem organizacji Twojej firmy. Zmiana nazwy warsztatu nie stanowi problemu, ale nowy podmiot prawny potrzebuje nowej licencji.",
+    "offlineOrganizationId": "ID organizacji",
+    "offlineWorkshop": "Warsztat",
+    "offlineOrgNumber": "Numer organizacji",
+    "offlineOrgNumberRequired": "Przed wystawieniem licencji offline trzeba ustawić numer organizacji.",
+    "requestCode": "Kod żądania licencji",
+    "copyRequestCode": "Kopiuj",
+    "requestCodeCopied": "Skopiowano kod żądania",
+    "requestCodeCopyFailed": "Nie udało się skopiować kodu żądania",
+    "offlineOrderHint": "Zamów na torqvoice.com, podając ten kod.",
+    "orderOffline": "Zamów licencję offline"
   },
   },
   "about": {
   "about": {
     "title": "O Torqvoice",
     "title": "O Torqvoice",

+ 28 - 4
messages/pt-BR/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Ativa",
     "active": "Ativa",
     "inactive": "Inativa",
     "inactive": "Inativa",
     "lastChecked": "Última verificação: {date}",
     "lastChecked": "Última verificação: {date}",
-    "enterLicenseKey": "Insira a chave de licença",
+    "enterLicenseKey": "Digite uma chave de licença ou um token de licença offline",
     "validate": "Validar",
     "validate": "Validar",
     "validated": "Licença validada com sucesso",
     "validated": "Licença validada com sucesso",
     "invalid": "Chave de licença inválida",
     "invalid": "Chave de licença inválida",
+    "offlineInvalid": "Este não é um token de licença offline válido para esta oficina. A licença salva neste servidor não foi alterada.",
     "failedValidate": "Falha ao validar a licença",
     "failedValidate": "Falha ao validar a licença",
-    "keyHint": "Cada chave de licença está vinculada a esta organização e não pode ser compartilhada entre várias organizações.",
+    "keyHint": "Cada chave de licença ou token de licença offline está vinculado a esta organização e não pode ser compartilhado entre várias organizações.",
     "expired": "Expirada",
     "expired": "Expirada",
     "unverified": "Não verificada",
     "unverified": "Não verificada",
     "expiresOn": "Expira em: {date}",
     "expiresOn": "Expira em: {date}",
     "unreachable": "Não foi possível acessar torqvoice.com. A licença salva neste servidor permanece inalterada.",
     "unreachable": "Não foi possível acessar torqvoice.com. A licença salva neste servidor permanece inalterada.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta após {max} dias sem uma verificação bem-sucedida.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta após {max} dias sem uma verificação bem-sucedida.",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
-    "signedHint": "A validação é assinada por torqvoice.com e repetida diariamente, portanto o servidor precisa de acesso de saída a torqvoice.com.",
+    "signedHint": "As chaves de assinatura são assinadas pelo torqvoice.com e verificadas novamente todos os dias, então o servidor precisa de acesso de saída ao torqvoice.com. Um token de licença offline é verificado neste servidor e não precisa de acesso.",
     "makeItYours": "Deixe com a sua cara",
     "makeItYours": "Deixe com a sua cara",
     "makeItYoursHint": "A marca Torqvoice sumiu. Mais dois passos e o aplicativo fica com a sua cara.",
     "makeItYoursHint": "A marca Torqvoice sumiu. Mais dois passos e o aplicativo fica com a sua cara.",
     "linkAppName": "Definir o nome do aplicativo",
     "linkAppName": "Definir o nome do aplicativo",
     "linkAppIcon": "Adicionar seu próprio favicon e ícone do aplicativo",
     "linkAppIcon": "Adicionar seu próprio favicon e ícone do aplicativo",
     "adminOnlyHint": "Somente um superadministrador pode alterar o nome e o ícone do aplicativo. Fale com quem cuida desta instalação.",
     "adminOnlyHint": "Somente um superadministrador pode alterar o nome e o ícone do aplicativo. Fale com quem cuida desta instalação.",
     "makeItYoursLocked": "Uma licença white-label libera o nome e o ícone do aplicativo.",
     "makeItYoursLocked": "Uma licença white-label libera o nome e o ícone do aplicativo.",
-    "needsLicense": "Precisa de licença"
+    "needsLicense": "Precisa de licença",
+    "mismatch": "Não corresponde a esta oficina",
+    "perpetual": "Perpétua",
+    "licensedTo": "Licenciado para {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Esta licença é verificada neste servidor e nunca precisa entrar em contato com o torqvoice.com.",
+    "perpetualWorkshops": "Cobre {count, plural, one {# oficina} other {# oficinas}} nesta instalação.",
+    "mismatchExplain": "Esta licença foi emitida para o número da organização {licensed}, mas o número da organização desta oficina é {current}.",
+    "mismatchExplainNotSet": "Esta licença foi emitida para o número da organização {licensed}, mas esta oficina não tem número da organização definido.",
+    "openCompanySettings": "Abrir as configurações da Empresa",
+    "notSet": "Não definido",
+    "offlineActivated": "Licença offline ativada",
+    "offlineTitle": "Licença offline",
+    "offlineDescription": "Uma compra única que remove a marca Torqvoice desta oficina e nunca entra em contato com o torqvoice.com.",
+    "offlineBinding": "A licença fica vinculada ao número da organização da sua empresa. Renomear a oficina não tem problema, mas uma nova pessoa jurídica precisa de uma nova licença.",
+    "offlineOrganizationId": "ID da organização",
+    "offlineWorkshop": "Oficina",
+    "offlineOrgNumber": "Número da organização",
+    "offlineOrgNumberRequired": "É preciso definir um número da organização antes que uma licença offline possa ser emitida.",
+    "requestCode": "Código de solicitação de licença",
+    "copyRequestCode": "Copiar",
+    "requestCodeCopied": "Código de solicitação copiado",
+    "requestCodeCopyFailed": "Não foi possível copiar o código de solicitação",
+    "offlineOrderHint": "Faça o pedido no torqvoice.com com este código.",
+    "orderOffline": "Pedir licença offline"
   },
   },
   "about": {
   "about": {
     "title": "Sobre o Torqvoice",
     "title": "Sobre o Torqvoice",

+ 28 - 4
messages/pt-PT/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Ativa",
     "active": "Ativa",
     "inactive": "Inativa",
     "inactive": "Inativa",
     "lastChecked": "Última verificação: {date}",
     "lastChecked": "Última verificação: {date}",
-    "enterLicenseKey": "Introduza a chave de licença",
+    "enterLicenseKey": "Introduza uma chave de licença ou um token de licença offline",
     "validate": "Validar",
     "validate": "Validar",
     "validated": "Licença validada com sucesso",
     "validated": "Licença validada com sucesso",
     "invalid": "Chave de licença inválida",
     "invalid": "Chave de licença inválida",
+    "offlineInvalid": "Este não é um token de licença offline válido para esta oficina. A licença guardada neste servidor mantém-se inalterada.",
     "failedValidate": "Não foi possível validar a licença",
     "failedValidate": "Não foi possível validar a licença",
-    "keyHint": "Cada chave de licença está associada a esta organização e não pode ser partilhada entre várias organizações.",
+    "keyHint": "Cada chave de licença ou token de licença offline está associado a esta organização e não pode ser partilhado entre várias organizações.",
     "expired": "Expirada",
     "expired": "Expirada",
     "unverified": "Não verificada",
     "unverified": "Não verificada",
     "expiresOn": "Expira em: {date}",
     "expiresOn": "Expira em: {date}",
     "unreachable": "Não foi possível contactar torqvoice.com. A licença guardada neste servidor mantém-se inalterada.",
     "unreachable": "Não foi possível contactar torqvoice.com. A licença guardada neste servidor mantém-se inalterada.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta a aparecer após {max} dias sem uma verificação bem-sucedida.",
     "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta a aparecer após {max} dias sem uma verificação bem-sucedida.",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
     "rejected": "torqvoice.com não aceitou esta chave: {reason}",
-    "signedHint": "A validação é assinada por torqvoice.com e repetida diariamente, por isso o servidor precisa de acesso de saída a torqvoice.com.",
+    "signedHint": "As chaves de subscrição são assinadas pelo torqvoice.com e verificadas novamente todos os dias, pelo que o servidor precisa de acesso de saída ao torqvoice.com. Um token de licença offline é verificado neste servidor e não precisa de acesso.",
     "makeItYours": "Torne-a sua",
     "makeItYours": "Torne-a sua",
     "makeItYoursHint": "A marca Torqvoice desapareceu. Mais dois passos e a aplicação fica com o seu aspeto.",
     "makeItYoursHint": "A marca Torqvoice desapareceu. Mais dois passos e a aplicação fica com o seu aspeto.",
     "linkAppName": "Definir o nome da aplicação",
     "linkAppName": "Definir o nome da aplicação",
     "linkAppIcon": "Adicionar o seu próprio favicon e ícone da aplicação",
     "linkAppIcon": "Adicionar o seu próprio favicon e ícone da aplicação",
     "adminOnlyHint": "Só um superadministrador pode alterar o nome e o ícone da aplicação. Fale com quem gere esta instalação.",
     "adminOnlyHint": "Só um superadministrador pode alterar o nome e o ícone da aplicação. Fale com quem gere esta instalação.",
     "makeItYoursLocked": "Uma licença white-label desbloqueia o nome e o ícone da aplicação.",
     "makeItYoursLocked": "Uma licença white-label desbloqueia o nome e o ícone da aplicação.",
-    "needsLicense": "Requer licença"
+    "needsLicense": "Requer licença",
+    "mismatch": "Não corresponde a esta oficina",
+    "perpetual": "Perpétua",
+    "licensedTo": "Licenciado a {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Esta licença é verificada neste servidor e nunca precisa de contactar o torqvoice.com.",
+    "perpetualWorkshops": "Abrange {count, plural, one {# oficina} other {# oficinas}} nesta instalação.",
+    "mismatchExplain": "Esta licença foi emitida para o NIF {licensed}, mas o NIF desta oficina é {current}.",
+    "mismatchExplainNotSet": "Esta licença foi emitida para o NIF {licensed}, mas esta oficina não tem NIF definido.",
+    "openCompanySettings": "Abrir as definições da Empresa",
+    "notSet": "Não definido",
+    "offlineActivated": "Licença offline ativada",
+    "offlineTitle": "Licença offline",
+    "offlineDescription": "Uma compra única que remove a marca Torqvoice desta oficina e nunca contacta o torqvoice.com.",
+    "offlineBinding": "A licença fica associada ao NIF da sua empresa. Mudar o nome da oficina não é problema, mas uma nova pessoa coletiva precisa de uma nova licença.",
+    "offlineOrganizationId": "ID da organização",
+    "offlineWorkshop": "Oficina",
+    "offlineOrgNumber": "NIF",
+    "offlineOrgNumberRequired": "É necessário definir o NIF antes de poder ser emitida uma licença offline.",
+    "requestCode": "Código de pedido de licença",
+    "copyRequestCode": "Copiar",
+    "requestCodeCopied": "Código de pedido copiado",
+    "requestCodeCopyFailed": "Não foi possível copiar o código de pedido",
+    "offlineOrderHint": "Encomende em torqvoice.com com este código.",
+    "orderOffline": "Encomendar licença offline"
   },
   },
   "about": {
   "about": {
     "title": "Sobre o Torqvoice",
     "title": "Sobre o Torqvoice",

+ 28 - 4
messages/ru/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Активна",
     "active": "Активна",
     "inactive": "Неактивна",
     "inactive": "Неактивна",
     "lastChecked": "Последняя проверка: {date}",
     "lastChecked": "Последняя проверка: {date}",
-    "enterLicenseKey": "Введите лицензионный ключ",
+    "enterLicenseKey": "Введите лицензионный ключ или токен офлайн-лицензии",
     "validate": "Проверить",
     "validate": "Проверить",
     "validated": "Лицензия успешно подтверждена",
     "validated": "Лицензия успешно подтверждена",
     "invalid": "Недействительный лицензионный ключ",
     "invalid": "Недействительный лицензионный ключ",
+    "offlineInvalid": "Это не действительный токен офлайн-лицензии для этой мастерской. Лицензия, сохранённая на этом сервере, не изменена.",
     "failedValidate": "Не удалось проверить лицензию",
     "failedValidate": "Не удалось проверить лицензию",
-    "keyHint": "Каждый лицензионный ключ привязан к этой организации и не может быть использован в нескольких организациях.",
+    "keyHint": "Каждый лицензионный ключ или токен офлайн-лицензии привязан к этой организации и не может использоваться в нескольких организациях.",
     "expired": "Истекла",
     "expired": "Истекла",
     "unverified": "Не проверена",
     "unverified": "Не проверена",
     "expiresOn": "Истекает: {date}",
     "expiresOn": "Истекает: {date}",
     "unreachable": "Не удалось связаться с torqvoice.com. Лицензия, сохранённая на этом сервере, не изменилась.",
     "unreachable": "Не удалось связаться с torqvoice.com. Лицензия, сохранённая на этом сервере, не изменилась.",
     "unverifiedHint": "Этот сервер уже {days} дн. не может подтвердить лицензию на torqvoice.com. Через {max} дн. без успешной проверки брендинг Torqvoice возвращается.",
     "unverifiedHint": "Этот сервер уже {days} дн. не может подтвердить лицензию на torqvoice.com. Через {max} дн. без успешной проверки брендинг Torqvoice возвращается.",
     "rejected": "torqvoice.com не принял этот ключ: {reason}",
     "rejected": "torqvoice.com не принял этот ключ: {reason}",
-    "signedHint": "Проверка подписывается torqvoice.com и повторяется ежедневно, поэтому серверу нужен исходящий доступ к torqvoice.com.",
+    "signedHint": "Ключи подписки подписываются torqvoice.com и проверяются ежедневно, поэтому серверу нужен исходящий доступ к torqvoice.com. Токен офлайн-лицензии проверяется на этом сервере, и доступ ему не нужен.",
     "makeItYours": "Сделайте его своим",
     "makeItYours": "Сделайте его своим",
     "makeItYoursHint": "Знак Torqvoice убран. Ещё два шага, и приложение будет выглядеть по-вашему.",
     "makeItYoursHint": "Знак Torqvoice убран. Ещё два шага, и приложение будет выглядеть по-вашему.",
     "linkAppName": "Задать название приложения",
     "linkAppName": "Задать название приложения",
     "linkAppIcon": "Добавить свой favicon и значок приложения",
     "linkAppIcon": "Добавить свой favicon и значок приложения",
     "adminOnlyHint": "Изменить название и значок приложения может только суперадминистратор. Обратитесь к тому, кто обслуживает эту установку.",
     "adminOnlyHint": "Изменить название и значок приложения может только суперадминистратор. Обратитесь к тому, кто обслуживает эту установку.",
     "makeItYoursLocked": "Лицензия white-label открывает название и значок приложения.",
     "makeItYoursLocked": "Лицензия white-label открывает название и значок приложения.",
-    "needsLicense": "Нужна лицензия"
+    "needsLicense": "Нужна лицензия",
+    "mismatch": "Не соответствует этой мастерской",
+    "perpetual": "Бессрочная",
+    "licensedTo": "Лицензиат: {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Эта лицензия проверяется на этом сервере, и ей никогда не нужно связываться с torqvoice.com.",
+    "perpetualWorkshops": "Действует для {count, plural, one {# мастерской} few {# мастерских} many {# мастерских} other {# мастерской}} в этой установке.",
+    "mismatchExplain": "Эта лицензия выдана на номер организации {licensed}, но номер организации этой мастерской {current}.",
+    "mismatchExplainNotSet": "Эта лицензия выдана на номер организации {licensed}, но у этой мастерской не указан номер организации.",
+    "openCompanySettings": "Открыть настройки компании",
+    "notSet": "Не указан",
+    "offlineActivated": "Офлайн-лицензия активирована",
+    "offlineTitle": "Офлайн-лицензия",
+    "offlineDescription": "Разовая покупка, которая убирает брендинг Torqvoice для этой мастерской и никогда не связывается с torqvoice.com.",
+    "offlineBinding": "Лицензия привязана к номеру организации вашей компании. Переименовать мастерскую можно, но для нового юридического лица нужна новая лицензия.",
+    "offlineOrganizationId": "ID организации",
+    "offlineWorkshop": "Мастерская",
+    "offlineOrgNumber": "Номер организации",
+    "offlineOrgNumberRequired": "Чтобы можно было выдать офлайн-лицензию, нужно указать номер организации.",
+    "requestCode": "Код запроса лицензии",
+    "copyRequestCode": "Копировать",
+    "requestCodeCopied": "Код запроса скопирован",
+    "requestCodeCopyFailed": "Не удалось скопировать код запроса",
+    "offlineOrderHint": "Оформите заказ на torqvoice.com с этим кодом.",
+    "orderOffline": "Заказать офлайн-лицензию"
   },
   },
   "about": {
   "about": {
     "title": "О Torqvoice",
     "title": "О Torqvoice",

+ 28 - 4
messages/tr/settings.json

@@ -1620,26 +1620,50 @@
     "active": "Aktif",
     "active": "Aktif",
     "inactive": "Pasif",
     "inactive": "Pasif",
     "lastChecked": "Son kontrol: {date}",
     "lastChecked": "Son kontrol: {date}",
-    "enterLicenseKey": "Lisans anahtarını girin",
+    "enterLicenseKey": "Bir lisans anahtarı veya çevrimdışı lisans belirteci girin",
     "validate": "Doğrula",
     "validate": "Doğrula",
     "validated": "Lisans başarıyla doğrulandı",
     "validated": "Lisans başarıyla doğrulandı",
     "invalid": "Geçersiz lisans anahtarı",
     "invalid": "Geçersiz lisans anahtarı",
+    "offlineInvalid": "Bu, bu atölye için geçerli bir çevrimdışı lisans belirteci değil. Bu sunucuda kayıtlı lisans değişmedi.",
     "failedValidate": "Lisans doğrulanamadı",
     "failedValidate": "Lisans doğrulanamadı",
-    "keyHint": "Her lisans anahtarı bu kuruluşa bağlıdır ve birden fazla kuruluşta paylaşılamaz.",
+    "keyHint": "Her lisans anahtarı veya çevrimdışı lisans belirteci bu kuruluşa bağlıdır ve birden fazla kuruluş arasında paylaşılamaz.",
     "expired": "Süresi doldu",
     "expired": "Süresi doldu",
     "unverified": "Doğrulanmadı",
     "unverified": "Doğrulanmadı",
     "expiresOn": "Bitiş: {date}",
     "expiresOn": "Bitiş: {date}",
     "unreachable": "torqvoice.com'a ulaşılamadı. Bu sunucuda kayıtlı lisans değişmedi.",
     "unreachable": "torqvoice.com'a ulaşılamadı. Bu sunucuda kayıtlı lisans değişmedi.",
     "unverifiedHint": "Bu sunucu {days} gündür lisansı torqvoice.com ile doğrulayamıyor. Başarılı bir kontrol olmadan {max} gün sonra Torqvoice markası geri döner.",
     "unverifiedHint": "Bu sunucu {days} gündür lisansı torqvoice.com ile doğrulayamıyor. Başarılı bir kontrol olmadan {max} gün sonra Torqvoice markası geri döner.",
     "rejected": "torqvoice.com bu anahtarı kabul etmedi: {reason}",
     "rejected": "torqvoice.com bu anahtarı kabul etmedi: {reason}",
-    "signedHint": "Doğrulama torqvoice.com tarafından imzalanır ve her gün yenilenir, bu nedenle sunucunun torqvoice.com'a giden erişimi olmalıdır.",
+    "signedHint": "Abonelik anahtarları torqvoice.com tarafından imzalanır ve her gün yeniden kontrol edilir, bu nedenle sunucunun torqvoice.com adresine giden erişimi olması gerekir. Çevrimdışı lisans belirteci bu sunucuda doğrulanır ve erişim gerektirmez.",
     "makeItYours": "Kendinize göre uyarlayın",
     "makeItYours": "Kendinize göre uyarlayın",
     "makeItYoursHint": "Torqvoice işareti kaldırıldı. İki adım daha ve uygulama sizin görünümünüze kavuşur.",
     "makeItYoursHint": "Torqvoice işareti kaldırıldı. İki adım daha ve uygulama sizin görünümünüze kavuşur.",
     "linkAppName": "Uygulama adını belirleyin",
     "linkAppName": "Uygulama adını belirleyin",
     "linkAppIcon": "Kendi favicon ve uygulama simgenizi ekleyin",
     "linkAppIcon": "Kendi favicon ve uygulama simgenizi ekleyin",
     "adminOnlyHint": "Uygulama adını ve simgesini yalnızca bir süper yönetici değiştirebilir. Bu kurulumu yöneten kişiye başvurun.",
     "adminOnlyHint": "Uygulama adını ve simgesini yalnızca bir süper yönetici değiştirebilir. Bu kurulumu yöneten kişiye başvurun.",
     "makeItYoursLocked": "White-label lisansı uygulama adını ve simgesini açar.",
     "makeItYoursLocked": "White-label lisansı uygulama adını ve simgesini açar.",
-    "needsLicense": "Lisans gerekir"
+    "needsLicense": "Lisans gerekir",
+    "mismatch": "Bu atölyeyle eşleşmiyor",
+    "perpetual": "Süresiz",
+    "licensedTo": "Lisans sahibi: {name} ({registrationNumber}), {country}",
+    "perpetualHint": "Bu lisans bu sunucuda doğrulanır ve torqvoice.com ile hiçbir zaman iletişim kurması gerekmez.",
+    "perpetualWorkshops": "Bu kurulumda {count, plural, one {# atölyeyi} other {# atölyeyi}} kapsar.",
+    "mismatchExplain": "Bu lisans {licensed} kuruluş numarası için düzenlendi, ancak bu atölyenin kuruluş numarası {current}.",
+    "mismatchExplainNotSet": "Bu lisans {licensed} kuruluş numarası için düzenlendi, ancak bu atölye için bir kuruluş numarası ayarlanmamış.",
+    "openCompanySettings": "Şirket ayarlarını aç",
+    "notSet": "Ayarlanmadı",
+    "offlineActivated": "Çevrimdışı lisans etkinleştirildi",
+    "offlineTitle": "Çevrimdışı lisans",
+    "offlineDescription": "Torqvoice markasını yalnızca bu atölye için kaldıran ve torqvoice.com ile hiçbir zaman iletişim kurmayan tek seferlik bir satın alma.",
+    "offlineBinding": "Lisans, işletmenizin kuruluş numarasına bağlıdır. Atölyenin adını değiştirmek sorun değil, ancak yeni bir tüzel kişilik yeni bir lisans gerektirir.",
+    "offlineOrganizationId": "Kuruluş kimliği",
+    "offlineWorkshop": "Atölye",
+    "offlineOrgNumber": "Kuruluş numarası",
+    "offlineOrgNumberRequired": "Çevrimdışı lisans düzenlenebilmesi için bir kuruluş numarası gereklidir.",
+    "requestCode": "Lisans talep kodu",
+    "copyRequestCode": "Kopyala",
+    "requestCodeCopied": "Talep kodu kopyalandı",
+    "requestCodeCopyFailed": "Talep kodu kopyalanamadı",
+    "offlineOrderHint": "Bu kodla torqvoice.com üzerinden sipariş verin.",
+    "orderOffline": "Çevrimdışı lisans sipariş et"
   },
   },
   "about": {
   "about": {
     "title": "Torqvoice Hakkında",
     "title": "Torqvoice Hakkında",

+ 258 - 7
src/__tests__/features/settings/license-settings.test.tsx

@@ -3,11 +3,16 @@
  * sees what the licence unlocks: the app name and icon rows are locked until
  * sees what the licence unlocks: the app name and icon rows are locked until
  * the licence is valid, then link a super admin to the admin page. Members
  * the licence is valid, then link a super admin to the admin page. Members
  * are told only a super admin can change those two.
  * are told only a super admin can change those two.
+ *
+ * The offline licence: who it was issued to, why it does not match this
+ * workshop, and the request card that hands torqvoice.com what it needs to
+ * mint one.
  */
  */
 
 
-import { describe, it, expect, vi } from 'vitest'
-import { render, screen, within } from '@testing-library/react'
-import type { LicenseTokenStatus } from '@/lib/license/token'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'
+import type { ComponentProps } from 'react'
+import type { LicenseTokenStatus, PerpetualLicensee } from '@/lib/license/token'
 
 
 vi.mock('next/navigation', () => ({
 vi.mock('next/navigation', () => ({
   useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }),
   useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }),
@@ -16,23 +21,70 @@ vi.mock('@/features/settings/Actions/validateLicense', () => ({ validateLicense:
 vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
 vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
 
 
 const { LicenseSettings } = await import('@/features/settings/Components/license-settings')
 const { LicenseSettings } = await import('@/features/settings/Components/license-settings')
+const { validateLicense } = await import('@/features/settings/Actions/validateLicense')
+const { toast } = await import('sonner')
 
 
 const ADMIN_HREF = '/admin/settings#app-branding'
 const ADMIN_HREF = '/admin/settings#app-branding'
 const SUPER_ADMIN_NOTE = /only a super admin can change the app name and icon/i
 const SUPER_ADMIN_NOTE = /only a super admin can change the app name and icon/i
 
 
-function renderPage(status: LicenseTokenStatus, brandingHref: string | null) {
+const ORG_ID = 'org_123'
+const WORKSHOP = 'Bilverksted Øst'
+const LICENSEE: PerpetualLicensee = {
+  name: 'Bilverksted Øst AS',
+  registrationNumber: '987654321',
+  country: 'NO',
+}
+
+type Props = ComponentProps<typeof LicenseSettings>
+
+function renderSettings(overrides: Partial<Props> = {}) {
   return render(
   return render(
     <LicenseSettings
     <LicenseSettings
       initialKey="TV-KEY"
       initialKey="TV-KEY"
-      initialStatus={status}
+      initialStatus="missing"
+      initialKind={null}
+      initialLicensee={null}
+      initialMaxOrganizations={null}
       initialExpiresAt=""
       initialExpiresAt=""
       initialIssuedAt=""
       initialIssuedAt=""
       initialCheckedAt=""
       initialCheckedAt=""
-      brandingHref={brandingHref}
+      organizationId={ORG_ID}
+      workshopName={WORKSHOP}
+      registrationNumber="987654321"
+      brandingHref={ADMIN_HREF}
+      {...overrides}
     />
     />
   )
   )
 }
 }
 
 
+function renderPage(status: LicenseTokenStatus, brandingHref: string | null) {
+  return renderSettings({
+    initialStatus: status,
+    initialKind: status === 'missing' ? null : 'subscription',
+    brandingHref,
+  })
+}
+
+function renderPerpetual(status: LicenseTokenStatus, registrationNumber = '987654321') {
+  return renderSettings({
+    initialKey: 'tvp1.payload.signature',
+    initialStatus: status,
+    initialKind: 'perpetual',
+    initialLicensee: LICENSEE,
+    initialMaxOrganizations: 1,
+    initialIssuedAt: '2020-01-01T00:00:00.000Z',
+    initialCheckedAt: '2020-01-01T00:00:00.000Z',
+    registrationNumber,
+  })
+}
+
+/** What torqvoice.com reads back out of the request code. */
+function decodeRequestCode(code: string): unknown {
+  expect(code.startsWith('tvr1.')).toBe(true)
+  const base64 = code.slice(5).replace(/-/g, '+').replace(/_/g, '/')
+  return JSON.parse(Buffer.from(base64, 'base64').toString('utf8'))
+}
+
 describe('LicenseSettings next steps', () => {
 describe('LicenseSettings next steps', () => {
   it('links a super admin to the app branding once the licence is valid', () => {
   it('links a super admin to the app branding once the licence is valid', () => {
     renderPage('valid', ADMIN_HREF)
     renderPage('valid', ADMIN_HREF)
@@ -46,7 +98,8 @@ describe('LicenseSettings next steps', () => {
       screen.getByRole('link', { name: /add your own favicon and app icon/i })
       screen.getByRole('link', { name: /add your own favicon and app icon/i })
     ).toHaveAttribute('href', ADMIN_HREF)
     ).toHaveAttribute('href', ADMIN_HREF)
     // Those two and nothing else: the list is about what the licence unlocks.
     // Those two and nothing else: the list is about what the licence unlocks.
-    expect(screen.getAllByRole('link')).toHaveLength(2)
+    const steps = within(document.getElementById('license-next-steps') as HTMLElement)
+    expect(steps.getAllByRole('link')).toHaveLength(2)
     expect(screen.queryByText('Needs a licence')).not.toBeInTheDocument()
     expect(screen.queryByText('Needs a licence')).not.toBeInTheDocument()
     expect(screen.queryByText(SUPER_ADMIN_NOTE)).not.toBeInTheDocument()
     expect(screen.queryByText(SUPER_ADMIN_NOTE)).not.toBeInTheDocument()
   })
   })
@@ -88,3 +141,201 @@ describe('LicenseSettings next steps', () => {
     expect(screen.getByText(SUPER_ADMIN_NOTE)).toBeInTheDocument()
     expect(screen.getByText(SUPER_ADMIN_NOTE)).toBeInTheDocument()
   })
   })
 })
 })
+
+describe('LicenseSettings offline licence', () => {
+  beforeEach(() => {
+    vi.mocked(toast.success).mockClear()
+    vi.mocked(toast.error).mockClear()
+    vi.mocked(validateLicense).mockReset()
+  })
+
+  it('names the licensee and marks a valid offline licence as perpetual', () => {
+    renderPerpetual('valid')
+
+    expect(screen.getByText('Active')).toBeInTheDocument()
+    expect(screen.getByText('Perpetual')).toBeInTheDocument()
+    expect(
+      screen.getByText('Licensed to Bilverksted Øst AS (987654321), Norway')
+    ).toBeInTheDocument()
+    expect(
+      screen.getByText(/verified on this server and never needs to contact torqvoice\.com/i)
+    ).toBeInTheDocument()
+    // No term and no check-in for a licence that never calls home.
+    expect(screen.queryByText(/^Expires:/)).not.toBeInTheDocument()
+    expect(screen.queryByText(/^Last checked:/)).not.toBeInTheDocument()
+    expect(screen.queryByText(/has not been able to confirm/i)).not.toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: /purchase white-label/i })).not.toBeInTheDocument()
+  })
+
+  it('explains a mismatch with the number this workshop has now', () => {
+    renderPerpetual('mismatch', '111222333')
+
+    expect(screen.getByText('Does not match this workshop')).toBeInTheDocument()
+    expect(screen.queryByText('Perpetual')).not.toBeInTheDocument()
+    const licensee = within(document.getElementById('license-licensee') as HTMLElement)
+    expect(
+      licensee.getByText(
+        /issued to organization number 987654321, but this workshop's organization number is 111222333/i
+      )
+    ).toBeInTheDocument()
+    expect(licensee.getByRole('link', { name: 'Open Company settings' })).toHaveAttribute(
+      'href',
+      '/settings/company#orgNumber'
+    )
+    // A replacement can still be pasted.
+    expect(screen.getByPlaceholderText(/licence key or an offline licence token/i)).toBeVisible()
+  })
+
+  it('says so when the workshop has no organization number at all', () => {
+    renderPerpetual('mismatch', '')
+
+    expect(
+      screen.getByText(/but this workshop has no organization number set/i)
+    ).toBeInTheDocument()
+  })
+
+  it('offers a request code carrying the workshop, and copies it', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
+    renderPage('valid', ADMIN_HREF)
+
+    const card = within(document.getElementById('license-offline') as HTMLElement)
+    expect(card.getByText('Offline licence')).toBeInTheDocument()
+    expect(card.getByText(ORG_ID)).toBeInTheDocument()
+    expect(card.getByText(WORKSHOP)).toBeInTheDocument()
+    expect(card.getByText('987654321')).toBeInTheDocument()
+
+    const code = (card.getByLabelText('Licence request code') as HTMLInputElement).value
+    expect(decodeRequestCode(code)).toEqual({
+      v: 1,
+      org: ORG_ID,
+      name: WORKSHOP,
+      registrationNumber: '987654321',
+    })
+
+    fireEvent.click(card.getByRole('button', { name: 'Copy' }))
+    await waitFor(() => expect(writeText).toHaveBeenCalledWith(code))
+    expect(toast.success).toHaveBeenCalledWith('Request code copied')
+    expect(card.getByRole('link', { name: /order offline licence/i })).toHaveAttribute(
+      'href',
+      'https://torqvoice.com/subscriptions/offline-license'
+    )
+  })
+
+  it('will not hand out a request code until the organization number is set', () => {
+    renderSettings({ registrationNumber: '  ' })
+
+    const card = within(document.getElementById('license-offline') as HTMLElement)
+    expect(card.getByText('Not set')).toBeInTheDocument()
+    expect(card.getByRole('link', { name: 'Open Company settings' })).toHaveAttribute(
+      'href',
+      '/settings/company#orgNumber'
+    )
+    expect(card.getByText(/organization number is required/i)).toBeInTheDocument()
+    expect(card.getByRole('button', { name: 'Copy' })).toBeDisabled()
+    expect((card.getByLabelText('Licence request code') as HTMLInputElement).value).toBe('')
+  })
+
+  it('hides the offline card once a perpetual licence is active, and in the demo', () => {
+    const { unmount } = renderPerpetual('valid')
+    expect(document.getElementById('license-offline')).toBeNull()
+    unmount()
+
+    renderSettings({ demoMode: true })
+    expect(document.getElementById('license-offline')).toBeNull()
+  })
+
+  it('keeps the offline card for a perpetual licence that does not match', () => {
+    renderPerpetual('mismatch', '111222333')
+    expect(document.getElementById('license-offline')).not.toBeNull()
+  })
+
+  it('says the offline licence is activated, never that torqvoice.com was unreachable', async () => {
+    vi.mocked(validateLicense).mockResolvedValue({
+      success: true,
+      data: {
+        status: 'valid',
+        valid: true,
+        plan: 'white-label',
+        reachable: true,
+        kind: 'perpetual',
+        licensee: LICENSEE,
+        attempted: 'perpetual',
+        accepted: true,
+      },
+    } as Awaited<ReturnType<typeof validateLicense>>)
+    renderSettings()
+
+    fireEvent.change(screen.getByPlaceholderText(/licence key or an offline licence token/i), {
+      target: { value: 'tvp1.payload.signature' },
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'Validate' }))
+
+    await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Offline licence activated'))
+    expect(toast.error).not.toHaveBeenCalled()
+    expect(await screen.findByText('Perpetual')).toBeInTheDocument()
+    expect(
+      screen.getByText('Licensed to Bilverksted Øst AS (987654321), Norway')
+    ).toBeInTheDocument()
+  })
+
+  it('shows the mismatch message when the pasted token names another business', async () => {
+    vi.mocked(validateLicense).mockResolvedValue({
+      success: true,
+      data: {
+        status: 'mismatch',
+        valid: false,
+        plan: 'white-label',
+        reachable: true,
+        kind: 'perpetual',
+        licensee: { ...LICENSEE, registrationNumber: '555666777' },
+        attempted: 'perpetual',
+        accepted: true,
+      },
+    } as Awaited<ReturnType<typeof validateLicense>>)
+    renderSettings()
+
+    fireEvent.change(screen.getByPlaceholderText(/licence key or an offline licence token/i), {
+      target: { value: 'tvp1.payload.signature' },
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'Validate' }))
+
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith(
+        "This licence was issued to organization number 555666777, but this workshop's organization number is 987654321."
+      )
+    )
+  })
+  it('keeps the active licence on screen when a pasted token is rejected', async () => {
+    vi.mocked(validateLicense).mockResolvedValue({
+      success: true,
+      data: {
+        // What is still on disk: the valid offline licence.
+        status: 'valid',
+        valid: true,
+        plan: 'white-label',
+        reachable: true,
+        kind: 'perpetual',
+        licensee: LICENSEE,
+        attempted: 'perpetual',
+        accepted: false,
+      },
+    } as Awaited<ReturnType<typeof validateLicense>>)
+    renderSettings({ initialStatus: 'valid', initialKind: 'perpetual', initialLicensee: LICENSEE })
+
+    fireEvent.change(screen.getByPlaceholderText(/licence key or an offline licence token/i), {
+      target: { value: 'tvp1.garbage.sig' },
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'Validate' }))
+
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith(
+        'This is not a valid offline licence token for this workshop. The licence stored on this server is unchanged.'
+      )
+    )
+    expect(toast.success).not.toHaveBeenCalled()
+    expect(screen.getByText('Active')).toBeInTheDocument()
+    expect(screen.getByText('Perpetual')).toBeInTheDocument()
+    expect(screen.queryByText('Inactive')).not.toBeInTheDocument()
+  })
+})

+ 250 - 0
src/__tests__/features/settings/validate-license.test.ts

@@ -0,0 +1,250 @@
+/**
+ * @vitest-environment node
+ *
+ * The Validate button on the License page takes either a licence key or a
+ * pasted perpetual token. A key goes to torqvoice.com; a `tvp1.` token is
+ * verified and stored offline and never leaves the server.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const deleteMany = vi.hoisted(() => vi.fn())
+vi.mock('@/lib/db', () => ({ db: { appSetting: { deleteMany } } }))
+
+const seenOptions = vi.hoisted(() => [] as unknown[])
+vi.mock('@/lib/with-auth', () => ({
+  withAuth: async (
+    action: (ctx: { userId: string; organizationId: string }) => unknown,
+    options?: unknown
+  ) => {
+    seenOptions.push(options)
+    return { success: true, data: await action({ userId: 'u-1', organizationId: 'org-1' }) }
+  },
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/demo', () => ({ demoGuard: vi.fn() }))
+vi.mock('@/lib/license/revalidate', () => ({
+  revalidateLicense: vi.fn(),
+  storedLicenseVerification: vi.fn(),
+  storePerpetualLicense: vi.fn(),
+}))
+
+import { validateLicense } from '@/features/settings/Actions/validateLicense'
+import { demoGuard } from '@/lib/demo'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import {
+  revalidateLicense,
+  storedLicenseVerification,
+  storePerpetualLicense,
+} from '@/lib/license/revalidate'
+import type { LicenseTokenVerification } from '@/lib/license/token'
+
+const LICENSEE = {
+  name: 'Example Workshop AS',
+  registrationNumber: 'NO 123 456 789 MVA',
+  country: 'NO',
+}
+
+function verification(extra: Partial<LicenseTokenVerification>): LicenseTokenVerification {
+  return {
+    status: 'valid',
+    kind: 'subscription',
+    payload: null,
+    ageDays: 0,
+    daysUntilExpiry: null,
+    expiresAt: null,
+    maxOrganizations: null,
+    licensee: null,
+    ...extra,
+  }
+}
+
+const perpetualPayload = {
+  v: 1 as const,
+  lid: 'lic-p1',
+  org: 'org-1',
+  plan: 'white-label',
+  maxOrganizations: 1,
+  licensee: LICENSEE,
+  issuedAt: '2026-10-01T00:00:00.000Z',
+}
+
+beforeEach(() => {
+  vi.mocked(revalidateLicense).mockReset()
+  vi.mocked(storePerpetualLicense).mockReset()
+  vi.mocked(demoGuard).mockReset()
+  deleteMany.mockReset().mockResolvedValue({ count: 0 })
+  seenOptions.length = 0
+})
+
+describe('validateLicense', () => {
+  it('stores a pasted perpetual token offline', async () => {
+    vi.mocked(storePerpetualLicense).mockResolvedValue(
+      verification({
+        status: 'valid',
+        kind: 'perpetual',
+        payload: perpetualPayload,
+        maxOrganizations: 1,
+        licensee: LICENSEE,
+      })
+    )
+    const result = await validateLicense('  tvp1.payload.sig  ')
+
+    expect(storePerpetualLicense).toHaveBeenCalledWith('org-1', 'tvp1.payload.sig', 'u-1')
+    expect(revalidateLicense).not.toHaveBeenCalled()
+    expect(result).toEqual({
+      success: true,
+      data: {
+        status: 'valid',
+        valid: true,
+        plan: 'white-label',
+        kind: 'perpetual',
+        licensee: LICENSEE,
+        attempted: 'perpetual',
+        accepted: true,
+        reachable: true,
+      },
+    })
+  })
+
+  it('keeps the stored licence on screen when a pasted token does not verify', async () => {
+    vi.mocked(storePerpetualLicense).mockResolvedValue(
+      verification({ status: 'invalid', kind: null })
+    )
+    vi.mocked(storedLicenseVerification).mockResolvedValue(
+      verification({
+        status: 'valid',
+        kind: 'perpetual',
+        payload: perpetualPayload,
+        maxOrganizations: 1,
+        licensee: LICENSEE,
+      })
+    )
+    const result = await validateLicense('tvp1.garbage.sig')
+
+    expect(storedLicenseVerification).toHaveBeenCalledWith('org-1')
+    // Nothing changed, so the banner dismissal and the cache stay as they were.
+    expect(deleteMany).not.toHaveBeenCalled()
+    expect(result.data).toEqual({
+      status: 'valid',
+      valid: true,
+      plan: 'white-label',
+      kind: 'perpetual',
+      licensee: LICENSEE,
+      attempted: 'perpetual',
+      accepted: false,
+      reachable: true,
+    })
+  })
+
+  it('reports a mismatch with the licensee so the page can name the number', async () => {
+    vi.mocked(storePerpetualLicense).mockResolvedValue(
+      verification({
+        status: 'mismatch',
+        kind: 'perpetual',
+        payload: perpetualPayload,
+        maxOrganizations: 1,
+        licensee: LICENSEE,
+      })
+    )
+    const result = await validateLicense('tvp1.payload.sig')
+    expect(result.data).toMatchObject({
+      status: 'mismatch',
+      valid: false,
+      kind: 'perpetual',
+      licensee: LICENSEE,
+      attempted: 'perpetual',
+      accepted: true,
+    })
+  })
+
+  it('sends a licence key to torqvoice.com as before', async () => {
+    vi.mocked(revalidateLicense).mockResolvedValue({
+      remote: { reachable: true, valid: true, plan: 'white-label', expiresAt: '', token: 't' },
+      verification: verification({ status: 'valid' }),
+    })
+    const result = await validateLicense('TQV-ABCD-1234')
+
+    expect(revalidateLicense).toHaveBeenCalledWith('org-1', 'TQV-ABCD-1234', 'u-1', {
+      replacePerpetual: true,
+    })
+    expect(storePerpetualLicense).not.toHaveBeenCalled()
+    expect(result.data).toMatchObject({
+      status: 'valid',
+      valid: true,
+      plan: 'white-label',
+      reachable: true,
+      kind: 'subscription',
+      licensee: null,
+      attempted: 'subscription',
+      accepted: true,
+    })
+  })
+
+  it('reports a rejected key without touching the offline licence that stays on disk', async () => {
+    vi.mocked(revalidateLicense).mockResolvedValue({
+      remote: {
+        reachable: true,
+        valid: false,
+        plan: 'free',
+        expiresAt: '',
+        token: null,
+        error: 'License not found',
+      },
+      verification: verification({
+        status: 'valid',
+        kind: 'perpetual',
+        payload: perpetualPayload,
+        licensee: LICENSEE,
+      }),
+    })
+    const result = await validateLicense('TQV-WRONG')
+
+    expect(deleteMany).not.toHaveBeenCalled()
+    expect(result.data).toEqual({
+      status: 'valid',
+      valid: true,
+      plan: 'white-label',
+      kind: 'perpetual',
+      licensee: LICENSEE,
+      attempted: 'subscription',
+      accepted: false,
+      reachable: true,
+      reason: 'License not found',
+    })
+  })
+
+  it('says torqvoice.com was unreachable and reports the licence still on disk', async () => {
+    vi.mocked(revalidateLicense).mockResolvedValue({
+      remote: { reachable: false, valid: false, plan: 'free', expiresAt: '', token: null },
+      verification: verification({ status: 'stale', kind: 'subscription' }),
+    })
+    const result = await validateLicense('TQV-ABCD-1234')
+    expect(result.data).toMatchObject({
+      status: 'stale',
+      valid: false,
+      kind: 'subscription',
+      attempted: 'subscription',
+      accepted: false,
+      reachable: false,
+    })
+  })
+
+  it('keeps the demo guard and the settings permission on both paths', async () => {
+    vi.mocked(storePerpetualLicense).mockResolvedValue(verification({ status: 'invalid' }))
+    await validateLicense('tvp1.payload.sig')
+    expect(demoGuard).toHaveBeenCalledTimes(1)
+    expect(seenOptions[0]).toEqual({
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+    })
+  })
+
+  it('lets the expiry banner warn again after a perpetual token is stored', async () => {
+    vi.mocked(storePerpetualLicense).mockResolvedValue(verification({ status: 'valid' }))
+    await validateLicense('tvp1.payload.sig')
+    expect(deleteMany).toHaveBeenCalledWith({
+      where: { organizationId: 'org-1', key: 'license.expiryDismissed' },
+    })
+  })
+})

+ 130 - 7
src/__tests__/lib/features.test.ts

@@ -28,10 +28,12 @@ import { db } from '@/lib/db'
 import {
 import {
   getFeatures,
   getFeatures,
   getMaxOrganizations,
   getMaxOrganizations,
+  installHasLicense,
+  installLicenseMaxOrganizations,
   organizationAllowance,
   organizationAllowance,
   PLAN_FEATURES,
   PLAN_FEATURES,
 } from '@/lib/features'
 } from '@/lib/features'
-import { verifyLicenseToken } from '@/lib/license/token'
+import { type LicenseTokenVerification, verifyLicenseToken } from '@/lib/license/token'
 import { scheduleLicenseSelfHeal } from '@/lib/license/revalidate'
 import { scheduleLicenseSelfHeal } from '@/lib/license/revalidate'
 
 
 const mockFindUnique = vi.mocked(db.subscription.findUnique)
 const mockFindUnique = vi.mocked(db.subscription.findUnique)
@@ -40,8 +42,31 @@ const mockLimitOverrides = vi.mocked(db.systemSetting.findMany)
 const mockVerify = vi.mocked(verifyLicenseToken)
 const mockVerify = vi.mocked(verifyLicenseToken)
 const mockSelfHeal = vi.mocked(scheduleLicenseSelfHeal)
 const mockSelfHeal = vi.mocked(scheduleLicenseSelfHeal)
 
 
-function verification(status: 'missing' | 'invalid' | 'expired' | 'stale' | 'valid') {
-  return { status, payload: null, ageDays: null, daysUntilExpiry: null }
+function verification(
+  status: LicenseTokenVerification['status'],
+  extra: Partial<LicenseTokenVerification> = {}
+): LicenseTokenVerification {
+  return {
+    status,
+    kind: status === 'missing' || status === 'invalid' ? null : 'subscription',
+    payload: null,
+    ageDays: null,
+    daysUntilExpiry: null,
+    expiresAt: null,
+    maxOrganizations: null,
+    licensee: null,
+    ...extra,
+  }
+}
+
+const LICENSEE = {
+  name: 'Example Workshop AS',
+  registrationNumber: 'NO 123 456 789 MVA',
+  country: 'NO',
+}
+
+function perpetual(status: 'valid' | 'mismatch', maxOrganizations = 3) {
+  return verification(status, { kind: 'perpetual', maxOrganizations, licensee: LICENSEE })
 }
 }
 
 
 beforeEach(() => {
 beforeEach(() => {
@@ -217,7 +242,9 @@ describe('getFeatures — self-hosted mode', () => {
     ] as any)
     ] as any)
     mockVerify.mockReturnValue(verification('valid'))
     mockVerify.mockReturnValue(verification('valid'))
     const features = await getFeatures('org-1')
     const features = await getFeatures('org-1')
-    expect(mockVerify).toHaveBeenCalledWith('tvl1.x.y', 'org-1')
+    expect(mockVerify).toHaveBeenCalledWith('tvl1.x.y', 'org-1', {
+      registrationNumber: undefined,
+    })
     expect(features.brandingRemoved).toBe(true)
     expect(features.brandingRemoved).toBe(true)
     expect(features.customPlatformName).toBe(true)
     expect(features.customPlatformName).toBe(true)
     expect(mockSelfHeal).not.toHaveBeenCalled()
     expect(mockSelfHeal).not.toHaveBeenCalled()
@@ -265,6 +292,48 @@ describe('getFeatures — self-hosted mode', () => {
     await getFeatures('org-1')
     await getFeatures('org-1')
     expect(mockSelfHeal).not.toHaveBeenCalled()
     expect(mockSelfHeal).not.toHaveBeenCalled()
   })
   })
+
+  it('removes branding on a perpetual token that matches the registration number', async () => {
+    mockFindMany.mockResolvedValue([
+      { key: 'license.token', value: 'tvp1.x.y' },
+      { key: 'invoice.orgNumber', value: 'NO 123456789 MVA' },
+    ] as any)
+    mockVerify.mockReturnValue(perpetual('valid', 3))
+    const features = await getFeatures('org-1')
+    // The workshop's own number goes to the verifier, which does the compare.
+    expect(mockVerify).toHaveBeenCalledWith('tvp1.x.y', 'org-1', {
+      registrationNumber: 'NO 123456789 MVA',
+    })
+    const where = mockFindMany.mock.calls[0][0]?.where as { key: { in: string[] } }
+    expect(where.key.in).toContain('invoice.orgNumber')
+    expect(features.brandingRemoved).toBe(true)
+    expect(features.customPlatformName).toBe(true)
+    // A perpetual licence says how many workshops it covers.
+    expect(features.maxOrganizations).toBe(3)
+  })
+
+  it('keeps branding and one workshop when the registration number does not match', async () => {
+    mockFindMany.mockResolvedValue([
+      // A leftover key must not send a perpetual install to torqvoice.com.
+      { key: 'license.key', value: 'KEY' },
+      { key: 'license.token', value: 'tvp1.x.y' },
+      { key: 'invoice.orgNumber', value: '999 999 999' },
+    ] as any)
+    mockVerify.mockReturnValue(perpetual('mismatch', 3))
+    const features = await getFeatures('org-1')
+    expect(features.brandingRemoved).toBe(false)
+    expect(features.customPlatformName).toBe(false)
+    expect(features.maxOrganizations).toBe(1)
+    expect(mockSelfHeal).not.toHaveBeenCalled()
+  })
+
+  it('gives a valid subscription token the white-label number of workshops', async () => {
+    mockFindMany.mockResolvedValue([{ key: 'license.token', value: 'tvl1.x.y' }] as any)
+    mockVerify.mockReturnValue(verification('valid'))
+    expect((await getFeatures('org-1')).maxOrganizations).toBe(
+      PLAN_FEATURES['white-label'].maxOrganizations
+    )
+  })
 })
 })
 
 
 describe('the workshop limit on a self-hosted install', () => {
 describe('the workshop limit on a self-hosted install', () => {
@@ -290,8 +359,8 @@ describe('the workshop limit on a self-hosted install', () => {
 
 
   it('is lifted by a valid licence token on any organization', async () => {
   it('is lifted by a valid licence token on any organization', async () => {
     mockFindMany.mockResolvedValue([
     mockFindMany.mockResolvedValue([
-      { organizationId: 'org-1', value: 'tvl1.stale' },
-      { organizationId: 'org-2', value: 'tvl1.good' },
+      { organizationId: 'org-1', key: 'license.token', value: 'tvl1.stale' },
+      { organizationId: 'org-2', key: 'license.token', value: 'tvl1.good' },
     ] as never)
     ] as never)
     mockVerify.mockImplementation((token) =>
     mockVerify.mockImplementation((token) =>
       verification(token === 'tvl1.good' ? 'valid' : 'stale')
       verification(token === 'tvl1.good' ? 'valid' : 'stale')
@@ -302,11 +371,65 @@ describe('the workshop limit on a self-hosted install', () => {
   })
   })
 
 
   it('does not count a token that fails to verify', async () => {
   it('does not count a token that fails to verify', async () => {
-    mockFindMany.mockResolvedValue([{ organizationId: 'org-1', value: 'tvl1.forged' }] as never)
+    mockFindMany.mockResolvedValue([
+      { organizationId: 'org-1', key: 'license.token', value: 'tvl1.forged' },
+    ] as never)
     mockVerify.mockReturnValue(verification('invalid'))
     mockVerify.mockReturnValue(verification('invalid'))
     expect(await getMaxOrganizations('user-1')).toBe(1)
     expect(await getMaxOrganizations('user-1')).toBe(1)
   })
   })
 
 
+  it('is what a perpetual licence allows, checked against its own workshop number', async () => {
+    mockFindMany.mockResolvedValue([
+      { organizationId: 'org-1', key: 'license.token', value: 'tvp1.one' },
+      { organizationId: 'org-1', key: 'invoice.orgNumber', value: 'NO 123 456 789 MVA' },
+      { organizationId: 'org-2', key: 'invoice.orgNumber', value: 'SE 556677-8899' },
+    ] as never)
+    mockVerify.mockReturnValue(perpetual('valid', 2))
+    expect(await getMaxOrganizations('user-1')).toBe(2)
+    expect(await installHasLicense()).toBe(true)
+    expect(mockVerify).toHaveBeenCalledWith('tvp1.one', 'org-1', {
+      registrationNumber: 'NO 123 456 789 MVA',
+    })
+    // Each check reads tokens and numbers in one query, not one per workshop.
+    expect(mockFindMany).toHaveBeenCalledTimes(2)
+  })
+
+  it('takes the highest number among the valid licences on the install', async () => {
+    mockFindMany.mockResolvedValue([
+      { organizationId: 'org-1', key: 'license.token', value: 'tvp1.two' },
+      { organizationId: 'org-2', key: 'license.token', value: 'tvp1.five' },
+      { organizationId: 'org-3', key: 'license.token', value: 'tvp1.mismatch' },
+    ] as never)
+    mockVerify.mockImplementation((token) => {
+      if (token === 'tvp1.two') return perpetual('valid', 2)
+      if (token === 'tvp1.five') return perpetual('valid', 5)
+      return perpetual('mismatch', 50)
+    })
+    expect(await installLicenseMaxOrganizations()).toBe(5)
+  })
+
+  it('lets a subscription licence beside a perpetual one bring the plan number', async () => {
+    mockFindMany.mockResolvedValue([
+      { organizationId: 'org-1', key: 'license.token', value: 'tvp1.two' },
+      { organizationId: 'org-2', key: 'license.token', value: 'tvl1.good' },
+    ] as never)
+    mockVerify.mockImplementation((token) =>
+      token === 'tvp1.two' ? perpetual('valid', 2) : verification('valid')
+    )
+    expect(await installLicenseMaxOrganizations()).toBe(
+      PLAN_FEATURES['white-label'].maxOrganizations
+    )
+  })
+
+  it('is one with no valid licence, and a mismatched perpetual one does not count', async () => {
+    mockFindMany.mockResolvedValue([
+      { organizationId: 'org-1', key: 'license.token', value: 'tvp1.x' },
+    ] as never)
+    mockVerify.mockReturnValue(perpetual('mismatch', 4))
+    expect(await installLicenseMaxOrganizations()).toBe(1)
+    expect(await installHasLicense()).toBe(false)
+  })
+
   it('plan features say one workshop without a licence and many with one', async () => {
   it('plan features say one workshop without a licence and many with one', async () => {
     mockFindMany.mockResolvedValue([])
     mockFindMany.mockResolvedValue([])
     expect((await getFeatures('org-1')).maxOrganizations).toBe(1)
     expect((await getFeatures('org-1')).maxOrganizations).toBe(1)

+ 51 - 11
src/__tests__/lib/license-expiry.test.ts

@@ -53,6 +53,23 @@ import {
   sendExpiryWarning,
   sendExpiryWarning,
 } from '@/lib/cron/check-licenses'
 } from '@/lib/cron/check-licenses'
 
 
+function verified(
+  status: LicenseTokenVerification['status'],
+  extra: Partial<LicenseTokenVerification> = {}
+): LicenseTokenVerification {
+  return {
+    status,
+    kind: status === 'missing' || status === 'invalid' ? null : 'subscription',
+    payload: null,
+    ageDays: null,
+    daysUntilExpiry: null,
+    expiresAt: null,
+    maxOrganizations: null,
+    licensee: null,
+    ...extra,
+  }
+}
+
 const ORG_ID = 'org-test-1'
 const ORG_ID = 'org-test-1'
 const USER_ID = 'user-test-1'
 const USER_ID = 'user-test-1'
 const LICENSE_KEY = 'test-license-key'
 const LICENSE_KEY = 'test-license-key'
@@ -214,13 +231,11 @@ describe('revalidateOrganizationLicense', () => {
         expiresAt: '',
         expiresAt: '',
         token: 'tvl1.x.y',
         token: 'tvl1.x.y',
       },
       },
-      verification: {
-        status: 'valid',
-        payload: null,
+      verification: verified(verification.status ?? 'valid', {
         ageDays: 0,
         ageDays: 0,
         daysUntilExpiry: 100,
         daysUntilExpiry: 100,
         ...verification,
         ...verification,
-      },
+      }),
     })
     })
   }
   }
 
 
@@ -275,6 +290,21 @@ describe('revalidateOrganizationLicense', () => {
     )
     )
   })
   })
 
 
+  it('never warns about a perpetual token, whose age means nothing', async () => {
+    vi.mocked(revalidateLicense).mockResolvedValue({
+      remote: null,
+      verification: {
+        ...verified('valid', { ageDays: 400, daysUntilExpiry: null }),
+        kind: 'perpetual',
+      },
+    })
+
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
+
+    expect(notify).not.toHaveBeenCalled()
+    expect(sendOrgMail).not.toHaveBeenCalled()
+  })
+
   it('warns when the token has not been refreshed for a week', async () => {
   it('warns when the token has not been refreshed for a week', async () => {
     mockRemote({ ageDays: LICENSE_TOKEN_WARN_AGE_DAYS })
     mockRemote({ ageDays: LICENSE_TOKEN_WARN_AGE_DAYS })
 
 
@@ -332,15 +362,12 @@ describe('refreshLicensesMissingTokens', () => {
         { organizationId: null, value: 'KEY-C' },
         { organizationId: null, value: 'KEY-C' },
       ] as any)
       ] as any)
       .mockResolvedValueOnce([{ organizationId: 'org-fresh', value: 'tvl1.good' }] as any)
       .mockResolvedValueOnce([{ organizationId: 'org-fresh', value: 'tvl1.good' }] as any)
-    vi.mocked(verifyLicenseToken).mockImplementation((token) => ({
-      status: token === 'tvl1.good' ? 'valid' : 'missing',
-      payload: null,
-      ageDays: null,
-      daysUntilExpiry: null,
-    }))
+    vi.mocked(verifyLicenseToken).mockImplementation((token) =>
+      verified(token === 'tvl1.good' ? 'valid' : 'missing')
+    )
     vi.mocked(revalidateLicense).mockResolvedValue({
     vi.mocked(revalidateLicense).mockResolvedValue({
       remote: { reachable: true, valid: true, plan: 'white-label', expiresAt: '', token: 't' },
       remote: { reachable: true, valid: true, plan: 'white-label', expiresAt: '', token: 't' },
-      verification: { status: 'valid', payload: null, ageDays: 0, daysUntilExpiry: 100 },
+      verification: verified('valid', { ageDays: 0, daysUntilExpiry: 100 }),
     })
     })
 
 
     await refreshLicensesMissingTokens()
     await refreshLicensesMissingTokens()
@@ -349,6 +376,19 @@ describe('refreshLicensesMissingTokens', () => {
     expect(revalidateLicense).toHaveBeenCalledWith('org-legacy', 'KEY-B')
     expect(revalidateLicense).toHaveBeenCalledWith('org-legacy', 'KEY-B')
   })
   })
 
 
+  it('leaves a perpetual token alone even beside a leftover key', async () => {
+    vi.mocked(db.appSetting.findMany)
+      .mockResolvedValueOnce([{ organizationId: 'org-perpetual', value: 'KEY-P' }] as any)
+      .mockResolvedValueOnce([{ organizationId: 'org-perpetual', value: 'tvp1.x.y' }] as any)
+    // No registration number reaches this check, so a perpetual token reads
+    // as a mismatch here. It must still not be refreshed over.
+    vi.mocked(verifyLicenseToken).mockReturnValue(verified('mismatch', { kind: 'perpetual' }))
+
+    await refreshLicensesMissingTokens()
+
+    expect(revalidateLicense).not.toHaveBeenCalled()
+  })
+
   it('never throws', async () => {
   it('never throws', async () => {
     vi.mocked(db.appSetting.findMany).mockRejectedValue(new Error('db down'))
     vi.mocked(db.appSetting.findMany).mockRejectedValue(new Error('db down'))
     await expect(refreshLicensesMissingTokens()).resolves.toBeUndefined()
     await expect(refreshLicensesMissingTokens()).resolves.toBeUndefined()

+ 153 - 0
src/__tests__/lib/license-perpetual-store.test.ts

@@ -0,0 +1,153 @@
+/**
+ * @vitest-environment node
+ *
+ * Storing a pasted perpetual token. It is verified offline against the
+ * workshop's registration number, kept even on a mismatch so the License page
+ * can say which number to fix, and refused only when it does not verify.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    appSetting: {
+      findUnique: vi.fn(),
+      upsert: vi.fn((args: unknown) => ({ op: 'upsert', args })),
+      deleteMany: vi.fn((args: unknown) => ({ op: 'deleteMany', args })),
+    },
+    organizationMember: { findFirst: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+vi.mock('@/lib/license/token', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/license/token')>()
+  return { ...actual, verifyLicenseToken: vi.fn() }
+})
+
+import { db } from '@/lib/db'
+import { storePerpetualLicense } from '@/lib/license/revalidate'
+import {
+  type LicenseTokenVerification,
+  type PerpetualLicensePayload,
+  verifyLicenseToken,
+} from '@/lib/license/token'
+
+const ORG = 'org-1'
+const TOKEN = 'tvp1.payload.sig'
+const LICENSEE = {
+  name: 'Example Workshop AS',
+  registrationNumber: 'NO 123 456 789 MVA',
+  country: 'NO',
+}
+const PAYLOAD: PerpetualLicensePayload = {
+  v: 1,
+  lid: 'lic-p1',
+  org: ORG,
+  plan: 'white-label',
+  maxOrganizations: 1,
+  licensee: LICENSEE,
+  issuedAt: '2026-10-01T00:00:00.000Z',
+}
+
+function perpetual(status: LicenseTokenVerification['status']): LicenseTokenVerification {
+  const read = status === 'valid' || status === 'mismatch'
+  return {
+    status,
+    kind: read ? 'perpetual' : null,
+    payload: read ? PAYLOAD : null,
+    ageDays: read ? 30 : null,
+    daysUntilExpiry: null,
+    expiresAt: null,
+    maxOrganizations: read ? 1 : null,
+    licensee: read ? LICENSEE : null,
+  }
+}
+
+type Write = { op: string; args: { where: Record<string, unknown>; update?: { value: string } } }
+
+function writes(): Write[] {
+  const call = vi.mocked(db.$transaction).mock.calls[0]
+  return (call?.[0] ?? []) as unknown as Write[]
+}
+
+function stored(key: string): string | undefined {
+  const write = writes().find(
+    (w) =>
+      w.op === 'upsert' &&
+      (w.args.where.organizationId_key as { key: string } | undefined)?.key === key
+  )
+  return write?.args.update?.value
+}
+
+beforeEach(() => {
+  vi.mocked(db.appSetting.upsert).mockClear()
+  vi.mocked(db.appSetting.deleteMany).mockClear()
+  vi.mocked(db.appSetting.findUnique).mockReset()
+  vi.mocked(db.$transaction).mockReset().mockResolvedValue([])
+  vi.mocked(db.organizationMember.findFirst)
+    .mockReset()
+    .mockResolvedValue({ userId: 'u-1' } as never)
+  vi.mocked(verifyLicenseToken).mockReset()
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: 'NO123456789MVA' } as never)
+})
+
+describe('storePerpetualLicense', () => {
+  it('verifies against the workshop registration number', async () => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(perpetual('valid'))
+    await storePerpetualLicense(ORG, TOKEN, 'u-1')
+    expect(db.appSetting.findUnique).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { organizationId_key: { organizationId: ORG, key: 'invoice.orgNumber' } },
+      })
+    )
+    expect(verifyLicenseToken).toHaveBeenCalledWith(TOKEN, ORG, {
+      registrationNumber: 'NO123456789MVA',
+    })
+  })
+
+  it('stores a valid token and drops the subscription key and term', async () => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(perpetual('valid'))
+    const verification = await storePerpetualLicense(ORG, TOKEN, 'u-1')
+
+    expect(verification.status).toBe('valid')
+    expect(db.$transaction).toHaveBeenCalledTimes(1)
+    expect(stored('license.token')).toBe(TOKEN)
+    expect(stored('license.valid')).toBe('true')
+    expect(stored('license.plan')).toBe('white-label')
+    expect(Number.isNaN(Date.parse(stored('license.checkedAt') ?? ''))).toBe(false)
+    expect(db.appSetting.deleteMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, key: { in: ['license.key', 'license.expiresAt'] } },
+    })
+    expect(writes().some((w) => w.op === 'deleteMany')).toBe(true)
+  })
+
+  it('stores a mismatched token too, marked not valid', async () => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(perpetual('mismatch'))
+    const verification = await storePerpetualLicense(ORG, TOKEN, 'u-1')
+
+    expect(verification.status).toBe('mismatch')
+    expect(verification.licensee).toEqual(LICENSEE)
+    expect(stored('license.token')).toBe(TOKEN)
+    expect(stored('license.valid')).toBe('false')
+    expect(db.appSetting.deleteMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, key: { in: ['license.key', 'license.expiresAt'] } },
+    })
+  })
+
+  it('stores nothing when the token does not verify', async () => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(perpetual('invalid'))
+    const verification = await storePerpetualLicense(ORG, TOKEN, 'u-1')
+
+    expect(verification.status).toBe('invalid')
+    expect(db.$transaction).not.toHaveBeenCalled()
+    expect(db.appSetting.upsert).not.toHaveBeenCalled()
+    expect(db.appSetting.deleteMany).not.toHaveBeenCalled()
+  })
+
+  it('verifies with no registration number when the workshop has none', async () => {
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
+    vi.mocked(verifyLicenseToken).mockReturnValue(perpetual('mismatch'))
+    await storePerpetualLicense(ORG, TOKEN, 'u-1')
+    expect(verifyLicenseToken).toHaveBeenCalledWith(TOKEN, ORG, { registrationNumber: undefined })
+  })
+})

+ 183 - 0
src/__tests__/lib/license-remote-store.test.ts

@@ -0,0 +1,183 @@
+/**
+ * @vitest-environment node
+ *
+ * A perpetual (offline) token on disk is never overwritten by a refresh, a
+ * rejected key or an unreachable torqvoice.com. The check happens inside the
+ * same transaction as the writes, so a response that was already in flight
+ * when the token was pasted cannot land on top of it. Only a key
+ * torqvoice.com accepts, typed by the owner, replaces it.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const tx = {
+  appSetting: {
+    findUnique: vi.fn(),
+    upsert: vi.fn(async (args: unknown) => args),
+    deleteMany: vi.fn(async (args: unknown) => args),
+  },
+}
+vi.mock('@/lib/db', () => ({
+  db: {
+    appSetting: { findMany: vi.fn(), findUnique: vi.fn() },
+    organizationMember: { findFirst: vi.fn() },
+    $transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)),
+  },
+}))
+vi.mock('@/lib/license/token', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/license/token')>()
+  return { ...actual, verifyLicenseToken: vi.fn() }
+})
+vi.mock('@/lib/torqvoice-com', () => ({ torqvoiceComUrl: () => 'https://torqvoice.test' }))
+
+import { db } from '@/lib/db'
+import {
+  type RemoteLicenseResult,
+  revalidateLicense,
+  storeRemoteLicense,
+} from '@/lib/license/revalidate'
+import { type LicenseTokenVerification, verifyLicenseToken } from '@/lib/license/token'
+
+const ORG = 'org-1'
+const USER = 'u-1'
+
+function verification(
+  kind: LicenseTokenVerification['kind'],
+  status: LicenseTokenVerification['status']
+): LicenseTokenVerification {
+  return {
+    status,
+    kind,
+    payload: null,
+    ageDays: null,
+    daysUntilExpiry: null,
+    expiresAt: null,
+    maxOrganizations: null,
+    licensee: null,
+  }
+}
+
+const REJECTED: RemoteLicenseResult = {
+  reachable: true,
+  valid: false,
+  plan: 'free',
+  expiresAt: '',
+  token: null,
+  error: 'License not found',
+}
+const ACCEPTED: RemoteLicenseResult = {
+  reachable: true,
+  valid: true,
+  plan: 'white-label',
+  expiresAt: '2027-01-01T00:00:00.000Z',
+  token: 'tvl1.fresh.sig',
+}
+const UNREACHABLE: RemoteLicenseResult = {
+  reachable: false,
+  valid: false,
+  plan: 'free',
+  expiresAt: '',
+  token: null,
+}
+
+function upsertedKeys(): string[] {
+  return tx.appSetting.upsert.mock.calls.map(
+    (c) => (c[0] as { where: { organizationId_key: { key: string } } }).where.organizationId_key.key
+  )
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  tx.appSetting.findUnique.mockResolvedValue({ value: 'tvp1.on.disk' })
+})
+
+describe('storeRemoteLicense beside a perpetual token', () => {
+  beforeEach(() => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(verification('perpetual', 'valid'))
+  })
+
+  it('writes nothing when torqvoice.com rejects a key, even from the Validate button', async () => {
+    const wrote = await storeRemoteLicense(ORG, 'TQV-WRONG', REJECTED, USER, {
+      replacePerpetual: true,
+    })
+    expect(wrote).toBe(false)
+    expect(tx.appSetting.upsert).not.toHaveBeenCalled()
+    expect(tx.appSetting.deleteMany).not.toHaveBeenCalled()
+  })
+
+  it('writes nothing when torqvoice.com is unreachable', async () => {
+    expect(await storeRemoteLicense(ORG, 'TQV-ANY', UNREACHABLE, USER)).toBe(false)
+    expect(tx.appSetting.upsert).not.toHaveBeenCalled()
+  })
+
+  it('ignores an accepted key from a refresh, which never replaces an offline licence', async () => {
+    expect(await storeRemoteLicense(ORG, 'TQV-OLD', ACCEPTED, USER)).toBe(false)
+    expect(tx.appSetting.upsert).not.toHaveBeenCalled()
+  })
+
+  it('lets the owner replace it with a key torqvoice.com accepts', async () => {
+    const wrote = await storeRemoteLicense(ORG, 'TQV-NEW', ACCEPTED, USER, {
+      replacePerpetual: true,
+    })
+    expect(wrote).toBe(true)
+    expect(upsertedKeys()).toEqual(
+      expect.arrayContaining(['license.key', 'license.token', 'license.valid', 'license.expiresAt'])
+    )
+  })
+
+  it('reads the token inside the transaction, so an in-flight response sees the pasted token', async () => {
+    await storeRemoteLicense(ORG, 'TQV-OLD', ACCEPTED, USER)
+    expect(tx.appSetting.findUnique).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { organizationId_key: { organizationId: ORG, key: 'license.token' } },
+      })
+    )
+    expect(db.appSetting.findUnique).not.toHaveBeenCalled()
+  })
+})
+
+describe('storeRemoteLicense with a subscription or no token on disk', () => {
+  it('stores an accepted key and token as before', async () => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(verification('subscription', 'stale'))
+    expect(await storeRemoteLicense(ORG, 'TQV-OK', ACCEPTED, USER)).toBe(true)
+    expect(upsertedKeys()).toEqual(
+      expect.arrayContaining(['license.key', 'license.checkedAt', 'license.token'])
+    )
+  })
+
+  it('drops a subscription token torqvoice.com no longer vouches for', async () => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(verification('subscription', 'valid'))
+    expect(await storeRemoteLicense(ORG, 'TQV-OLD', REJECTED, USER)).toBe(true)
+    expect(tx.appSetting.deleteMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, key: 'license.token' },
+    })
+  })
+
+  it('only records the check when torqvoice.com is unreachable', async () => {
+    vi.mocked(verifyLicenseToken).mockReturnValue(verification(null, 'missing'))
+    tx.appSetting.findUnique.mockResolvedValue(null)
+    expect(await storeRemoteLicense(ORG, 'TQV-ANY', UNREACHABLE, USER)).toBe(true)
+    expect(upsertedKeys()).toEqual(['license.key', 'license.checkedAt'])
+  })
+})
+
+describe('revalidateLicense', () => {
+  it('does not even call torqvoice.com for a refresh when a perpetual token is on disk', async () => {
+    const fetchSpy = vi.spyOn(globalThis, 'fetch')
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: 'license.token', value: 'tvp1.on.disk' },
+      { key: 'invoice.orgNumber', value: 'NO 123 456 789 MVA' },
+    ] as never)
+    vi.mocked(verifyLicenseToken).mockReturnValue(verification('perpetual', 'valid'))
+
+    const result = await revalidateLicense(ORG, 'TQV-LEFTOVER')
+
+    expect(fetchSpy).not.toHaveBeenCalled()
+    expect(result.remote).toBeNull()
+    expect(result.verification.kind).toBe('perpetual')
+    // Verified against the workshop's own number, as the feature gate does.
+    expect(verifyLicenseToken).toHaveBeenCalledWith('tvp1.on.disk', ORG, {
+      registrationNumber: 'NO 123 456 789 MVA',
+    })
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+})

+ 142 - 1
src/__tests__/lib/license-token.test.ts

@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
 import { generateKeyPairSync, sign } from 'node:crypto'
 import { generateKeyPairSync, sign } from 'node:crypto'
 import {
 import {
   LICENSE_TOKEN_MAX_AGE_DAYS,
   LICENSE_TOKEN_MAX_AGE_DAYS,
+  isPerpetualLicenseToken,
+  normalizeRegistrationNumber,
   verifyLicenseToken,
   verifyLicenseToken,
   verifyLicenseTokenWith,
   verifyLicenseTokenWith,
 } from '@/lib/license/token'
 } from '@/lib/license/token'
@@ -32,13 +34,45 @@ function mint(
 }
 }
 
 
 const verify = (token: string | null | undefined, now?: Date) =>
 const verify = (token: string | null | undefined, now?: Date) =>
-  verifyLicenseTokenWith([publicKey], token, ORG, now)
+  verifyLicenseTokenWith([publicKey], token, ORG, { now })
+
+const REG = 'NO 123 456 789 MVA'
+
+function mintPerpetual(
+  overrides: Partial<Record<string, unknown>> = {},
+  key = privateKey,
+  prefix = 'tvp1'
+): string {
+  const payload = {
+    v: 1,
+    lid: 'lic-p1',
+    org: ORG,
+    plan: 'white-label',
+    maxOrganizations: 1,
+    licensee: { name: 'Example Workshop AS', registrationNumber: REG, country: 'NO' },
+    issuedAt: new Date().toISOString(),
+    ...overrides,
+  }
+  const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
+  const sig = sign(null, Buffer.from(encoded), key).toString('base64url')
+  return `${prefix}.${encoded}.${sig}`
+}
+
+const verifyPerpetual = (
+  token: string | null | undefined,
+  registrationNumber: string | null | undefined = REG,
+  now?: Date
+) => verifyLicenseTokenWith([publicKey], token, ORG, { registrationNumber, now })
 
 
 describe('verifyLicenseToken', () => {
 describe('verifyLicenseToken', () => {
   it('accepts a fresh token signed for this org', () => {
   it('accepts a fresh token signed for this org', () => {
     const v = verify(mint())
     const v = verify(mint())
     expect(v.status).toBe('valid')
     expect(v.status).toBe('valid')
+    expect(v.kind).toBe('subscription')
     expect(v.payload?.plan).toBe('white-label')
     expect(v.payload?.plan).toBe('white-label')
+    expect(v.expiresAt).toBe(v.payload && 'expiresAt' in v.payload ? v.payload.expiresAt : null)
+    expect(v.maxOrganizations).toBeNull()
+    expect(v.licensee).toBeNull()
     expect(v.ageDays).toBe(0)
     expect(v.ageDays).toBe(0)
     expect(v.daysUntilExpiry).toBe(200)
     expect(v.daysUntilExpiry).toBe(200)
   })
   })
@@ -125,7 +159,114 @@ describe('verifyLicenseToken', () => {
     expect(verify(token, later).status).toBe('stale')
     expect(verify(token, later).status).toBe('stale')
   })
   })
 
 
+  it('a subscription token ignores the registration number', () => {
+    expect(
+      verifyLicenseTokenWith([publicKey], mint(), ORG, { registrationNumber: 'x' }).status
+    ).toBe('valid')
+  })
+
   it('the production verifier does not accept a token from a key it does not embed', () => {
   it('the production verifier does not accept a token from a key it does not embed', () => {
     expect(verifyLicenseToken(mint(), ORG).status).toBe('invalid')
     expect(verifyLicenseToken(mint(), ORG).status).toBe('invalid')
+    expect(verifyLicenseToken(mintPerpetual(), ORG, { registrationNumber: REG }).status).toBe(
+      'invalid'
+    )
+  })
+})
+
+describe('perpetual (tvp1) tokens', () => {
+  it('is recognised by its prefix', () => {
+    expect(isPerpetualLicenseToken(mintPerpetual())).toBe(true)
+    expect(isPerpetualLicenseToken(`  ${mintPerpetual()}`)).toBe(true)
+    expect(isPerpetualLicenseToken(mint())).toBe(false)
+    expect(isPerpetualLicenseToken('TQV-ABCD-1234')).toBe(false)
+    expect(isPerpetualLicenseToken(null)).toBe(false)
+  })
+
+  it('verifies with no clock at all when the registration number matches', () => {
+    const v = verifyPerpetual(mintPerpetual())
+    expect(v.status).toBe('valid')
+    expect(v.kind).toBe('perpetual')
+    expect(v.expiresAt).toBeNull()
+    expect(v.daysUntilExpiry).toBeNull()
+    expect(v.maxOrganizations).toBe(1)
+    expect(v.licensee?.name).toBe('Example Workshop AS')
+    expect(v.licensee?.registrationNumber).toBe(REG)
+  })
+
+  it('never goes stale or expires', () => {
+    const old = mintPerpetual({ issuedAt: new Date(Date.now() - 4000 * DAY_MS).toISOString() })
+    const farFuture = new Date(Date.now() + 20000 * DAY_MS)
+    const v = verifyPerpetual(old, REG, farFuture)
+    expect(v.status).toBe('valid')
+    expect(v.ageDays).toBeGreaterThan(LICENSE_TOKEN_MAX_AGE_DAYS)
+  })
+
+  it('matches registration numbers on letters and digits only', () => {
+    expect(verifyPerpetual(mintPerpetual(), 'no123456789mva').status).toBe('valid')
+    expect(verifyPerpetual(mintPerpetual(), 'NO-123.456.789 MVA').status).toBe('valid')
+    expect(normalizeRegistrationNumber(' sc 123/456 ')).toBe('SC123456')
+    expect(normalizeRegistrationNumber(null)).toBe('')
+  })
+
+  it('reports mismatch, keeping the licensee, when the workshop number differs or is empty', () => {
+    const cases = [
+      verifyPerpetual(mintPerpetual(), 'NO 123 456 780 MVA'),
+      verifyPerpetual(mintPerpetual(), ''),
+      verifyPerpetual(mintPerpetual(), null),
+      verifyLicenseTokenWith([publicKey], mintPerpetual(), ORG, {}),
+    ]
+    for (const v of cases) {
+      expect(v.status).toBe('mismatch')
+      expect(v.kind).toBe('perpetual')
+      expect(v.licensee?.registrationNumber).toBe(REG)
+      expect(v.maxOrganizations).toBe(1)
+    }
+  })
+
+  it('is bound to the organization it was minted for', () => {
+    expect(verifyPerpetual(mintPerpetual({ org: 'someone-else' })).status).toBe('invalid')
+  })
+
+  it('rejects a perpetual payload signed with the subscription prefix and vice versa', () => {
+    expect(verifyPerpetual(mintPerpetual({}, privateKey, 'tvl1')).status).toBe('invalid')
+    const subscriptionShapeUnderPerpetualPrefix = mint({}, privateKey, 'tvp1')
+    expect(verifyPerpetual(subscriptionShapeUnderPerpetualPrefix).status).toBe('invalid')
+  })
+
+  it('rejects other keys, edits and malformed payloads', () => {
+    expect(verifyPerpetual(mintPerpetual({}, other.privateKey)).status).toBe('invalid')
+    expect(verifyPerpetual(mintPerpetual({ maxOrganizations: 0 })).status).toBe('invalid')
+    expect(verifyPerpetual(mintPerpetual({ maxOrganizations: '5' })).status).toBe('invalid')
+    expect(verifyPerpetual(mintPerpetual({ maxOrganizations: 1.5 })).status).toBe('invalid')
+    expect(verifyPerpetual(mintPerpetual({ licensee: undefined })).status).toBe('invalid')
+    expect(
+      verifyPerpetual(
+        mintPerpetual({ licensee: { name: 'X', registrationNumber: '', country: 'NO' } })
+      ).status
+    ).toBe('invalid')
+    expect(
+      verifyPerpetual(
+        mintPerpetual({ licensee: { name: 'X', registrationNumber: ' - ', country: 'NO' } }),
+        ' - '
+      ).status
+    ).toBe('invalid')
+    const token = mintPerpetual()
+    const [prefix, , sig] = token.split('.')
+    const forged = Buffer.from(
+      JSON.stringify({
+        v: 1,
+        lid: 'lic-p1',
+        org: ORG,
+        plan: 'white-label',
+        maxOrganizations: 999,
+        licensee: { name: 'Example Workshop AS', registrationNumber: REG, country: 'NO' },
+        issuedAt: new Date().toISOString(),
+      })
+    ).toString('base64url')
+    expect(verifyPerpetual(`${prefix}.${forged}.${sig}`).status).toBe('invalid')
+  })
+
+  it('carries the workshop allowance the token names', () => {
+    expect(verifyPerpetual(mintPerpetual({ maxOrganizations: 3 })).maxOrganizations).toBe(3)
   })
   })
 })
 })

+ 19 - 10
src/app/(authenticated)/layout.tsx

@@ -263,22 +263,31 @@ export default async function DashboardLayout({ children }: { children: React.Re
     const licenceSettings = await db.appSetting.findMany({
     const licenceSettings = await db.appSetting.findMany({
       where: {
       where: {
         organizationId: data.organizationId,
         organizationId: data.organizationId,
-        key: { in: ['license.token', 'license.key', 'license.expiryDismissed'] },
+        key: {
+          in: ['license.token', 'license.key', 'license.expiryDismissed', 'invoice.orgNumber'],
+        },
       },
       },
       select: { key: true, value: true },
       select: { key: true, value: true },
     })
     })
     const licenceMap = new Map(licenceSettings.map((s) => [s.key, s.value]))
     const licenceMap = new Map(licenceSettings.map((s) => [s.key, s.value]))
     licenseExpiryDismissed = licenceMap.get('license.expiryDismissed') === 'true'
     licenseExpiryDismissed = licenceMap.get('license.expiryDismissed') === 'true'
-    const verification = verifyLicenseToken(licenceMap.get('license.token'), data.organizationId)
-    if (verification.status === 'valid') {
-      daysUntilExpiry = verification.daysUntilExpiry
-      if (verification.ageDays !== null && verification.ageDays >= LICENSE_TOKEN_WARN_AGE_DAYS) {
-        unverifiedDaysLeft = Math.max(0, LICENSE_TOKEN_MAX_AGE_DAYS - verification.ageDays)
+    const verification = verifyLicenseToken(licenceMap.get('license.token'), data.organizationId, {
+      registrationNumber: licenceMap.get('invoice.orgNumber'),
+    })
+    // A perpetual licence has no term and no refresh, so neither notice
+    // applies to it. A registration number mismatch is explained on the
+    // License page, where the number can be compared and fixed.
+    if (verification.kind !== 'perpetual') {
+      if (verification.status === 'valid') {
+        daysUntilExpiry = verification.daysUntilExpiry
+        if (verification.ageDays !== null && verification.ageDays >= LICENSE_TOKEN_WARN_AGE_DAYS) {
+          unverifiedDaysLeft = Math.max(0, LICENSE_TOKEN_MAX_AGE_DAYS - verification.ageDays)
+        }
+      } else if (verification.status === 'stale') {
+        unverifiedDaysLeft = 0
+      } else if (verification.status === 'expired') {
+        daysUntilExpiry = verification.daysUntilExpiry
       }
       }
-    } else if (verification.status === 'stale') {
-      unverifiedDaysLeft = 0
-    } else if (verification.status === 'expired') {
-      daysUntilExpiry = verification.daysUntilExpiry
     }
     }
   }
   }
 
 

+ 31 - 11
src/app/(authenticated)/settings/license/page.tsx

@@ -15,30 +15,50 @@ export default async function LicensePage() {
   const authContext = await getAuthContext()
   const authContext = await getAuthContext()
   if (!authContext) redirect('/auth/sign-in')
   if (!authContext) redirect('/auth/sign-in')
 
 
-  const settings = await db.appSetting.findMany({
-    where: {
-      organizationId: authContext.organizationId,
-      key: {
-        in: [SETTING_KEYS.LICENSE_KEY, SETTING_KEYS.LICENSE_TOKEN, SETTING_KEYS.LICENSE_CHECKED_AT],
+  const [settings, organization] = await Promise.all([
+    db.appSetting.findMany({
+      where: {
+        organizationId: authContext.organizationId,
+        key: {
+          in: [
+            SETTING_KEYS.LICENSE_KEY,
+            SETTING_KEYS.LICENSE_TOKEN,
+            SETTING_KEYS.LICENSE_CHECKED_AT,
+            SETTING_KEYS.INVOICE_ORG_NUMBER,
+          ],
+        },
       },
       },
-    },
-    select: { key: true, value: true },
-  })
+      select: { key: true, value: true },
+    }),
+    db.organization.findUnique({
+      where: { id: authContext.organizationId },
+      select: { name: true },
+    }),
+  ])
 
 
   const map = new Map(settings.map((s) => [s.key, s.value]))
   const map = new Map(settings.map((s) => [s.key, s.value]))
-  // What the feature gate sees, not what the last validate call said.
+  const registrationNumber = map.get(SETTING_KEYS.INVOICE_ORG_NUMBER) || ''
+  // What the feature gate sees, not what the last validate call said. An
+  // offline licence only holds while the organization number still matches.
   const verification = verifyLicenseToken(
   const verification = verifyLicenseToken(
     map.get(SETTING_KEYS.LICENSE_TOKEN),
     map.get(SETTING_KEYS.LICENSE_TOKEN),
-    authContext.organizationId
+    authContext.organizationId,
+    { registrationNumber }
   )
   )
 
 
   return (
   return (
     <LicenseSettings
     <LicenseSettings
       initialKey={map.get(SETTING_KEYS.LICENSE_KEY) || ''}
       initialKey={map.get(SETTING_KEYS.LICENSE_KEY) || ''}
       initialStatus={verification.status}
       initialStatus={verification.status}
-      initialExpiresAt={verification.payload?.expiresAt ?? ''}
+      initialKind={verification.kind}
+      initialLicensee={verification.licensee}
+      initialMaxOrganizations={verification.maxOrganizations}
+      initialExpiresAt={verification.expiresAt ?? ''}
       initialIssuedAt={verification.payload?.issuedAt ?? ''}
       initialIssuedAt={verification.payload?.issuedAt ?? ''}
       initialCheckedAt={map.get(SETTING_KEYS.LICENSE_CHECKED_AT) || ''}
       initialCheckedAt={map.get(SETTING_KEYS.LICENSE_CHECKED_AT) || ''}
+      organizationId={authContext.organizationId}
+      workshopName={organization?.name ?? ''}
+      registrationNumber={registrationNumber}
       // The app name and icon live on the super-admin page; anyone else is
       // The app name and icon live on the super-admin page; anyone else is
       // told who to ask instead of being sent to a redirect.
       // told who to ask instead of being sent to a redirect.
       brandingHref={authContext.role === 'super_admin' ? '/admin/settings#app-branding' : null}
       brandingHref={authContext.role === 'super_admin' ? '/admin/settings#app-branding' : null}

+ 85 - 16
src/features/settings/Actions/validateLicense.ts

@@ -5,20 +5,58 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { demoGuard } from '@/lib/demo'
 import { demoGuard } from '@/lib/demo'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
-import { revalidateLicense } from '@/lib/license/revalidate'
-import type { LicenseTokenStatus } from '@/lib/license/token'
+import {
+  revalidateLicense,
+  storedLicenseVerification,
+  storePerpetualLicense,
+} from '@/lib/license/revalidate'
+import {
+  isPerpetualLicenseToken,
+  type LicenseTokenKind,
+  type LicenseTokenStatus,
+  type LicenseTokenVerification,
+  type PerpetualLicensee,
+} from '@/lib/license/token'
 
 
 export type ValidateLicenseResult = {
 export type ValidateLicenseResult = {
-  /** what the feature gate now sees */
+  /**
+   * The licence on disk after the attempt, which is what the feature gate
+   * sees. A rejected key or token changes nothing, so these then describe
+   * the licence that was already there.
+   */
   status: LicenseTokenStatus
   status: LicenseTokenStatus
   valid: boolean
   valid: boolean
   plan: string
   plan: string
-  /** false when torqvoice.com could not be reached; the stored token is untouched */
+  kind: LicenseTokenKind | null
+  /** who a perpetual licence was issued to, so the page can show a mismatch */
+  licensee: PerpetualLicensee | null
+  /** what was pasted: a `tvp1.` token is a perpetual licence; anything else is a key */
+  attempted: LicenseTokenKind
+  /** whether the pasted value is now the stored licence */
+  accepted: boolean
+  /** false when torqvoice.com could not be reached; the stored licence is untouched */
   reachable: boolean
   reachable: boolean
   /** torqvoice.com's reason when it answered "not valid" */
   /** torqvoice.com's reason when it answered "not valid" */
   reason?: string
   reason?: string
 }
 }
 
 
+function fromStored(verification: LicenseTokenVerification, fallbackPlan = 'free') {
+  return {
+    status: verification.status,
+    valid: verification.status === 'valid',
+    plan: verification.payload?.plan ?? fallbackPlan,
+    kind: verification.kind,
+    licensee: verification.licensee,
+  }
+}
+
+// Let the expiry banner warn again on the next cycle.
+function clearExpiryDismissal(organizationId: string) {
+  return db.appSetting.deleteMany({
+    where: { organizationId, key: 'license.expiryDismissed' },
+  })
+}
+
 export async function validateLicense(licenseKey: string) {
 export async function validateLicense(licenseKey: string) {
   return withAuth(
   return withAuth(
     async (ctx): Promise<ValidateLicenseResult> => {
     async (ctx): Promise<ValidateLicenseResult> => {
@@ -30,21 +68,52 @@ export async function validateLicense(licenseKey: string) {
         throw new Error('License key is required')
         throw new Error('License key is required')
       }
       }
 
 
-      const { remote, verification } = await revalidateLicense(ctx.organizationId, key, ctx.userId)
-
-      // Let the expiry banner warn again on the next cycle.
-      await db.appSetting.deleteMany({
-        where: { organizationId: ctx.organizationId, key: 'license.expiryDismissed' },
-      })
+      // A perpetual token is verified here and now, offline. Sending it to
+      // torqvoice.com as a key would only come back "not valid".
+      if (isPerpetualLicenseToken(key)) {
+        const verification = await storePerpetualLicense(ctx.organizationId, key, ctx.userId)
+        const accepted = verification.status === 'valid' || verification.status === 'mismatch'
+        if (!accepted) {
+          // Nothing was stored, so the page keeps showing what is there.
+          return {
+            ...fromStored(await storedLicenseVerification(ctx.organizationId)),
+            attempted: 'perpetual',
+            accepted: false,
+            reachable: true,
+          }
+        }
+        await clearExpiryDismissal(ctx.organizationId)
+        revalidatePath('/settings')
+        return {
+          ...fromStored(verification),
+          attempted: 'perpetual',
+          accepted: true,
+          reachable: true,
+        }
+      }
 
 
-      revalidatePath('/settings')
+      // The owner typed a key on purpose, so one torqvoice.com accepts may
+      // replace a perpetual token; a rejected or unanswered one changes nothing.
+      const { remote, verification } = await revalidateLicense(
+        ctx.organizationId,
+        key,
+        ctx.userId,
+        {
+          replacePerpetual: true,
+        }
+      )
+      const accepted = Boolean(remote?.reachable && remote.valid)
+      if (accepted) {
+        await clearExpiryDismissal(ctx.organizationId)
+        revalidatePath('/settings')
+      }
 
 
       return {
       return {
-        status: verification.status,
-        valid: verification.status === 'valid',
-        plan: verification.payload?.plan ?? remote.plan,
-        reachable: remote.reachable,
-        reason: remote.error,
+        ...fromStored(verification, remote?.plan),
+        attempted: 'subscription',
+        accepted,
+        reachable: remote?.reachable ?? true,
+        reason: remote?.error,
       }
       }
     },
     },
     {
     {

+ 227 - 13
src/features/settings/Components/license-settings.tsx

@@ -3,7 +3,7 @@
 import { useState } from 'react'
 import { useState } from 'react'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
@@ -12,8 +12,11 @@ import { Badge } from '@/components/ui/badge'
 import { AppCard } from '@/components/app-card'
 import { AppCard } from '@/components/app-card'
 import {
 import {
   ArrowRight,
   ArrowRight,
+  Building2,
+  Copy,
   ExternalLink,
   ExternalLink,
   ImageIcon,
   ImageIcon,
+  Infinity as InfinityIcon,
   Info,
   Info,
   Key,
   Key,
   Loader2,
   Loader2,
@@ -25,11 +28,46 @@ import { validateLicense } from '../Actions/validateLicense'
 import {
 import {
   LICENSE_TOKEN_MAX_AGE_DAYS,
   LICENSE_TOKEN_MAX_AGE_DAYS,
   LICENSE_TOKEN_WARN_AGE_DAYS,
   LICENSE_TOKEN_WARN_AGE_DAYS,
+  type LicenseTokenKind,
   type LicenseTokenStatus,
   type LicenseTokenStatus,
+  normalizeRegistrationNumber,
+  type PerpetualLicensee,
 } from '@/lib/license/token'
 } from '@/lib/license/token'
 
 
 const DAY_MS = 24 * 60 * 60 * 1000
 const DAY_MS = 24 * 60 * 60 * 1000
 
 
+const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
+
+/** Where the workshop's organization number is set; the field's own id is the anchor. */
+const ORG_NUMBER_HREF = '/settings/company#orgNumber'
+
+/**
+ * The licence request code the offline checkout on torqvoice.com takes, so
+ * nobody types an organization id by hand: `tvr1.` plus base64url JSON of
+ * what the token will be minted for. Not signed; torqvoice.com only reads it.
+ */
+export function licenseRequestCode(
+  organizationId: string,
+  workshopName: string,
+  registrationNumber: string
+): string {
+  const json = JSON.stringify({ v: 1, org: organizationId, name: workshopName, registrationNumber })
+  // UTF-8 first: workshop names are rarely plain ASCII.
+  let binary = ''
+  for (const byte of new TextEncoder().encode(json)) binary += String.fromCharCode(byte)
+  const base64url = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+  return `tvr1.${base64url}`
+}
+
+/** "NO" reads as "Norway" in the workshop's language; an unknown code stays as it is. */
+function countryName(code: string, locale: string): string {
+  try {
+    return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code
+  } catch {
+    return code
+  }
+}
+
 /**
 /**
  * One line of the "make it yours" list. The list is always shown, so a
  * One line of the "make it yours" list. The list is always shown, so a
  * workshop sees what the licence unlocks before buying one: a locked row
  * workshop sees what the licence unlocks before buying one: a locked row
@@ -92,53 +130,101 @@ function NextStep({
 export function LicenseSettings({
 export function LicenseSettings({
   initialKey,
   initialKey,
   initialStatus,
   initialStatus,
+  initialKind,
+  initialLicensee,
+  initialMaxOrganizations,
   initialExpiresAt,
   initialExpiresAt,
   initialIssuedAt,
   initialIssuedAt,
   initialCheckedAt,
   initialCheckedAt,
+  organizationId,
+  workshopName,
+  registrationNumber,
   brandingHref,
   brandingHref,
   demoMode = false,
   demoMode = false,
 }: {
 }: {
   initialKey: string
   initialKey: string
   initialStatus: LicenseTokenStatus
   initialStatus: LicenseTokenStatus
-  /** from the verified token; empty without one */
+  /** subscription or offline (perpetual); null without a readable token */
+  initialKind: LicenseTokenKind | null
+  /** who an offline licence was issued to; null for a subscription */
+  initialLicensee: PerpetualLicensee | null
+  /** workshops an offline licence covers; null unless the token says */
+  initialMaxOrganizations: number | null
+  /** from the verified token; empty without one or for an offline licence */
   initialExpiresAt: string
   initialExpiresAt: string
   /** when torqvoice.com last signed the stored token; empty without one */
   /** when torqvoice.com last signed the stored token; empty without one */
   initialIssuedAt: string
   initialIssuedAt: string
   /** when this server last tried, reachable or not */
   /** when this server last tried, reachable or not */
   initialCheckedAt: string
   initialCheckedAt: string
+  organizationId: string
+  /** the organization's own name, carried in the request code */
+  workshopName: string
+  /** the workshop's organization number setting; empty when not set */
+  registrationNumber: string
   /** the admin page that names the install in the browser; null for anyone who may not open it */
   /** the admin page that names the install in the browser; null for anyone who may not open it */
   brandingHref: string | null
   brandingHref: string | null
   demoMode?: boolean
   demoMode?: boolean
 }) {
 }) {
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('settings')
   const t = useTranslations('settings')
+  const locale = useLocale()
   const [licenseKey, setLicenseKey] = useState(initialKey)
   const [licenseKey, setLicenseKey] = useState(initialKey)
   const [status, setStatus] = useState<LicenseTokenStatus>(initialStatus)
   const [status, setStatus] = useState<LicenseTokenStatus>(initialStatus)
+  const [kind, setKind] = useState<LicenseTokenKind | null>(initialKind)
+  const [licensee, setLicensee] = useState<PerpetualLicensee | null>(initialLicensee)
   const [isValidating, setIsValidating] = useState(false)
   const [isValidating, setIsValidating] = useState(false)
 
 
   const licenseValid = status === 'valid'
   const licenseValid = status === 'valid'
+  const perpetual = kind === 'perpetual'
+  // An offline token that checked out, whether or not it matches this workshop.
+  const showLicensee =
+    perpetual && licensee !== null && status !== 'missing' && status !== 'invalid'
+  const hasRegistrationNumber = normalizeRegistrationNumber(registrationNumber).length > 0
+  const requestCode = hasRegistrationNumber
+    ? licenseRequestCode(organizationId, workshopName, registrationNumber)
+    : ''
   const issuedAgeDays = initialIssuedAt
   const issuedAgeDays = initialIssuedAt
     ? Math.floor((Date.now() - new Date(initialIssuedAt).getTime()) / DAY_MS)
     ? Math.floor((Date.now() - new Date(initialIssuedAt).getTime()) / DAY_MS)
     : null
     : null
+  // An offline licence never checks in, so it is never unverified.
   const showUnverifiedHint =
   const showUnverifiedHint =
+    !perpetual &&
     (status === 'valid' || status === 'stale') &&
     (status === 'valid' || status === 'stale') &&
     issuedAgeDays !== null &&
     issuedAgeDays !== null &&
     issuedAgeDays >= LICENSE_TOKEN_WARN_AGE_DAYS
     issuedAgeDays >= LICENSE_TOKEN_WARN_AGE_DAYS
 
 
+  const mismatchMessage = (licensed: string) =>
+    hasRegistrationNumber
+      ? t('license.mismatchExplain', { licensed, current: registrationNumber })
+      : t('license.mismatchExplainNotSet', { licensed })
+
   const handleValidateLicense = async () => {
   const handleValidateLicense = async () => {
     setIsValidating(true)
     setIsValidating(true)
     try {
     try {
       const result = await validateLicense(licenseKey)
       const result = await validateLicense(licenseKey)
       if (result.success && result.data) {
       if (result.success && result.data) {
-        setStatus(result.data.status)
-        if (!result.data.reachable) {
-          toast.error(t('license.unreachable'))
-        } else if (result.data.valid) {
-          toast.success(t('license.validated'))
-        } else if (result.data.reason) {
-          toast.error(t('license.rejected', { reason: result.data.reason }))
+        const data = result.data
+        // What is on disk now. A rejected key or token leaves the existing
+        // licence in place, and the page keeps showing it.
+        setStatus(data.status)
+        setKind(data.kind)
+        setLicensee(data.licensee)
+        if (!data.accepted) {
+          if (data.attempted === 'perpetual') {
+            toast.error(t('license.offlineInvalid'))
+          } else if (!data.reachable) {
+            toast.error(t('license.unreachable'))
+          } else if (data.reason) {
+            toast.error(t('license.rejected', { reason: data.reason }))
+          } else {
+            toast.error(t('license.invalid'))
+          }
+        } else if (data.status === 'mismatch') {
+          toast.error(mismatchMessage(data.licensee?.registrationNumber ?? ''))
+        } else if (data.attempted === 'perpetual') {
+          toast.success(t('license.offlineActivated'))
         } else {
         } else {
-          toast.error(t('license.invalid'))
+          toast.success(t('license.validated'))
         }
         }
         router.refresh()
         router.refresh()
       } else {
       } else {
@@ -149,8 +235,19 @@ export function LicenseSettings({
     }
     }
   }
   }
 
 
+  const handleCopyRequestCode = async () => {
+    try {
+      await navigator.clipboard.writeText(requestCode)
+      toast.success(t('license.requestCodeCopied'))
+    } catch {
+      toast.error(t('license.requestCodeCopyFailed'))
+    }
+  }
+
   const statusBadge = licenseValid ? (
   const statusBadge = licenseValid ? (
     <Badge variant="default">{t('license.active')}</Badge>
     <Badge variant="default">{t('license.active')}</Badge>
+  ) : status === 'mismatch' ? (
+    <Badge variant="destructive">{t('license.mismatch')}</Badge>
   ) : status === 'expired' ? (
   ) : status === 'expired' ? (
     <Badge variant="destructive">{t('license.expired')}</Badge>
     <Badge variant="destructive">{t('license.expired')}</Badge>
   ) : status === 'stale' ? (
   ) : status === 'stale' ? (
@@ -171,7 +268,7 @@ export function LicenseSettings({
             <Button asChild variant="default" size="sm" className="shrink-0">
             <Button asChild variant="default" size="sm" className="shrink-0">
               <a
               <a
                 id="license-purchase"
                 id="license-purchase"
-                href={`${process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'}/subscriptions/white-label`}
+                href={`${TORQVOICE_COM_URL}/subscriptions/white-label`}
                 target="_blank"
                 target="_blank"
                 rel="noopener noreferrer"
                 rel="noopener noreferrer"
               >
               >
@@ -186,17 +283,54 @@ export function LicenseSettings({
         <div id="license-status" className="flex flex-wrap items-center gap-2">
         <div id="license-status" className="flex flex-wrap items-center gap-2">
           <Label>{t('license.status')}</Label>
           <Label>{t('license.status')}</Label>
           {statusBadge}
           {statusBadge}
-          {initialExpiresAt && (
+          {licenseValid && perpetual && (
+            <Badge variant="outline" className="gap-1">
+              <InfinityIcon className="h-3 w-3" aria-hidden="true" />
+              {t('license.perpetual')}
+            </Badge>
+          )}
+          {!perpetual && initialExpiresAt && (
             <span className="text-xs text-muted-foreground">
             <span className="text-xs text-muted-foreground">
               {t('license.expiresOn', { date: new Date(initialExpiresAt).toLocaleDateString() })}
               {t('license.expiresOn', { date: new Date(initialExpiresAt).toLocaleDateString() })}
             </span>
             </span>
           )}
           )}
-          {initialCheckedAt && (
+          {!perpetual && initialCheckedAt && (
             <span className="text-xs text-muted-foreground">
             <span className="text-xs text-muted-foreground">
               {t('license.lastChecked', { date: new Date(initialCheckedAt).toLocaleDateString() })}
               {t('license.lastChecked', { date: new Date(initialCheckedAt).toLocaleDateString() })}
             </span>
             </span>
           )}
           )}
         </div>
         </div>
+        {showLicensee && licensee && (
+          <div id="license-licensee" className="space-y-1.5 rounded-lg border bg-muted/20 p-3">
+            <p className="flex items-start gap-2 text-sm font-medium">
+              <Building2
+                className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
+                aria-hidden="true"
+              />
+              <span className="min-w-0 break-words">
+                {t('license.licensedTo', {
+                  name: licensee.name,
+                  registrationNumber: licensee.registrationNumber,
+                  country: countryName(licensee.country, locale),
+                })}
+              </span>
+            </p>
+            <p className="text-xs text-muted-foreground">{t('license.perpetualHint')}</p>
+            {initialMaxOrganizations !== null && (
+              <p className="text-xs text-muted-foreground">
+                {t('license.perpetualWorkshops', { count: initialMaxOrganizations })}
+              </p>
+            )}
+            {status === 'mismatch' && (
+              <p className="text-xs text-destructive">
+                {mismatchMessage(licensee.registrationNumber)}{' '}
+                <Link href={ORG_NUMBER_HREF} className="font-medium underline underline-offset-2">
+                  {t('license.openCompanySettings')}
+                </Link>
+              </p>
+            )}
+          </div>
+        )}
         {showUnverifiedHint && issuedAgeDays !== null && (
         {showUnverifiedHint && issuedAgeDays !== null && (
           <p className="text-xs text-amber-600">
           <p className="text-xs text-amber-600">
             {t('license.unverifiedHint', {
             {t('license.unverifiedHint', {
@@ -257,6 +391,86 @@ export function LicenseSettings({
           )}
           )}
         </div>
         </div>
       </AppCard>
       </AppCard>
+      {/* The way to an offline licence, until one is active. The demo runs on our licence. */}
+      {!demoMode && !(licenseValid && perpetual) && (
+        <AppCard
+          id="license-offline"
+          icon={InfinityIcon}
+          title={t('license.offlineTitle')}
+          description={t('license.offlineDescription')}
+          contentClassName="space-y-4"
+        >
+          <p className="text-sm text-muted-foreground">{t('license.offlineBinding')}</p>
+          <dl className="grid gap-3 rounded-lg border bg-muted/20 p-3 text-sm sm:grid-cols-3">
+            <div className="min-w-0 space-y-0.5">
+              <dt className="text-xs text-muted-foreground">
+                {t('license.offlineOrganizationId')}
+              </dt>
+              <dd className="break-all font-mono text-xs">{organizationId}</dd>
+            </div>
+            <div className="min-w-0 space-y-0.5">
+              <dt className="text-xs text-muted-foreground">{t('license.offlineWorkshop')}</dt>
+              <dd className="break-words">{workshopName}</dd>
+            </div>
+            <div className="min-w-0 space-y-0.5">
+              <dt className="text-xs text-muted-foreground">{t('license.offlineOrgNumber')}</dt>
+              {hasRegistrationNumber ? (
+                <dd className="break-all font-mono text-xs">{registrationNumber}</dd>
+              ) : (
+                <dd className="space-y-1">
+                  <span className="block text-amber-600">{t('license.notSet')}</span>
+                  <Link
+                    href={ORG_NUMBER_HREF}
+                    className="block text-xs font-medium underline underline-offset-2"
+                  >
+                    {t('license.openCompanySettings')}
+                  </Link>
+                </dd>
+              )}
+            </div>
+          </dl>
+          {!hasRegistrationNumber && (
+            <p className="flex items-start gap-1.5 text-xs text-amber-600">
+              <Info className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+              <span>{t('license.offlineOrgNumberRequired')}</span>
+            </p>
+          )}
+          <div className="space-y-1.5">
+            <Label htmlFor="license-request-code">{t('license.requestCode')}</Label>
+            <div className="flex gap-2">
+              <Input
+                id="license-request-code"
+                readOnly
+                value={requestCode}
+                onFocus={(e) => e.target.select()}
+                className="font-mono text-xs"
+              />
+              <Button
+                type="button"
+                variant="outline"
+                onClick={handleCopyRequestCode}
+                disabled={!hasRegistrationNumber}
+              >
+                <Copy className="mr-2 h-4 w-4" />
+                {t('license.copyRequestCode')}
+              </Button>
+            </div>
+          </div>
+          <div className="flex flex-wrap items-center justify-between gap-3">
+            <p className="text-xs text-muted-foreground">{t('license.offlineOrderHint')}</p>
+            <Button asChild variant="default" size="sm" className="shrink-0">
+              <a
+                href={`${TORQVOICE_COM_URL}/subscriptions/offline-license`}
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                {t('license.orderOffline')}
+                <ExternalLink className="ml-2 h-3 w-3" />
+              </a>
+            </Button>
+          </div>
+        </AppCard>
+      )}
     </div>
     </div>
   )
   )
 }
 }

+ 18 - 1
src/features/settings/Lib/searchIndex/license.ts

@@ -1,6 +1,6 @@
 import type { SettingsEntry } from './types'
 import type { SettingsEntry } from './types'
 
 
-/** License (self-hosted only): the white-label card and its key field. */
+/** License (self-hosted only): the white-label card and its key field, and the offline licence card. */
 export const licenseEntries: SettingsEntry[] = [
 export const licenseEntries: SettingsEntry[] = [
   {
   {
     id: 'license.whiteLabel',
     id: 'license.whiteLabel',
@@ -40,4 +40,21 @@ export const licenseEntries: SettingsEntry[] = [
     anchor: 'license-purchase',
     anchor: 'license-purchase',
     keywords: ['buy license', 'purchase license', 'white label price', 'upgrade'],
     keywords: ['buy license', 'purchase license', 'white label price', 'upgrade'],
   },
   },
+  {
+    id: 'license.offline',
+    page: 'license',
+    title: 'license.offlineTitle',
+    description: 'license.offlineDescription',
+    anchor: 'license-offline',
+    keywords: [
+      'offline license',
+      'offline licence',
+      'perpetual license',
+      'lifetime license',
+      'one-time purchase',
+      'license request code',
+      'no check-in',
+      'air gapped',
+    ],
+  },
 ]
 ]

+ 11 - 4
src/lib/cron/check-licenses.ts

@@ -19,6 +19,9 @@ const EXPIRY_WARNING_DAYS = 14
 export async function revalidateOrganizationLicense(organizationId: string, licenseKey: string) {
 export async function revalidateOrganizationLicense(organizationId: string, licenseKey: string) {
   const { verification } = await revalidateLicense(organizationId, licenseKey)
   const { verification } = await revalidateLicense(organizationId, licenseKey)
   const { status, daysUntilExpiry, ageDays } = verification
   const { status, daysUntilExpiry, ageDays } = verification
+  // A perpetual token was not refreshed (revalidateLicense leaves it alone)
+  // and has no clock to warn about: no term, and its age means nothing.
+  if (verification.kind === 'perpetual') return
 
 
   if (
   if (
     status === 'valid' &&
     status === 'valid' &&
@@ -177,10 +180,14 @@ export async function refreshLicensesMissingTokens() {
       select: { organizationId: true, value: true },
       select: { organizationId: true, value: true },
     })
     })
     const tokenByOrg = new Map(tokens.map((t) => [t.organizationId, t.value]))
     const tokenByOrg = new Map(tokens.map((t) => [t.organizationId, t.value]))
-    const needing = orgs.filter(
-      (o) =>
-        verifyLicenseToken(tokenByOrg.get(o.organizationId), o.organizationId).status !== 'valid'
-    )
+    const needing = orgs.filter((o) => {
+      const verification = verifyLicenseToken(tokenByOrg.get(o.organizationId), o.organizationId)
+      // A perpetual token has nothing to fetch, and refreshing a leftover key
+      // would put a subscription token over it. Storing one drops the key, so
+      // this only guards a database edited by hand.
+      if (verification.kind === 'perpetual') return false
+      return verification.status !== 'valid'
+    })
     if (needing.length === 0) return
     if (needing.length === 0) return
     await revalidateAll(needing)
     await revalidateAll(needing)
   } catch (error) {
   } catch (error) {

+ 59 - 19
src/lib/features.ts

@@ -253,26 +253,33 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
   // Self-hosted mode — all features unlocked, license only controls branding.
   // Self-hosted mode — all features unlocked, license only controls branding.
   //
   //
   // The gate trusts one thing: a token signed by torqvoice.com, bound to this
   // The gate trusts one thing: a token signed by torqvoice.com, bound to this
-  // organization, refreshed within the last two weeks. The operator owns this
-  // database, so `license.valid` and friends are display cache only; editing
-  // them changes nothing here. See src/lib/license/token.ts.
+  // organization. A subscription token must have been refreshed within the
+  // last two weeks; a perpetual one must name the registration number this
+  // workshop prints on its invoices, so `invoice.orgNumber` is read alongside
+  // it. The operator owns this database, so `license.valid` and friends are
+  // display cache only; editing them changes nothing here. See
+  // src/lib/license/token.ts.
   const settings = await db.appSetting.findMany({
   const settings = await db.appSetting.findMany({
     where: {
     where: {
       organizationId,
       organizationId,
-      key: { in: ['license.token', 'license.key'] },
+      key: { in: ['license.token', 'license.key', 'invoice.orgNumber'] },
     },
     },
   })
   })
 
 
   const map = new Map(settings.map((s) => [s.key, s.value]))
   const map = new Map(settings.map((s) => [s.key, s.value]))
-  const verification = verifyLicenseToken(map.get('license.token'), organizationId)
+  const verification = verifyLicenseToken(map.get('license.token'), organizationId, {
+    registrationNumber: map.get('invoice.orgNumber'),
+  })
   const hasLicense = verification.status === 'valid'
   const hasLicense = verification.status === 'valid'
 
 
   // A key with no usable token is an install that has not talked to
   // A key with no usable token is an install that has not talked to
   // torqvoice.com recently, or one that upgraded from the release that stored
   // torqvoice.com recently, or one that upgraded from the release that stored
   // plain booleans. Refresh in the background; the cron would get there within
   // plain booleans. Refresh in the background; the cron would get there within
-  // a day anyway, this just makes the upgrade invisible.
+  // a day anyway, this just makes the upgrade invisible. A perpetual token is
+  // never refreshed: torqvoice.com has nothing newer to say about it, and a
+  // mismatch is fixed in Company settings, not over the network.
   const key = map.get('license.key')
   const key = map.get('license.key')
-  if (key && !hasLicense) {
+  if (key && !hasLicense && verification.kind !== 'perpetual') {
     scheduleLicenseSelfHeal(organizationId, key)
     scheduleLicenseSelfHeal(organizationId, key)
   }
   }
 
 
@@ -281,26 +288,59 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
     brandingRemoved: hasLicense,
     brandingRemoved: hasLicense,
     customPlatformName: hasLicense,
     customPlatformName: hasLicense,
     // One workshop per install without a licence. A self-hosting workshop
     // One workshop per install without a licence. A self-hosting workshop
-    // needs one; running many is what the white-label licence is for.
-    maxOrganizations: hasLicense ? PLAN_FEATURES['white-label'].maxOrganizations : 1,
+    // needs one; running many is what the white-label licence is for. A
+    // perpetual licence says how many it covers.
+    maxOrganizations: hasLicense
+      ? (verification.maxOrganizations ?? PLAN_FEATURES['white-label'].maxOrganizations)
+      : 1,
   }
   }
 })
 })
 
 
+/**
+ * The workshops each valid licence token on this install allows, one entry
+ * per token that verifies. A perpetual token is checked against its own
+ * workshop's registration number, so both are loaded together.
+ */
+async function validInstallLicenseLimits(): Promise<number[]> {
+  const rows = await db.appSetting.findMany({
+    where: { key: { in: ['license.token', 'invoice.orgNumber'] }, organizationId: { not: null } },
+    select: { organizationId: true, key: true, value: true },
+  })
+  const orgNumbers = new Map<string, string>()
+  for (const row of rows) {
+    if (row.key === 'invoice.orgNumber' && row.organizationId) {
+      orgNumbers.set(row.organizationId, row.value)
+    }
+  }
+
+  const limits: number[] = []
+  for (const row of rows) {
+    if (row.key !== 'license.token' || !row.organizationId) continue
+    const verification = verifyLicenseToken(row.value, row.organizationId, {
+      registrationNumber: orgNumbers.get(row.organizationId),
+    })
+    if (verification.status !== 'valid') continue
+    limits.push(verification.maxOrganizations ?? PLAN_FEATURES['white-label'].maxOrganizations)
+  }
+  return limits
+}
+
 /**
 /**
  * Whether any organization on this install holds a valid licence token.
  * Whether any organization on this install holds a valid licence token.
  * The licence is what turns a single-workshop install into a multi-workshop
  * The licence is what turns a single-workshop install into a multi-workshop
  * one, so it is checked across the install, not per person.
  * one, so it is checked across the install, not per person.
  */
  */
 export async function installHasLicense(): Promise<boolean> {
 export async function installHasLicense(): Promise<boolean> {
-  const tokens = await db.appSetting.findMany({
-    where: { key: 'license.token', organizationId: { not: null } },
-    select: { organizationId: true, value: true },
-  })
-  return tokens.some(
-    (row) =>
-      row.organizationId !== null &&
-      verifyLicenseToken(row.value, row.organizationId).status === 'valid'
-  )
+  return (await validInstallLicenseLimits()).length > 0
+}
+
+/**
+ * How many workshops this install may run: the most any valid licence on it
+ * allows. A subscription licence brings the white-label plan's number, a
+ * perpetual one the number it was sold with. One without any.
+ */
+export async function installLicenseMaxOrganizations(): Promise<number> {
+  return Math.max(1, ...(await validInstallLicenseLimits()))
 }
 }
 
 
 /**
 /**
@@ -310,7 +350,7 @@ export async function installHasLicense(): Promise<boolean> {
  */
  */
 export async function getMaxOrganizations(userId: string): Promise<number> {
 export async function getMaxOrganizations(userId: string): Promise<number> {
   if (!isCloudMode()) {
   if (!isCloudMode()) {
-    return (await installHasLicense()) ? PLAN_FEATURES['white-label'].maxOrganizations : 1
+    return installLicenseMaxOrganizations()
   }
   }
 
 
   const ownedOrgs = await db.organizationMember.findMany({
   const ownedOrgs = await db.organizationMember.findMany({

+ 4 - 1
src/lib/license/public-keys.ts

@@ -3,7 +3,10 @@
  * Single-line base64 SPKI DER. The private halves never leave torqvoice.com.
  * Single-line base64 SPKI DER. The private halves never leave torqvoice.com.
  *
  *
  * More than one entry is only ever for rotation: add the new key here and
  * More than one entry is only ever for rotation: add the new key here and
- * ship it, then switch the signer, then drop the old key a release later.
+ * ship it, then switch the signer. A key is NEVER removed once it has signed
+ * a perpetual (`tvp1`) licence: those tokens are bought once and verified
+ * offline for the life of the install, so dropping their key would silently
+ * take a paid licence away.
  *
  *
  * This is deliberately a constant and not an environment variable. An env
  * This is deliberately a constant and not an environment variable. An env
  * override would let an operator point the app at a key they hold, which is
  * override would let an operator point the app at a key they hold, which is

+ 149 - 31
src/lib/license/revalidate.ts

@@ -1,4 +1,3 @@
-import type { Prisma } from '@/generated/prisma/client'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { verifyLicenseToken, type LicenseTokenVerification } from './token'
 import { verifyLicenseToken, type LicenseTokenVerification } from './token'
@@ -11,6 +10,7 @@ import { torqvoiceComUrl } from '@/lib/torqvoice-com'
  * `getFeatures`. Only the signed token matters for entitlements; the plain
  * `getFeatures`. Only the signed token matters for entitlements; the plain
  * rows (`license.valid`, `license.plan`, `license.expiresAt`) are kept as a
  * rows (`license.valid`, `license.plan`, `license.expiresAt`) are kept as a
  * display cache for the licence page and mean nothing to the feature gate.
  * display cache for the licence page and mean nothing to the feature gate.
+ * A perpetual token is stored here too, but never talks to torqvoice.com.
  */
  */
 
 
 export type RemoteLicenseResult = {
 export type RemoteLicenseResult = {
@@ -67,62 +67,180 @@ function upsert(organizationId: string, userId: string, key: string, value: stri
   })
   })
 }
 }
 
 
+/**
+ * The licence on disk, verified the way the feature gate verifies it: the
+ * stored token against the workshop's own registration number.
+ */
+export async function storedLicenseVerification(
+  organizationId: string
+): Promise<LicenseTokenVerification> {
+  const rows = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: { in: [SETTING_KEYS.LICENSE_TOKEN, SETTING_KEYS.INVOICE_ORG_NUMBER] },
+    },
+    select: { key: true, value: true },
+  })
+  const token = rows.find((r) => r.key === SETTING_KEYS.LICENSE_TOKEN)?.value
+  const registrationNumber = rows.find((r) => r.key === SETTING_KEYS.INVOICE_ORG_NUMBER)?.value
+  return verifyLicenseToken(token, organizationId, { registrationNumber })
+}
+
+export type RemoteStoreOptions = {
+  /**
+   * From the Validate button only. A key torqvoice.com accepts may then
+   * replace a perpetual token, because the owner typed it in on purpose. The
+   * cron and the self-heal never pass it, so no refresh can touch an offline
+   * licence.
+   */
+  replacePerpetual?: boolean
+}
+
 /**
 /**
  * Stores a validate response. A reachable answer is authoritative, including
  * Stores a validate response. A reachable answer is authoritative, including
  * "not valid": the token is dropped. An unreachable torqvoice.com changes
  * "not valid": the token is dropped. An unreachable torqvoice.com changes
  * nothing except `license.checkedAt`, so the existing token keeps working
  * nothing except `license.checkedAt`, so the existing token keeps working
  * until it ages out on its own.
  * until it ages out on its own.
+ *
+ * Except over a perpetual token. One on disk is read inside the same
+ * transaction as the writes, so a refresh that was already in flight when the
+ * token was pasted cannot land on top of it, and a mistyped key, a rejected
+ * key or an unreachable torqvoice.com never costs a paid offline licence.
+ * Only a key torqvoice.com accepts, typed in by the owner, replaces it.
+ *
+ * Returns whether anything was written.
  */
  */
 export async function storeRemoteLicense(
 export async function storeRemoteLicense(
   organizationId: string,
   organizationId: string,
   licenseKey: string,
   licenseKey: string,
   result: RemoteLicenseResult,
   result: RemoteLicenseResult,
-  userId?: string
-): Promise<void> {
+  userId?: string,
+  options: RemoteStoreOptions = {}
+): Promise<boolean> {
   const uid = userId ?? (await settingUserId(organizationId))
   const uid = userId ?? (await settingUserId(organizationId))
-  if (!uid) return
+  if (!uid) return false
 
 
   const now = new Date().toISOString()
   const now = new Date().toISOString()
-  const writes: Prisma.PrismaPromise<unknown>[] = [
-    upsert(organizationId, uid, SETTING_KEYS.LICENSE_KEY, licenseKey),
-    upsert(organizationId, uid, SETTING_KEYS.LICENSE_CHECKED_AT, now),
-  ]
-
-  if (result.reachable) {
-    writes.push(
-      upsert(organizationId, uid, SETTING_KEYS.LICENSE_VALID, String(result.valid)),
-      upsert(organizationId, uid, SETTING_KEYS.LICENSE_PLAN, result.plan)
-    )
-    if (result.expiresAt) {
-      writes.push(upsert(organizationId, uid, SETTING_KEYS.LICENSE_EXPIRES_AT, result.expiresAt))
+  const accepted = result.reachable && result.valid && Boolean(result.token)
+
+  return db.$transaction(async (tx) => {
+    const current = await tx.appSetting.findUnique({
+      where: { organizationId_key: { organizationId, key: SETTING_KEYS.LICENSE_TOKEN } },
+      select: { value: true },
+    })
+    const onDisk = verifyLicenseToken(current?.value, organizationId)
+    if (onDisk.kind === 'perpetual' && !(options.replacePerpetual && accepted)) {
+      return false
     }
     }
-    if (result.token) {
-      writes.push(upsert(organizationId, uid, SETTING_KEYS.LICENSE_TOKEN, result.token))
-    } else {
+
+    const put = (key: string, value: string) =>
+      tx.appSetting.upsert({
+        where: { organizationId_key: { organizationId, key } },
+        update: { value },
+        create: { userId: uid, organizationId, key, value },
+      })
+
+    const writes: Promise<unknown>[] = [
+      put(SETTING_KEYS.LICENSE_KEY, licenseKey),
+      put(SETTING_KEYS.LICENSE_CHECKED_AT, now),
+    ]
+
+    if (result.reachable) {
       writes.push(
       writes.push(
-        db.appSetting.deleteMany({
-          where: { organizationId, key: SETTING_KEYS.LICENSE_TOKEN },
-        })
+        put(SETTING_KEYS.LICENSE_VALID, String(result.valid)),
+        put(SETTING_KEYS.LICENSE_PLAN, result.plan)
       )
       )
+      if (result.expiresAt) {
+        writes.push(put(SETTING_KEYS.LICENSE_EXPIRES_AT, result.expiresAt))
+      }
+      if (result.token) {
+        writes.push(put(SETTING_KEYS.LICENSE_TOKEN, result.token))
+      } else {
+        writes.push(
+          tx.appSetting.deleteMany({
+            where: { organizationId, key: SETTING_KEYS.LICENSE_TOKEN },
+          })
+        )
+      }
     }
     }
-  }
 
 
-  await db.$transaction(writes)
+    await Promise.all(writes)
+    return true
+  })
 }
 }
 
 
-/** Fetches and stores in one go. Returns the verification of what is now on disk. */
+/**
+ * Fetches and stores in one go. Returns the verification of what is now on
+ * disk. `remote` is null when nothing was fetched: a refresh (no
+ * `replacePerpetual`) finds a perpetual token on disk and has nothing to do,
+ * since that licence never checks in.
+ */
 export async function revalidateLicense(
 export async function revalidateLicense(
   organizationId: string,
   organizationId: string,
   licenseKey: string,
   licenseKey: string,
-  userId?: string
-): Promise<{ remote: RemoteLicenseResult; verification: LicenseTokenVerification }> {
+  userId?: string,
+  options: RemoteStoreOptions = {}
+): Promise<{ remote: RemoteLicenseResult | null; verification: LicenseTokenVerification }> {
+  if (!options.replacePerpetual) {
+    const before = await storedLicenseVerification(organizationId)
+    if (before.kind === 'perpetual') return { remote: null, verification: before }
+  }
   const remote = await fetchRemoteLicense(licenseKey, organizationId)
   const remote = await fetchRemoteLicense(licenseKey, organizationId)
-  await storeRemoteLicense(organizationId, licenseKey, remote, userId)
-  const stored = await db.appSetting.findUnique({
-    where: { organizationId_key: { organizationId, key: SETTING_KEYS.LICENSE_TOKEN } },
+  await storeRemoteLicense(organizationId, licenseKey, remote, userId, options)
+  return { remote, verification: await storedLicenseVerification(organizationId) }
+}
+
+/**
+ * Stores a pasted perpetual token. Nothing goes over the network: the token
+ * verifies on its own against the workshop's registration number.
+ *
+ * A token whose signature holds but whose licensee does not match the
+ * workshop's `invoice.orgNumber` is stored anyway. The License page can then
+ * tell the owner which number the licence names, and the gate opens on its
+ * own once Company settings are corrected, without pasting the token again.
+ * Only a token that does not verify at all is refused.
+ *
+ * The licence key and its term are dropped: a perpetual licence replaces a
+ * subscription, and a leftover key would have the cron fetch a subscription
+ * token over the top of it.
+ */
+export async function storePerpetualLicense(
+  organizationId: string,
+  token: string,
+  userId?: string
+): Promise<LicenseTokenVerification> {
+  const orgNumber = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId, key: SETTING_KEYS.INVOICE_ORG_NUMBER } },
     select: { value: true },
     select: { value: true },
   })
   })
-  return { remote, verification: verifyLicenseToken(stored?.value, organizationId) }
+  const verification = verifyLicenseToken(token, organizationId, {
+    registrationNumber: orgNumber?.value,
+  })
+  if (verification.status !== 'valid' && verification.status !== 'mismatch') {
+    return verification
+  }
+
+  const uid = userId ?? (await settingUserId(organizationId))
+  if (!uid) return verification
+
+  await db.$transaction([
+    upsert(organizationId, uid, SETTING_KEYS.LICENSE_TOKEN, token),
+    db.appSetting.deleteMany({
+      where: {
+        organizationId,
+        key: { in: [SETTING_KEYS.LICENSE_KEY, SETTING_KEYS.LICENSE_EXPIRES_AT] },
+      },
+    }),
+    upsert(
+      organizationId,
+      uid,
+      SETTING_KEYS.LICENSE_VALID,
+      String(verification.status === 'valid')
+    ),
+    upsert(organizationId, uid, SETTING_KEYS.LICENSE_PLAN, verification.payload?.plan ?? 'free'),
+    upsert(organizationId, uid, SETTING_KEYS.LICENSE_CHECKED_AT, new Date().toISOString()),
+  ])
+  return verification
 }
 }
 
 
 // One attempt per org per hour from the request path. The cron does the real
 // One attempt per org per hour from the request path. The cron does the real

+ 192 - 32
src/lib/license/token.ts

@@ -2,24 +2,39 @@ import type { KeyObject } from 'node:crypto'
 import { embeddedKeys, signedPayload } from './signature'
 import { embeddedKeys, signedPayload } from './signature'
 
 
 /**
 /**
- * Verifies the signed licence token torqvoice.com hands out.
+ * Verifies the signed licence tokens torqvoice.com hands out.
  *
  *
- * Nothing in the local database is trusted on its own. The token carries the
- * organization it was minted for, the plan, the expiry and the time it was
- * signed, and only a valid signature over all of that unlocks branding
- * removal. See torqvoice.com `src/lib/license-signing.ts` for the signer.
+ * Nothing in the local database is trusted on its own. A token carries the
+ * organization it was minted for and only a valid signature over all of it
+ * unlocks branding removal. See torqvoice.com `src/lib/license-signing.ts`
+ * for the signer.
  *
  *
- * Two clocks apply:
+ * Two kinds of token exist, told apart by their prefix so one can never be
+ * read as the other:
+ *
+ * `tvl1`, the subscription token. torqvoice.com re-signs it on every daily
+ * check, and two clocks apply:
  * - `expiresAt` is the licence term itself.
  * - `expiresAt` is the licence term itself.
- * - `issuedAt` must be recent. torqvoice.com re-signs on every daily check,
- *   so a token older than LICENSE_TOKEN_MAX_AGE_DAYS means the app has not
- *   been able to reach torqvoice.com for that long, or somebody has cut it
- *   off deliberately. Either way branding comes back until a fresh token
- *   arrives. The header warns from LICENSE_TOKEN_WARN_AGE_DAYS so a genuine
- *   outage never surprises a paying customer.
+ * - `issuedAt` must be recent. A token older than LICENSE_TOKEN_MAX_AGE_DAYS
+ *   means the app has not been able to reach torqvoice.com for that long, or
+ *   somebody has cut it off deliberately. Either way branding comes back
+ *   until a fresh token arrives. The header warns from
+ *   LICENSE_TOKEN_WARN_AGE_DAYS so a genuine outage never surprises a paying
+ *   customer.
+ *
+ * `tvp1`, the perpetual (offline) token. Bought once, issued by hand, pasted
+ * into Settings, License, and verified here with no network call ever. It
+ * has no expiry and no staleness check. What binds it instead is the
+ * licensee: the token names the workshop's registration number (the field
+ * printed on its invoices) and only verifies while the workshop's own
+ * setting still matches. A token copied to another install would force that
+ * install to print the licensee's tax number on its invoices. The token also
+ * carries how many workshops the install may run, so a perpetual licence is
+ * never an unlimited one.
  */
  */
 
 
 export const LICENSE_TOKEN_PREFIX = 'tvl1'
 export const LICENSE_TOKEN_PREFIX = 'tvl1'
+export const PERPETUAL_LICENSE_TOKEN_PREFIX = 'tvp1'
 export const LICENSE_TOKEN_MAX_AGE_DAYS = 14
 export const LICENSE_TOKEN_MAX_AGE_DAYS = 14
 export const LICENSE_TOKEN_WARN_AGE_DAYS = 7
 export const LICENSE_TOKEN_WARN_AGE_DAYS = 7
 
 
@@ -34,59 +49,146 @@ export type LicenseTokenPayload = {
   issuedAt: string
   issuedAt: string
 }
 }
 
 
+export type PerpetualLicensee = {
+  /** display only: "Licensed to NAME" */
+  name: string
+  /** enforced against the workshop's own organization number setting */
+  registrationNumber: string
+  /** ISO 3166-1 alpha-2, display only */
+  country: string
+}
+
+export type PerpetualLicensePayload = {
+  v: 1
+  lid: string
+  org: string
+  plan: string
+  /** how many workshops this install may run; 1 for every licence sold so far */
+  maxOrganizations: number
+  licensee: PerpetualLicensee
+  /** informational: when the token was signed */
+  issuedAt: string
+}
+
+export type LicenseTokenKind = 'subscription' | 'perpetual'
+
 export type LicenseTokenStatus =
 export type LicenseTokenStatus =
   /** no token stored */
   /** no token stored */
   | 'missing'
   | 'missing'
   /** malformed, bad signature, or minted for another organization */
   /** malformed, bad signature, or minted for another organization */
   | 'invalid'
   | 'invalid'
-  /** signature fine, licence term is over */
+  /** signature fine, licence term is over (subscription only) */
   | 'expired'
   | 'expired'
-  /** signature fine, but not refreshed within LICENSE_TOKEN_MAX_AGE_DAYS */
+  /** signature fine, but not refreshed within LICENSE_TOKEN_MAX_AGE_DAYS (subscription only) */
   | 'stale'
   | 'stale'
+  /** signature fine, but the workshop's registration number is not the licensee's (perpetual only) */
+  | 'mismatch'
   | 'valid'
   | 'valid'
 
 
 export type LicenseTokenVerification = {
 export type LicenseTokenVerification = {
   status: LicenseTokenStatus
   status: LicenseTokenStatus
+  /** which kind of token this was; null when it could not be read at all */
+  kind: LicenseTokenKind | null
   /** present whenever the signature checked out, whatever the status */
   /** present whenever the signature checked out, whatever the status */
-  payload: LicenseTokenPayload | null
+  payload: LicenseTokenPayload | PerpetualLicensePayload | null
   /** whole days since the token was signed; null without a payload */
   /** whole days since the token was signed; null without a payload */
   ageDays: number | null
   ageDays: number | null
-  /** whole days until the licence term ends; null without a payload */
+  /** whole days until the licence term ends; null without a payload or for a perpetual token */
   daysUntilExpiry: number | null
   daysUntilExpiry: number | null
+  /** ISO 8601 end of term; null for a perpetual token or without a payload */
+  expiresAt: string | null
+  /** workshops the install may run under this token; null unless the token says */
+  maxOrganizations: number | null
+  /** who the perpetual token was issued to; null for a subscription token */
+  licensee: PerpetualLicensee | null
+}
+
+export type VerifyLicenseOptions = {
+  /**
+   * The workshop's own organization number setting (`invoice.orgNumber`).
+   * Required for a perpetual token to verify; a subscription token ignores
+   * it. Pass null or undefined when the workshop has not set one.
+   */
+  registrationNumber?: string | null
+  now?: Date
 }
 }
 
 
 const NOT_VERIFIED: LicenseTokenVerification = {
 const NOT_VERIFIED: LicenseTokenVerification = {
   status: 'invalid',
   status: 'invalid',
+  kind: null,
   payload: null,
   payload: null,
   ageDays: null,
   ageDays: null,
   daysUntilExpiry: null,
   daysUntilExpiry: null,
+  expiresAt: null,
+  maxOrganizations: null,
+  licensee: null,
+}
+
+/** Whether a pasted value is a perpetual token rather than a licence key. */
+export function isPerpetualLicenseToken(input: string | null | undefined): boolean {
+  return typeof input === 'string' && input.trim().startsWith(`${PERPETUAL_LICENSE_TOKEN_PREFIX}.`)
 }
 }
 
 
-function parsePayload(p: Record<string, unknown>): LicenseTokenPayload | null {
+/**
+ * Registration numbers are compared on letters and digits only, in upper
+ * case, so "NO 123 456 789 MVA" written one way on the licence and another
+ * way in Settings still match. Both sides come from the same field (the
+ * request panel copies it for the order), so no country rules are needed.
+ */
+export function normalizeRegistrationNumber(value: string | null | undefined): string {
+  return (value ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '')
+}
+
+function nonEmptyString(value: unknown): value is string {
+  return typeof value === 'string' && value.length > 0
+}
+
+function parseSubscriptionPayload(p: Record<string, unknown>): LicenseTokenPayload | null {
   if (p.v !== 1) return null
   if (p.v !== 1) return null
   for (const field of ['lid', 'org', 'plan', 'expiresAt', 'issuedAt']) {
   for (const field of ['lid', 'org', 'plan', 'expiresAt', 'issuedAt']) {
-    if (typeof p[field] !== 'string' || !(p[field] as string)) return null
+    if (!nonEmptyString(p[field])) return null
   }
   }
   if (Number.isNaN(Date.parse(p.expiresAt as string))) return null
   if (Number.isNaN(Date.parse(p.expiresAt as string))) return null
   if (Number.isNaN(Date.parse(p.issuedAt as string))) return null
   if (Number.isNaN(Date.parse(p.issuedAt as string))) return null
   return p as LicenseTokenPayload
   return p as LicenseTokenPayload
 }
 }
 
 
-/**
- * Verifies with an explicit key set. Tests use this with a throwaway pair;
- * production code goes through `verifyLicenseToken`, which pins the embedded
- * keys.
- */
-export function verifyLicenseTokenWith(
+function parsePerpetualPayload(p: Record<string, unknown>): PerpetualLicensePayload | null {
+  if (p.v !== 1) return null
+  for (const field of ['lid', 'org', 'plan', 'issuedAt']) {
+    if (!nonEmptyString(p[field])) return null
+  }
+  if (Number.isNaN(Date.parse(p.issuedAt as string))) return null
+  if (
+    typeof p.maxOrganizations !== 'number' ||
+    !Number.isInteger(p.maxOrganizations) ||
+    p.maxOrganizations < 1
+  ) {
+    return null
+  }
+  const licensee = p.licensee
+  if (!licensee || typeof licensee !== 'object') return null
+  const l = licensee as Record<string, unknown>
+  if (
+    !nonEmptyString(l.name) ||
+    !nonEmptyString(l.registrationNumber) ||
+    !nonEmptyString(l.country)
+  ) {
+    return null
+  }
+  // A licence bound to nothing would verify anywhere.
+  if (normalizeRegistrationNumber(l.registrationNumber).length === 0) return null
+  return p as PerpetualLicensePayload
+}
+
+function verifySubscription(
   keys: readonly KeyObject[],
   keys: readonly KeyObject[],
-  token: string | null | undefined,
+  token: string,
   organizationId: string,
   organizationId: string,
-  now: Date = new Date()
+  now: Date
 ): LicenseTokenVerification {
 ): LicenseTokenVerification {
-  if (!token) return { ...NOT_VERIFIED, status: 'missing' }
-
   const signed = signedPayload(keys, token, LICENSE_TOKEN_PREFIX)
   const signed = signedPayload(keys, token, LICENSE_TOKEN_PREFIX)
-  const payload = signed && parsePayload(signed)
+  const payload = signed && parseSubscriptionPayload(signed)
   if (!payload) return NOT_VERIFIED
   if (!payload) return NOT_VERIFIED
   // A token is bound to the org that asked for it. One lifted from another
   // A token is bound to the org that asked for it. One lifted from another
   // install, or minted for a different org on the same install, is rejected.
   // install, or minted for a different org on the same install, is rejected.
@@ -95,7 +197,15 @@ export function verifyLicenseTokenWith(
   const ageMs = now.getTime() - Date.parse(payload.issuedAt)
   const ageMs = now.getTime() - Date.parse(payload.issuedAt)
   const ageDays = Math.floor(ageMs / DAY_MS)
   const ageDays = Math.floor(ageMs / DAY_MS)
   const daysUntilExpiry = Math.ceil((Date.parse(payload.expiresAt) - now.getTime()) / DAY_MS)
   const daysUntilExpiry = Math.ceil((Date.parse(payload.expiresAt) - now.getTime()) / DAY_MS)
-  const base = { payload, ageDays, daysUntilExpiry }
+  const base: Omit<LicenseTokenVerification, 'status'> = {
+    kind: 'subscription',
+    payload,
+    ageDays,
+    daysUntilExpiry,
+    expiresAt: payload.expiresAt,
+    maxOrganizations: null,
+    licensee: null,
+  }
 
 
   if (daysUntilExpiry <= 0) return { ...base, status: 'expired' }
   if (daysUntilExpiry <= 0) return { ...base, status: 'expired' }
   // A future issuedAt is a clock problem, not a forgery (the signature held),
   // A future issuedAt is a clock problem, not a forgery (the signature held),
@@ -104,10 +214,60 @@ export function verifyLicenseTokenWith(
   return { ...base, status: 'valid' }
   return { ...base, status: 'valid' }
 }
 }
 
 
+function verifyPerpetual(
+  keys: readonly KeyObject[],
+  token: string,
+  organizationId: string,
+  registrationNumber: string | null | undefined,
+  now: Date
+): LicenseTokenVerification {
+  const signed = signedPayload(keys, token, PERPETUAL_LICENSE_TOKEN_PREFIX)
+  const payload = signed && parsePerpetualPayload(signed)
+  if (!payload) return NOT_VERIFIED
+  if (payload.org !== organizationId) return NOT_VERIFIED
+
+  const base: Omit<LicenseTokenVerification, 'status'> = {
+    kind: 'perpetual',
+    payload,
+    ageDays: Math.floor((now.getTime() - Date.parse(payload.issuedAt)) / DAY_MS),
+    daysUntilExpiry: null,
+    expiresAt: null,
+    maxOrganizations: payload.maxOrganizations,
+    licensee: payload.licensee,
+  }
+
+  // The binding that replaces the daily refresh: the workshop must still be
+  // the business the licence was issued to. An empty setting never matches.
+  const expected = normalizeRegistrationNumber(payload.licensee.registrationNumber)
+  const actual = normalizeRegistrationNumber(registrationNumber)
+  if (actual.length === 0 || actual !== expected) return { ...base, status: 'mismatch' }
+
+  return { ...base, status: 'valid' }
+}
+
+/**
+ * Verifies with an explicit key set. Tests use this with a throwaway pair;
+ * production code goes through `verifyLicenseToken`, which pins the embedded
+ * keys.
+ */
+export function verifyLicenseTokenWith(
+  keys: readonly KeyObject[],
+  token: string | null | undefined,
+  organizationId: string,
+  options: VerifyLicenseOptions = {}
+): LicenseTokenVerification {
+  if (!token) return { ...NOT_VERIFIED, status: 'missing' }
+  const now = options.now ?? new Date()
+  if (isPerpetualLicenseToken(token)) {
+    return verifyPerpetual(keys, token, organizationId, options.registrationNumber, now)
+  }
+  return verifySubscription(keys, token, organizationId, now)
+}
+
 export function verifyLicenseToken(
 export function verifyLicenseToken(
   token: string | null | undefined,
   token: string | null | undefined,
   organizationId: string,
   organizationId: string,
-  now: Date = new Date()
+  options: VerifyLicenseOptions = {}
 ): LicenseTokenVerification {
 ): LicenseTokenVerification {
-  return verifyLicenseTokenWith(embeddedKeys(), token, organizationId, now)
+  return verifyLicenseTokenWith(embeddedKeys(), token, organizationId, options)
 }
 }