Browse Source

Lock invoices and quotes once they are settled (#290)

* Lock invoices and quotes once they are settled

An invoice that has been sent or paid is a document the customer holds
and the books already count, but nothing stopped it being rewritten.

Locking is off until an org turns it on, per document type, with a
choice of when: invoices on sent or paid, quotes on sent or accepted. It
freezes what the document says it is owed and nothing else, so payments,
status changes, sending and attachments keep working and a locked
invoice can always still be paid.

Owners and admins can reopen a single document from its own page, which
is recorded in the audit log. Without that route the first genuinely
wrong locked invoice has no fix and locking gets switched off for good.

Invoices did not record being sent, so sentAt is added and stamped by
both the email and the share link.

* Show the lock as soon as it engages, and re-lock on a resend

Sending an invoice by email or handing over a share link now refreshes
the page, so the banner appears immediately instead of on the next
reload.

An unlock is permission to correct one version of a document, so sending
it again spends it: the customer holds the corrected copy and it earns
the same lock. sentAt therefore records the latest send rather than the
first. Under the paid trigger the unlock still stands, since the invoice
was already paid when it was reopened and nothing new happened to it.

Sending an invoice that is still pending now offers to mark it
completed, rather than locking it as work the board says is unfinished.

* Offer no save on a locked document

Marking an invoice completed after sending went through the dirty status
setter, so a status updateServiceStatus had already stored showed as
unsaved, and the save it invited was refused by the lock that the same
send had just applied. It now sets the status without dirtying the form.

A locked document also disables its own form rather than letting someone
retype a line and meet the refusal on save, and marking dirty is a no-op
while locked so the five-second autosave cannot fire into a refusal.

* Close every route around the lock, not just the front door

The audit of this branch found paths the lock missed and traps it left:
tire-hotel lines landing on locked invoices, the tax backfill retotaling
them in bulk, releasing toggles (unmark paid, quote back to draft,
deleting the settling payment) standing in for the admin unlock, the
technician API answering a locked job with a retryable 500, and quote
share links never counting as sending. Quotes also gain sentAt so an
unlock is spent on re-issue, a deposit on a zero-total draft no longer
counts as settled, and both editors drop their queued autosave the
moment the lock engages.
Bernt Christian Egeland 1 month ago
parent
commit
0f1fdd03ba
79 changed files with 3408 additions and 116 deletions
  1. 27 0
      messages/de/documentLock.json
  2. 5 1
      messages/de/service.json
  3. 16 1
      messages/de/settings.json
  4. 27 0
      messages/en/documentLock.json
  5. 5 1
      messages/en/service.json
  6. 16 1
      messages/en/settings.json
  7. 27 0
      messages/es/documentLock.json
  8. 5 1
      messages/es/service.json
  9. 16 1
      messages/es/settings.json
  10. 27 0
      messages/fr/documentLock.json
  11. 5 1
      messages/fr/service.json
  12. 16 1
      messages/fr/settings.json
  13. 27 0
      messages/it/documentLock.json
  14. 5 1
      messages/it/service.json
  15. 16 1
      messages/it/settings.json
  16. 27 0
      messages/lt/documentLock.json
  17. 5 1
      messages/lt/service.json
  18. 16 1
      messages/lt/settings.json
  19. 27 0
      messages/nb/documentLock.json
  20. 5 1
      messages/nb/service.json
  21. 16 1
      messages/nb/settings.json
  22. 27 0
      messages/nl/documentLock.json
  23. 5 1
      messages/nl/service.json
  24. 16 1
      messages/nl/settings.json
  25. 27 0
      messages/pl/documentLock.json
  26. 5 1
      messages/pl/service.json
  27. 16 1
      messages/pl/settings.json
  28. 27 0
      messages/pt-BR/documentLock.json
  29. 5 1
      messages/pt-BR/service.json
  30. 16 1
      messages/pt-BR/settings.json
  31. 27 0
      messages/ru/documentLock.json
  32. 5 1
      messages/ru/service.json
  33. 16 1
      messages/ru/settings.json
  34. 27 0
      messages/tr/documentLock.json
  35. 5 1
      messages/tr/service.json
  36. 16 1
      messages/tr/settings.json
  37. 24 0
      prisma/migrations/20260831190000_document_edit_lock/migration.sql
  38. 21 0
      prisma/schema.prisma
  39. 3 0
      src/__tests__/features/counter-sales.test.ts
  40. 101 0
      src/__tests__/features/quotes/locked-quote-autosave.test.ts
  41. 164 0
      src/__tests__/features/settings/document-lock-unlock.test.ts
  42. 143 0
      src/__tests__/features/settings/tax-backfill-respects-lock.test.ts
  43. 98 0
      src/__tests__/features/tire-hotel/tire-lines-respect-lock.test.ts
  44. 4 0
      src/__tests__/features/workorders/add-part.test.ts
  45. 532 0
      src/__tests__/features/workorders/document-lock-enforcement.test.ts
  46. 124 0
      src/__tests__/features/workorders/locked-invoice-autosave.test.ts
  47. 402 0
      src/__tests__/lib/document-lock.test.ts
  48. 64 0
      src/__tests__/lib/with-api-auth-document-lock.test.ts
  49. 3 0
      src/__tests__/multitenancy/quote-inspection-isolation.test.ts
  50. 4 0
      src/__tests__/multitenancy/service-record-isolation.test.ts
  51. 4 0
      src/__tests__/multitenancy/vehicle-service-isolation.test.ts
  52. 6 0
      src/app/(authenticated)/quotes/[id]/page.tsx
  53. 114 1
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  54. 4 0
      src/app/api/v1/tech/jobs/[id]/labor/route.ts
  55. 112 0
      src/components/document-lock-banner.tsx
  56. 6 7
      src/features/email/Actions/emailActions.ts
  57. 33 1
      src/features/payments/Actions/paymentActions.ts
  58. 17 2
      src/features/quotes/Actions/quoteActions.ts
  59. 5 0
      src/features/quotes/Actions/quoteShareActions.ts
  60. 59 23
      src/features/quotes/Components/QuotePageClient.tsx
  61. 10 1
      src/features/quotes/Components/QuoteShareDialog.tsx
  62. 18 1
      src/features/quotes/Components/useQuoteFormState.ts
  63. 11 3
      src/features/quotes/Schema/quoteSchema.ts
  64. 28 2
      src/features/settings/Actions/applyTaxRateToExisting.ts
  65. 106 0
      src/features/settings/Actions/documentLockActions.ts
  66. 9 0
      src/features/settings/Schema/settingsSchema.ts
  67. 11 0
      src/features/sms/Actions/smsActions.ts
  68. 4 0
      src/features/tire-hotel/Actions/tireJobActions.ts
  69. 32 1
      src/features/vehicles/Actions/serviceActions.ts
  70. 12 1
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  71. 119 49
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  72. 6 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  73. 10 0
      src/features/vehicles/Components/service-page/service-page-types.ts
  74. 31 0
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  75. 6 0
      src/features/vehicles/Lib/addPart.ts
  76. 2 0
      src/i18n/request.ts
  77. 123 0
      src/lib/document-lock.server.ts
  78. 238 0
      src/lib/document-lock.ts
  79. 9 0
      src/lib/with-api-auth.ts

+ 27 - 0
messages/de/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/de/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "BEZAHLT",
     "paidBadge": "BEZAHLT",
     "entityLabel": "Rechnung",
     "entityLabel": "Rechnung",
     "emailSubject": "Zahlungsbestätigung",
     "emailSubject": "Zahlungsbestätigung",
-    "statusEmailSubject": "Aktualisierung des Servicestatus"
+    "statusEmailSubject": "Aktualisierung des Servicestatus",
+    "markCompletedTitle": "Diese Rechnung als abgeschlossen markieren?",
+    "markCompletedDescription": "Sie ist beim Kunden, gilt aber weiterhin als nicht fertig und bleibt daher auf der Auftragstafel stehen.",
+    "markCompletedConfirm": "Als abgeschlossen markieren",
+    "lockedFieldsetLabel": "Gesperrte Rechnung, Bearbeitung deaktiviert"
   },
   },
   "payments": {
   "payments": {
     "title": "Zahlungen",
     "title": "Zahlungen",

+ 16 - 1
messages/de/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Kundennummern vergeben?",
     "assignCustomerNumbersConfirmTitle": "Kundennummern vergeben?",
     "assignCustomerNumbersConfirmDescription": "Die {count} Kunden ohne Nummer erhalten fortlaufende Nummern in der Reihenfolge ihrer Erstellung, beginnend nach der höchsten vorhandenen Nummer. Kunden mit Nummer bleiben unverändert. Nummern können danach bearbeitet werden.",
     "assignCustomerNumbersConfirmDescription": "Die {count} Kunden ohne Nummer erhalten fortlaufende Nummern in der Reihenfolge ihrer Erstellung, beginnend nach der höchsten vorhandenen Nummer. Kunden mit Nummer bleiben unverändert. Nummern können danach bearbeitet werden.",
     "layoutColorsHint": "Farben, Schriften und der Briefkopf-Stil liegen unter Vorlagen.",
     "layoutColorsHint": "Farben, Schriften und der Briefkopf-Stil liegen unter Vorlagen.",
-    "goToTemplates": "Vorlagen öffnen"
+    "goToTemplates": "Vorlagen öffnen",
+    "lockTitle": "Abgeschlossene Dokumente sperren",
+    "lockDescription": "Verhindert Änderungen an einer Rechnung oder einem Angebot, sobald es abgeschlossen ist.",
+    "lockWhatItDoes": "Gesperrt werden: Teile, Arbeit, Mengen, Preise, Rabatt, Steuer, Belegnummer und Datum. Auch das Löschen des Dokuments wird verhindert.",
+    "lockWhatItAllows": "Weiterhin möglich: Zahlungen erfassen, Online-Kartenzahlung, Statusänderungen, Versand und erneuter Versand, Anhänge und interne Notizen. Eine gesperrte Rechnung kann immer bezahlt werden.",
+    "lockUnlockNote": "Inhaber und Administratoren können ein einzelnes Dokument auf dessen eigener Seite entsperren, wenn wirklich etwas korrigiert werden muss. Jede Entsperrung wird im Audit-Log festgehalten.",
+    "lockInvoicesLabel": "Rechnungen sperren",
+    "lockTriggerSent": "Sobald die Rechnung versendet ist",
+    "lockTriggerPaid": "Sobald die Rechnung vollständig bezahlt ist",
+    "lockTriggerSentHint": "Sperrt, sobald die Rechnung per E-Mail versendet oder ein Freigabelink erstellt wird. Zuvor versendete Rechnungen sind nicht betroffen.",
+    "lockTriggerPaidHint": "Sperrt, sobald der volle Betrag beglichen oder die Rechnung manuell als bezahlt markiert ist. Teilweise bezahlte Rechnungen bleiben bearbeitbar.",
+    "lockQuotesLabel": "Angebote sperren",
+    "quoteLockTriggerSent": "Sobald das Angebot versendet ist",
+    "quoteLockTriggerAccepted": "Sobald das Angebot angenommen ist",
+    "quoteLockTriggerSentHint": "Sperrt, sobald das Angebot zum Kunden geht. Nur wählen, wenn Sie Angebote nie nachverhandeln.",
+    "quoteLockTriggerAcceptedHint": "Sperrt, sobald der Kunde das Angebot angenommen hat oder es in einen Auftrag umgewandelt wurde. Versendete Angebote bleiben bearbeitbar."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",

+ 27 - 0
messages/en/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/en/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "PAID",
     "paidBadge": "PAID",
     "entityLabel": "Invoice",
     "entityLabel": "Invoice",
     "emailSubject": "Payment Confirmation",
     "emailSubject": "Payment Confirmation",
-    "statusEmailSubject": "Service Status Update"
+    "statusEmailSubject": "Service Status Update",
+    "markCompletedTitle": "Mark this invoice as completed?",
+    "markCompletedDescription": "It has gone to the customer but is still marked as not finished, so it will keep showing on the work board.",
+    "markCompletedConfirm": "Mark completed",
+    "lockedFieldsetLabel": "Locked invoice, editing disabled"
   },
   },
   "payments": {
   "payments": {
     "title": "Payments",
     "title": "Payments",

+ 16 - 1
messages/en/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Assign customer numbers?",
     "assignCustomerNumbersConfirmTitle": "Assign customer numbers?",
     "assignCustomerNumbersConfirmDescription": "This will give sequential numbers to the {count} customers that have no number yet, in the order they were created, continuing after your highest existing number. Customers that already have a number are not changed. Numbers can be edited afterwards.",
     "assignCustomerNumbersConfirmDescription": "This will give sequential numbers to the {count} customers that have no number yet, in the order they were created, continuing after your highest existing number. Customers that already have a number are not changed. Numbers can be edited afterwards.",
     "layoutColorsHint": "Colors, fonts and the header style live in Templates.",
     "layoutColorsHint": "Colors, fonts and the header style live in Templates.",
-    "goToTemplates": "Open Templates"
+    "goToTemplates": "Open Templates",
+    "lockTitle": "Locking finished documents",
+    "lockDescription": "Stop an invoice or quote being changed once it is settled.",
+    "lockWhatItDoes": "What it freezes: parts, labour, quantities, prices, discount, tax, the document number and its date. Deleting the document is blocked too.",
+    "lockWhatItAllows": "What still works: recording payments, online card payments, status changes, sending and re-sending, attachments and internal notes. A locked invoice can always still be paid.",
+    "lockUnlockNote": "Owners and admins can unlock a single document from its own page when something genuinely needs correcting. Every unlock is recorded in the audit log.",
+    "lockInvoicesLabel": "Lock invoices",
+    "lockTriggerSent": "Once the invoice is sent",
+    "lockTriggerPaid": "Once the invoice is paid in full",
+    "lockTriggerSentHint": "Locks as soon as the invoice is emailed or a share link is created. Invoices sent before you turned this on are not affected.",
+    "lockTriggerPaidHint": "Locks once the full amount is settled, or the invoice is marked paid by hand. A part-paid invoice stays editable.",
+    "lockQuotesLabel": "Lock quotes",
+    "quoteLockTriggerSent": "Once the quote is sent",
+    "quoteLockTriggerAccepted": "Once the quote is accepted",
+    "quoteLockTriggerSentHint": "Locks as soon as the quote goes to the customer. Choose this only if you never revise a quote during negotiation.",
+    "quoteLockTriggerAcceptedHint": "Locks once the customer has accepted the quote, or it has been converted to a job. Sent quotes stay editable."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Drag to reorder. Use the width toggle to place sections side-by-side. Consecutive half-width sections (L/R) will render in two columns.",
     "helpText": "Drag to reorder. Use the width toggle to place sections side-by-side. Consecutive half-width sections (L/R) will render in two columns.",

+ 27 - 0
messages/es/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/es/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "PAGADO",
     "paidBadge": "PAGADO",
     "entityLabel": "Factura",
     "entityLabel": "Factura",
     "emailSubject": "Confirmación de Pago",
     "emailSubject": "Confirmación de Pago",
-    "statusEmailSubject": "Actualización del estado del servicio"
+    "statusEmailSubject": "Actualización del estado del servicio",
+    "markCompletedTitle": "¿Marcar esta factura como completada?",
+    "markCompletedDescription": "Ya se ha enviado al cliente, pero sigue marcada como no terminada, así que continuará apareciendo en el tablero.",
+    "markCompletedConfirm": "Marcar como completada",
+    "lockedFieldsetLabel": "Factura bloqueada, edición desactivada"
   },
   },
   "payments": {
   "payments": {
     "title": "Pagos",
     "title": "Pagos",

+ 16 - 1
messages/es/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "¿Asignar números de cliente?",
     "assignCustomerNumbersConfirmTitle": "¿Asignar números de cliente?",
     "assignCustomerNumbersConfirmDescription": "Los {count} clientes sin número recibirán números secuenciales en el orden en que se crearon, continuando después del número existente más alto. Los clientes que ya tienen número no cambian. Los números se pueden editar después.",
     "assignCustomerNumbersConfirmDescription": "Los {count} clientes sin número recibirán números secuenciales en el orden en que se crearon, continuando después del número existente más alto. Los clientes que ya tienen número no cambian. Los números se pueden editar después.",
     "layoutColorsHint": "Los colores, las fuentes y el estilo de encabezado están en Plantillas.",
     "layoutColorsHint": "Los colores, las fuentes y el estilo de encabezado están en Plantillas.",
-    "goToTemplates": "Abrir Plantillas"
+    "goToTemplates": "Abrir Plantillas",
+    "lockTitle": "Bloqueo de documentos finalizados",
+    "lockDescription": "Evita que una factura o un presupuesto se modifique una vez cerrado.",
+    "lockWhatItDoes": "Se bloquea: piezas, mano de obra, cantidades, precios, descuento, impuestos, número de documento y fecha. También se impide eliminarlo.",
+    "lockWhatItAllows": "Sigue funcionando: registrar pagos, pago con tarjeta en línea, cambios de estado, envío y reenvío, adjuntos y notas internas. Una factura bloqueada siempre se puede pagar.",
+    "lockUnlockNote": "Los propietarios y administradores pueden desbloquear un documento concreto desde su propia página cuando algo deba corregirse. Cada desbloqueo queda en el registro de auditoría.",
+    "lockInvoicesLabel": "Bloquear facturas",
+    "lockTriggerSent": "Cuando se envía la factura",
+    "lockTriggerPaid": "Cuando la factura está pagada por completo",
+    "lockTriggerSentHint": "Se bloquea en cuanto la factura se envía por correo o se crea un enlace para compartir. Las facturas enviadas antes de activarlo no se ven afectadas.",
+    "lockTriggerPaidHint": "Se bloquea cuando se salda el importe total o la factura se marca como pagada a mano. Una factura pagada en parte sigue siendo editable.",
+    "lockQuotesLabel": "Bloquear presupuestos",
+    "quoteLockTriggerSent": "Cuando se envía el presupuesto",
+    "quoteLockTriggerAccepted": "Cuando se acepta el presupuesto",
+    "quoteLockTriggerSentHint": "Se bloquea en cuanto el presupuesto llega al cliente. Elija esta opción solo si nunca revisa un presupuesto durante la negociación.",
+    "quoteLockTriggerAcceptedHint": "Se bloquea cuando el cliente acepta el presupuesto o este se convierte en un trabajo. Los presupuestos enviados siguen siendo editables."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Arrastra para reordenar. Usa el selector de ancho para colocar secciones lado a lado. Las secciones consecutivas de medio ancho (I/D) se mostrarán en dos columnas.",
     "helpText": "Arrastra para reordenar. Usa el selector de ancho para colocar secciones lado a lado. Las secciones consecutivas de medio ancho (I/D) se mostrarán en dos columnas.",

+ 27 - 0
messages/fr/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/fr/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "PAYÉ",
     "paidBadge": "PAYÉ",
     "entityLabel": "Facture",
     "entityLabel": "Facture",
     "emailSubject": "Confirmation de Paiement",
     "emailSubject": "Confirmation de Paiement",
-    "statusEmailSubject": "Mise à jour du statut du service"
+    "statusEmailSubject": "Mise à jour du statut du service",
+    "markCompletedTitle": "Marquer cette facture comme terminée ?",
+    "markCompletedDescription": "Elle est partie chez le client mais reste marquée comme non terminée, elle continuera donc d'apparaître sur le tableau.",
+    "markCompletedConfirm": "Marquer comme terminée",
+    "lockedFieldsetLabel": "Facture verrouillée, modification désactivée"
   },
   },
   "payments": {
   "payments": {
     "title": "Paiements",
     "title": "Paiements",

+ 16 - 1
messages/fr/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Attribuer des numéros clients ?",
     "assignCustomerNumbersConfirmTitle": "Attribuer des numéros clients ?",
     "assignCustomerNumbersConfirmDescription": "Les {count} clients sans numéro recevront des numéros séquentiels dans leur ordre de création, à la suite du numéro existant le plus élevé. Les clients ayant déjà un numéro ne sont pas modifiés. Les numéros restent modifiables ensuite.",
     "assignCustomerNumbersConfirmDescription": "Les {count} clients sans numéro recevront des numéros séquentiels dans leur ordre de création, à la suite du numéro existant le plus élevé. Les clients ayant déjà un numéro ne sont pas modifiés. Les numéros restent modifiables ensuite.",
     "layoutColorsHint": "Les couleurs, les polices et le style d’en-tête sont dans Modèles.",
     "layoutColorsHint": "Les couleurs, les polices et le style d’en-tête sont dans Modèles.",
-    "goToTemplates": "Ouvrir Modèles"
+    "goToTemplates": "Ouvrir Modèles",
+    "lockTitle": "Verrouillage des documents finalisés",
+    "lockDescription": "Empêche la modification d'une facture ou d'un devis une fois qu'il est réglé.",
+    "lockWhatItDoes": "Ce qui est verrouillé : pièces, main-d’œuvre, quantités, prix, remise, taxe, numéro et date du document. La suppression est également bloquée.",
+    "lockWhatItAllows": "Ce qui fonctionne encore : enregistrer des paiements, le paiement en ligne, les changements de statut, l’envoi et le renvoi, les pièces jointes et les notes internes. Une facture verrouillée reste toujours payable.",
+    "lockUnlockNote": "Les propriétaires et administrateurs peuvent déverrouiller un document depuis sa propre page lorsqu’une correction est réellement nécessaire. Chaque déverrouillage est consigné dans le journal d’audit.",
+    "lockInvoicesLabel": "Verrouiller les factures",
+    "lockTriggerSent": "Une fois la facture envoyée",
+    "lockTriggerPaid": "Une fois la facture intégralement payée",
+    "lockTriggerSentHint": "Verrouille dès que la facture est envoyée par e-mail ou qu'un lien de partage est créé. Les factures envoyées avant l'activation ne sont pas concernées.",
+    "lockTriggerPaidHint": "Verrouille une fois le montant total réglé ou la facture marquée payée à la main. Une facture partiellement payée reste modifiable.",
+    "lockQuotesLabel": "Verrouiller les devis",
+    "quoteLockTriggerSent": "Une fois le devis envoyé",
+    "quoteLockTriggerAccepted": "Une fois le devis accepté",
+    "quoteLockTriggerSentHint": "Verrouille dès que le devis part chez le client. À choisir uniquement si vous ne révisez jamais un devis en cours de négociation.",
+    "quoteLockTriggerAcceptedHint": "Verrouille une fois le devis accepté par le client ou converti en intervention. Les devis envoyés restent modifiables."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Glissez pour réorganiser. Utilisez le sélecteur de largeur pour placer les sections côte à côte. Les sections consécutives en demi-largeur (G/D) s'afficheront en deux colonnes.",
     "helpText": "Glissez pour réorganiser. Utilisez le sélecteur de largeur pour placer les sections côte à côte. Les sections consécutives en demi-largeur (G/D) s'afficheront en deux colonnes.",

+ 27 - 0
messages/it/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/it/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "PAGATO",
     "paidBadge": "PAGATO",
     "entityLabel": "Fattura",
     "entityLabel": "Fattura",
     "emailSubject": "Conferma Pagamento",
     "emailSubject": "Conferma Pagamento",
-    "statusEmailSubject": "Aggiornamento dello stato del servizio"
+    "statusEmailSubject": "Aggiornamento dello stato del servizio",
+    "markCompletedTitle": "Contrassegnare questa fattura come completata?",
+    "markCompletedDescription": "È stata inviata al cliente ma risulta ancora non conclusa, quindi resterà visibile sulla lavagna.",
+    "markCompletedConfirm": "Segna come completata",
+    "lockedFieldsetLabel": "Fattura bloccata, modifica disattivata"
   },
   },
   "payments": {
   "payments": {
     "title": "Pagamenti",
     "title": "Pagamenti",

+ 16 - 1
messages/it/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Assegnare i numeri cliente?",
     "assignCustomerNumbersConfirmTitle": "Assegnare i numeri cliente?",
     "assignCustomerNumbersConfirmDescription": "I {count} clienti senza numero riceveranno numeri sequenziali nell'ordine di creazione, proseguendo dal numero esistente più alto. I clienti che hanno già un numero non vengono modificati. I numeri restano modificabili.",
     "assignCustomerNumbersConfirmDescription": "I {count} clienti senza numero riceveranno numeri sequenziali nell'ordine di creazione, proseguendo dal numero esistente più alto. I clienti che hanno già un numero non vengono modificati. I numeri restano modificabili.",
     "layoutColorsHint": "Colori, caratteri e stile dell’intestazione sono in Modelli.",
     "layoutColorsHint": "Colori, caratteri e stile dell’intestazione sono in Modelli.",
-    "goToTemplates": "Apri Modelli"
+    "goToTemplates": "Apri Modelli",
+    "lockTitle": "Blocco dei documenti conclusi",
+    "lockDescription": "Impedisce la modifica di una fattura o di un preventivo una volta chiuso.",
+    "lockWhatItDoes": "Viene bloccato: ricambi, manodopera, quantità, prezzi, sconto, imposta, numero e data del documento. Anche l’eliminazione è impedita.",
+    "lockWhatItAllows": "Continua a funzionare: registrare pagamenti, pagamento online con carta, cambi di stato, invio e reinvio, allegati e note interne. Una fattura bloccata resta sempre pagabile.",
+    "lockUnlockNote": "Titolari e amministratori possono sbloccare un singolo documento dalla sua pagina quando serve davvero una correzione. Ogni sblocco viene registrato nel log di audit.",
+    "lockInvoicesLabel": "Blocca le fatture",
+    "lockTriggerSent": "Quando la fattura è inviata",
+    "lockTriggerPaid": "Quando la fattura è saldata per intero",
+    "lockTriggerSentHint": "Si blocca appena la fattura viene inviata via email o viene creato un link di condivisione. Le fatture inviate prima dell’attivazione non sono interessate.",
+    "lockTriggerPaidHint": "Si blocca quando l’intero importo è saldato o la fattura è segnata come pagata a mano. Una fattura pagata in parte resta modificabile.",
+    "lockQuotesLabel": "Blocca i preventivi",
+    "quoteLockTriggerSent": "Quando il preventivo è inviato",
+    "quoteLockTriggerAccepted": "Quando il preventivo è accettato",
+    "quoteLockTriggerSentHint": "Si blocca appena il preventivo raggiunge il cliente. Da scegliere solo se non rivedi mai un preventivo durante la trattativa.",
+    "quoteLockTriggerAcceptedHint": "Si blocca quando il cliente accetta il preventivo o questo viene convertito in un lavoro. I preventivi inviati restano modificabili."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Trascina per riordinare. Usa il selettore di larghezza per posizionare le sezioni affiancate. Le sezioni consecutive a mezza larghezza (S/D) verranno visualizzate in due colonne.",
     "helpText": "Trascina per riordinare. Usa il selettore di larghezza per posizionare le sezioni affiancate. Le sezioni consecutive a mezza larghezza (S/D) verranno visualizzate in due colonne.",

+ 27 - 0
messages/lt/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/lt/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "APMOKĖTA",
     "paidBadge": "APMOKĖTA",
     "entityLabel": "Sąskaita",
     "entityLabel": "Sąskaita",
     "emailSubject": "Mokėjimo patvirtinimas",
     "emailSubject": "Mokėjimo patvirtinimas",
-    "statusEmailSubject": "Paslaugos būsenos atnaujinimas"
+    "statusEmailSubject": "Paslaugos būsenos atnaujinimas",
+    "markCompletedTitle": "Pažymėti šią sąskaitą kaip užbaigtą?",
+    "markCompletedDescription": "Ji jau išsiųsta klientui, bet vis dar pažymėta kaip nebaigta, todėl liks matoma darbų lentoje.",
+    "markCompletedConfirm": "Žymėti užbaigta",
+    "lockedFieldsetLabel": "Užrakinta sąskaita, redagavimas išjungtas"
   },
   },
   "payments": {
   "payments": {
     "title": "Mokėjimai",
     "title": "Mokėjimai",

+ 16 - 1
messages/lt/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Priskirti klientų numerius?",
     "assignCustomerNumbersConfirmTitle": "Priskirti klientų numerius?",
     "assignCustomerNumbersConfirmDescription": "{count} klientams be numerio bus priskirti eilės numeriai sukūrimo tvarka, tęsiant nuo didžiausio esamo numerio. Klientai, jau turintys numerį, nekeičiami. Numerius galima redaguoti vėliau.",
     "assignCustomerNumbersConfirmDescription": "{count} klientams be numerio bus priskirti eilės numeriai sukūrimo tvarka, tęsiant nuo didžiausio esamo numerio. Klientai, jau turintys numerį, nekeičiami. Numerius galima redaguoti vėliau.",
     "layoutColorsHint": "Spalvos, šriftai ir antraštės stilius yra skiltyje Šablonai.",
     "layoutColorsHint": "Spalvos, šriftai ir antraštės stilius yra skiltyje Šablonai.",
-    "goToTemplates": "Atverti šablonus"
+    "goToTemplates": "Atverti šablonus",
+    "lockTitle": "Užbaigtų dokumentų užrakinimas",
+    "lockDescription": "Neleidžia keisti sąskaitos ar pasiūlymo, kai jis jau užbaigtas.",
+    "lockWhatItDoes": "Užrakinama: dalys, darbas, kiekiai, kainos, nuolaida, mokestis, dokumento numeris ir data. Dokumento ištrynimas taip pat blokuojamas.",
+    "lockWhatItAllows": "Toliau veikia: mokėjimų registravimas, mokėjimas kortele internetu, būsenos keitimas, siuntimas ir pakartotinis siuntimas, priedai ir vidinės pastabos. Užrakintą sąskaitą visada galima apmokėti.",
+    "lockUnlockNote": "Savininkai ir administratoriai gali atrakinti atskirą dokumentą jo paties puslapyje, kai kažką tikrai reikia pataisyti. Kiekvienas atrakinimas įrašomas į audito žurnalą.",
+    "lockInvoicesLabel": "Užrakinti sąskaitas",
+    "lockTriggerSent": "Kai sąskaita išsiųsta",
+    "lockTriggerPaid": "Kai sąskaita visiškai apmokėta",
+    "lockTriggerSentHint": "Užrakina, kai tik sąskaita išsiunčiama el. paštu arba sukuriama bendrinimo nuoroda. Anksčiau išsiųstoms sąskaitoms tai negalioja.",
+    "lockTriggerPaidHint": "Užrakina, kai padengiama visa suma arba sąskaita rankiniu būdu pažymima apmokėta. Iš dalies apmokėtą sąskaitą galima redaguoti.",
+    "lockQuotesLabel": "Užrakinti pasiūlymus",
+    "quoteLockTriggerSent": "Kai pasiūlymas išsiųstas",
+    "quoteLockTriggerAccepted": "Kai pasiūlymas priimtas",
+    "quoteLockTriggerSentHint": "Užrakina, kai tik pasiūlymas išsiunčiamas klientui. Rinkitės tik jei niekada nekeičiate pasiūlymo derybų metu.",
+    "quoteLockTriggerAcceptedHint": "Užrakina, kai klientas priima pasiūlymą arba jis paverčiamas darbu. Išsiųstus pasiūlymus galima redaguoti."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Vilkite, kad pertvarkytumėte. Naudokite pločio perjungiklį, kad išdėstytumėte sekcijas greta. Iš eilės einančios pusės pločio sekcijos (K/D) bus vaizduojamos dviejuose stulpeliuose.",
     "helpText": "Vilkite, kad pertvarkytumėte. Naudokite pločio perjungiklį, kad išdėstytumėte sekcijas greta. Iš eilės einančios pusės pločio sekcijos (K/D) bus vaizduojamos dviejuose stulpeliuose.",

+ 27 - 0
messages/nb/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "Denne fakturaen er låst fordi den er sendt",
+      "paid": "Denne fakturaen er låst fordi den er betalt"
+    },
+    "quote": {
+      "sent": "Dette tilbudet er låst fordi det er sendt",
+      "accepted": "Dette tilbudet er låst fordi det er akseptert"
+    }
+  },
+  "lockedBody": "Deler, arbeid, priser, rabatt, avgift, nummer og dato kan ikke lenger endres. Betalinger, statusendringer, sending og vedlegg virker fortsatt.",
+  "lockedAdminHint": "Som eier eller administrator kan du låse opp dokumentet hvis noe virkelig må rettes. Opplåsingen loggføres.",
+  "lockedMemberHint": "Be en eier eller administrator låse opp dokumentet hvis noe må rettes.",
+  "unlock": "Lås opp for redigering",
+  "relock": "Lås igjen",
+  "unlockConfirmTitle": "Låse opp dette dokumentet?",
+  "unlockConfirmDescription": {
+    "invoice": "Dokumentet kan redigeres igjen, og endringen loggføres med navnet ditt. Bruk heller kreditnota der regelverket krever det.",
+    "quote": "Dokumentet kan redigeres igjen, og endringen loggføres med navnet ditt."
+  },
+  "unlocked": "Låst opp for redigering",
+  "relocked": "Låst igjen",
+  "reopenedTitle": "Dette dokumentet er låst opp for redigering",
+  "reopenedBody": "Det kan redigeres til det låses igjen.",
+  "failed": "Kunne ikke endre låsen"
+}

+ 5 - 1
messages/nb/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "BETALT",
     "paidBadge": "BETALT",
     "entityLabel": "Faktura",
     "entityLabel": "Faktura",
     "emailSubject": "Betalingsbekreftelse",
     "emailSubject": "Betalingsbekreftelse",
-    "statusEmailSubject": "Oppdatering av servicestatus"
+    "statusEmailSubject": "Oppdatering av servicestatus",
+    "markCompletedTitle": "Merke denne fakturaen som fullført?",
+    "markCompletedDescription": "Den er sendt til kunden, men står fortsatt som ikke ferdig, så den blir liggende på tavla.",
+    "markCompletedConfirm": "Merk som fullført",
+    "lockedFieldsetLabel": "Låst faktura, redigering er deaktivert"
   },
   },
   "payments": {
   "payments": {
     "title": "Betalinger",
     "title": "Betalinger",

+ 16 - 1
messages/nb/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Tildele kundenumre?",
     "assignCustomerNumbersConfirmTitle": "Tildele kundenumre?",
     "assignCustomerNumbersConfirmDescription": "De {count} kundene uten nummer får fortløpende numre i opprettelsesrekkefølge, fortsettende etter det høyeste eksisterende nummeret. Kunder som allerede har nummer endres ikke. Numrene kan redigeres etterpå.",
     "assignCustomerNumbersConfirmDescription": "De {count} kundene uten nummer får fortløpende numre i opprettelsesrekkefølge, fortsettende etter det høyeste eksisterende nummeret. Kunder som allerede har nummer endres ikke. Numrene kan redigeres etterpå.",
     "layoutColorsHint": "Farger, skrifter og brevhodestilen ligger under Maler.",
     "layoutColorsHint": "Farger, skrifter og brevhodestilen ligger under Maler.",
-    "goToTemplates": "Åpne Maler"
+    "goToTemplates": "Åpne Maler",
+    "lockTitle": "Låsing av ferdige dokumenter",
+    "lockDescription": "Hindre at en faktura eller et tilbud endres etter at det er gjort opp.",
+    "lockWhatItDoes": "Dette låses: deler, arbeid, antall, priser, rabatt, avgift, dokumentnummer og dato. Sletting av dokumentet blokkeres også.",
+    "lockWhatItAllows": "Dette virker fortsatt: registrere betalinger, kortbetaling på nett, statusendringer, sending og ny sending, vedlegg og interne notater. En låst faktura kan alltid betales.",
+    "lockUnlockNote": "Eiere og administratorer kan låse opp ett enkelt dokument fra dokumentets egen side når noe virkelig må rettes. Hver opplåsing loggføres.",
+    "lockInvoicesLabel": "Lås fakturaer",
+    "lockTriggerSent": "Når fakturaen er sendt",
+    "lockTriggerPaid": "Når fakturaen er betalt i sin helhet",
+    "lockTriggerSentHint": "Låses så snart fakturaen sendes på e-post eller det opprettes en delingslenke. Fakturaer sendt før du slo dette på berøres ikke.",
+    "lockTriggerPaidHint": "Låses når hele beløpet er gjort opp, eller fakturaen merkes betalt manuelt. Delvis betalte fakturaer kan fortsatt endres.",
+    "lockQuotesLabel": "Lås tilbud",
+    "quoteLockTriggerSent": "Når tilbudet er sendt",
+    "quoteLockTriggerAccepted": "Når tilbudet er akseptert",
+    "quoteLockTriggerSentHint": "Låses så snart tilbudet går til kunden. Velg dette bare hvis du aldri endrer et tilbud underveis i forhandlingen.",
+    "quoteLockTriggerAcceptedHint": "Låses når kunden har akseptert tilbudet, eller det er gjort om til en jobb. Sendte tilbud kan fortsatt endres."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Dra for å sortere. Bruk bredde-veksler for å plassere seksjoner side om side. Påfølgende halvbredde-seksjoner (V/H) vises i to kolonner.",
     "helpText": "Dra for å sortere. Bruk bredde-veksler for å plassere seksjoner side om side. Påfølgende halvbredde-seksjoner (V/H) vises i to kolonner.",

+ 27 - 0
messages/nl/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/nl/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "BETAALD",
     "paidBadge": "BETAALD",
     "entityLabel": "Factuur",
     "entityLabel": "Factuur",
     "emailSubject": "Betalingsbevestiging",
     "emailSubject": "Betalingsbevestiging",
-    "statusEmailSubject": "Update servicestatus"
+    "statusEmailSubject": "Update servicestatus",
+    "markCompletedTitle": "Deze factuur als afgerond markeren?",
+    "markCompletedDescription": "Hij is naar de klant gegaan maar staat nog als niet afgerond, dus hij blijft op het bord staan.",
+    "markCompletedConfirm": "Als afgerond markeren",
+    "lockedFieldsetLabel": "Vergrendelde factuur, bewerken uitgeschakeld"
   },
   },
   "payments": {
   "payments": {
     "title": "Betalingen",
     "title": "Betalingen",

+ 16 - 1
messages/nl/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Klantnummers toewijzen?",
     "assignCustomerNumbersConfirmTitle": "Klantnummers toewijzen?",
     "assignCustomerNumbersConfirmDescription": "De {count} klanten zonder nummer krijgen opeenvolgende nummers in volgorde van aanmaak, doorlopend na het hoogste bestaande nummer. Klanten met een nummer worden niet gewijzigd. Nummers zijn achteraf te bewerken.",
     "assignCustomerNumbersConfirmDescription": "De {count} klanten zonder nummer krijgen opeenvolgende nummers in volgorde van aanmaak, doorlopend na het hoogste bestaande nummer. Klanten met een nummer worden niet gewijzigd. Nummers zijn achteraf te bewerken.",
     "layoutColorsHint": "Kleuren, lettertypen en de briefhoofdstijl staan bij Sjablonen.",
     "layoutColorsHint": "Kleuren, lettertypen en de briefhoofdstijl staan bij Sjablonen.",
-    "goToTemplates": "Sjablonen openen"
+    "goToTemplates": "Sjablonen openen",
+    "lockTitle": "Afgeronde documenten vergrendelen",
+    "lockDescription": "Voorkomt dat een factuur of offerte nog wijzigt zodra die is afgerond.",
+    "lockWhatItDoes": "Wat wordt vergrendeld: onderdelen, arbeid, aantallen, prijzen, korting, btw, documentnummer en datum. Verwijderen wordt ook geblokkeerd.",
+    "lockWhatItAllows": "Wat blijft werken: betalingen vastleggen, online kaartbetaling, statuswijzigingen, versturen en opnieuw versturen, bijlagen en interne notities. Een vergrendelde factuur blijft altijd betaalbaar.",
+    "lockUnlockNote": "Eigenaren en beheerders kunnen één document ontgrendelen vanaf de pagina van dat document wanneer er echt iets gecorrigeerd moet worden. Elke ontgrendeling komt in het auditlogboek.",
+    "lockInvoicesLabel": "Facturen vergrendelen",
+    "lockTriggerSent": "Zodra de factuur is verstuurd",
+    "lockTriggerPaid": "Zodra de factuur volledig is betaald",
+    "lockTriggerSentHint": "Vergrendelt zodra de factuur is gemaild of er een deellink is aangemaakt. Facturen die eerder zijn verstuurd blijven ongemoeid.",
+    "lockTriggerPaidHint": "Vergrendelt zodra het volledige bedrag is voldaan of de factuur handmatig op betaald is gezet. Een deels betaalde factuur blijft bewerkbaar.",
+    "lockQuotesLabel": "Offertes vergrendelen",
+    "quoteLockTriggerSent": "Zodra de offerte is verstuurd",
+    "quoteLockTriggerAccepted": "Zodra de offerte is geaccepteerd",
+    "quoteLockTriggerSentHint": "Vergrendelt zodra de offerte naar de klant gaat. Kies dit alleen als je een offerte nooit aanpast tijdens de onderhandeling.",
+    "quoteLockTriggerAcceptedHint": "Vergrendelt zodra de klant de offerte heeft geaccepteerd of die is omgezet naar een opdracht. Verstuurde offertes blijven bewerkbaar."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Sleep om te herordenen. Gebruik de breedte-schakelaar om secties naast elkaar te plaatsen. Opeenvolgende halve breedte secties (L/R) worden in twee kolommen weergegeven.",
     "helpText": "Sleep om te herordenen. Gebruik de breedte-schakelaar om secties naast elkaar te plaatsen. Opeenvolgende halve breedte secties (L/R) worden in twee kolommen weergegeven.",

+ 27 - 0
messages/pl/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/pl/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "ZAPŁACONE",
     "paidBadge": "ZAPŁACONE",
     "entityLabel": "Faktura",
     "entityLabel": "Faktura",
     "emailSubject": "Potwierdzenie Płatności",
     "emailSubject": "Potwierdzenie Płatności",
-    "statusEmailSubject": "Aktualizacja statusu serwisu"
+    "statusEmailSubject": "Aktualizacja statusu serwisu",
+    "markCompletedTitle": "Oznaczyć tę fakturę jako zakończoną?",
+    "markCompletedDescription": "Trafiła już do klienta, ale nadal jest oznaczona jako niezakończona, więc pozostanie na tablicy.",
+    "markCompletedConfirm": "Oznacz jako zakończoną",
+    "lockedFieldsetLabel": "Faktura zablokowana, edycja wyłączona"
   },
   },
   "payments": {
   "payments": {
     "title": "Płatności",
     "title": "Płatności",

+ 16 - 1
messages/pl/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Przydzielić numery klientów?",
     "assignCustomerNumbersConfirmTitle": "Przydzielić numery klientów?",
     "assignCustomerNumbersConfirmDescription": "{count} klientów bez numeru otrzyma kolejne numery w kolejności utworzenia, kontynuując od najwyższego istniejącego numeru. Klienci z numerem nie zostaną zmienieni. Numery można później edytować.",
     "assignCustomerNumbersConfirmDescription": "{count} klientów bez numeru otrzyma kolejne numery w kolejności utworzenia, kontynuując od najwyższego istniejącego numeru. Klienci z numerem nie zostaną zmienieni. Numery można później edytować.",
     "layoutColorsHint": "Kolory, czcionki i styl nagłówka są w Szablonach.",
     "layoutColorsHint": "Kolory, czcionki i styl nagłówka są w Szablonach.",
-    "goToTemplates": "Otwórz Szablony"
+    "goToTemplates": "Otwórz Szablony",
+    "lockTitle": "Blokowanie zakończonych dokumentów",
+    "lockDescription": "Zapobiega zmianom faktury lub oferty po jej zamknięciu.",
+    "lockWhatItDoes": "Blokowane są: części, robocizna, ilości, ceny, rabat, podatek, numer i data dokumentu. Usunięcie dokumentu również jest zablokowane.",
+    "lockWhatItAllows": "Nadal działa: rejestrowanie płatności, płatność kartą online, zmiany statusu, wysyłka i ponowna wysyłka, załączniki i notatki wewnętrzne. Zablokowaną fakturę zawsze można opłacić.",
+    "lockUnlockNote": "Właściciele i administratorzy mogą odblokować pojedynczy dokument z jego własnej strony, gdy naprawdę wymaga poprawki. Każde odblokowanie trafia do dziennika audytu.",
+    "lockInvoicesLabel": "Blokuj faktury",
+    "lockTriggerSent": "Po wysłaniu faktury",
+    "lockTriggerPaid": "Po opłaceniu faktury w całości",
+    "lockTriggerSentHint": "Blokuje, gdy faktura zostanie wysłana e-mailem lub powstanie link do udostępnienia. Faktury wysłane wcześniej nie są objęte.",
+    "lockTriggerPaidHint": "Blokuje po uregulowaniu pełnej kwoty lub ręcznym oznaczeniu faktury jako opłaconej. Faktura opłacona częściowo pozostaje edytowalna.",
+    "lockQuotesLabel": "Blokuj oferty",
+    "quoteLockTriggerSent": "Po wysłaniu oferty",
+    "quoteLockTriggerAccepted": "Po zaakceptowaniu oferty",
+    "quoteLockTriggerSentHint": "Blokuje, gdy oferta trafi do klienta. Wybierz tylko wtedy, gdy nigdy nie zmieniasz oferty w trakcie negocjacji.",
+    "quoteLockTriggerAcceptedHint": "Blokuje po zaakceptowaniu oferty przez klienta lub przekształceniu jej w zlecenie. Wysłane oferty pozostają edytowalne."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Przeciągnij, aby zmienić kolejność. Użyj przełącznika szerokości, aby umieścić sekcje obok siebie. Kolejne sekcje o połowie szerokości (L/P) będą wyświetlane w dwóch kolumnach.",
     "helpText": "Przeciągnij, aby zmienić kolejność. Użyj przełącznika szerokości, aby umieścić sekcje obok siebie. Kolejne sekcje o połowie szerokości (L/P) będą wyświetlane w dwóch kolumnach.",

+ 27 - 0
messages/pt-BR/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/pt-BR/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "PAGO",
     "paidBadge": "PAGO",
     "entityLabel": "Fatura",
     "entityLabel": "Fatura",
     "emailSubject": "Confirmação de Pagamento",
     "emailSubject": "Confirmação de Pagamento",
-    "statusEmailSubject": "Atualização do status do serviço"
+    "statusEmailSubject": "Atualização do status do serviço",
+    "markCompletedTitle": "Marcar esta fatura como concluída?",
+    "markCompletedDescription": "Ela já foi enviada ao cliente, mas continua marcada como não finalizada, então seguirá aparecendo no quadro.",
+    "markCompletedConfirm": "Marcar como concluída",
+    "lockedFieldsetLabel": "Fatura bloqueada, edição desativada"
   },
   },
   "payments": {
   "payments": {
     "title": "Pagamentos",
     "title": "Pagamentos",

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

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Atribuir números de cliente?",
     "assignCustomerNumbersConfirmTitle": "Atribuir números de cliente?",
     "assignCustomerNumbersConfirmDescription": "Os {count} clientes sem número receberão números sequenciais na ordem de criação, continuando após o maior número existente. Clientes que já têm número não são alterados. Os números podem ser editados depois.",
     "assignCustomerNumbersConfirmDescription": "Os {count} clientes sem número receberão números sequenciais na ordem de criação, continuando após o maior número existente. Clientes que já têm número não são alterados. Os números podem ser editados depois.",
     "layoutColorsHint": "Cores, fontes e o estilo do timbre ficam em Modelos.",
     "layoutColorsHint": "Cores, fontes e o estilo do timbre ficam em Modelos.",
-    "goToTemplates": "Abrir Modelos"
+    "goToTemplates": "Abrir Modelos",
+    "lockTitle": "Bloqueio de documentos concluídos",
+    "lockDescription": "Impede que uma fatura ou orçamento seja alterado depois de encerrado.",
+    "lockWhatItDoes": "O que é bloqueado: peças, mão de obra, quantidades, preços, desconto, imposto, número do documento e data. Excluir o documento também é bloqueado.",
+    "lockWhatItAllows": "O que continua funcionando: registrar pagamentos, pagamento online com cartão, mudanças de status, envio e reenvio, anexos e notas internas. Uma fatura bloqueada sempre pode ser paga.",
+    "lockUnlockNote": "Proprietários e administradores podem desbloquear um documento específico na página dele quando algo realmente precisa ser corrigido. Todo desbloqueio fica no log de auditoria.",
+    "lockInvoicesLabel": "Bloquear faturas",
+    "lockTriggerSent": "Assim que a fatura for enviada",
+    "lockTriggerPaid": "Assim que a fatura for paga integralmente",
+    "lockTriggerSentHint": "Bloqueia assim que a fatura é enviada por e-mail ou um link de compartilhamento é criado. Faturas enviadas antes de ativar isso não são afetadas.",
+    "lockTriggerPaidHint": "Bloqueia quando o valor total é quitado ou a fatura é marcada como paga manualmente. Uma fatura paga em parte continua editável.",
+    "lockQuotesLabel": "Bloquear orçamentos",
+    "quoteLockTriggerSent": "Assim que o orçamento for enviado",
+    "quoteLockTriggerAccepted": "Assim que o orçamento for aceito",
+    "quoteLockTriggerSentHint": "Bloqueia assim que o orçamento chega ao cliente. Escolha apenas se você nunca revisa um orçamento durante a negociação.",
+    "quoteLockTriggerAcceptedHint": "Bloqueia quando o cliente aceita o orçamento ou ele é convertido em serviço. Orçamentos enviados continuam editáveis."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Arraste para reordenar. Use o alternador de largura para posicionar seções lado a lado. Seções consecutivas de meia largura (E/D) serão exibidas em duas colunas.",
     "helpText": "Arraste para reordenar. Use o alternador de largura para posicionar seções lado a lado. Seções consecutivas de meia largura (E/D) serão exibidas em duas colunas.",

+ 27 - 0
messages/ru/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/ru/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "ОПЛАЧЕНО",
     "paidBadge": "ОПЛАЧЕНО",
     "entityLabel": "Счёт",
     "entityLabel": "Счёт",
     "emailSubject": "Подтверждение оплаты",
     "emailSubject": "Подтверждение оплаты",
-    "statusEmailSubject": "Обновление статуса обслуживания"
+    "statusEmailSubject": "Обновление статуса обслуживания",
+    "markCompletedTitle": "Отметить этот счёт как завершённый?",
+    "markCompletedDescription": "Он уже отправлен клиенту, но всё ещё помечен как незавершённый, поэтому останется на доске работ.",
+    "markCompletedConfirm": "Отметить завершённым",
+    "lockedFieldsetLabel": "Счёт заблокирован, редактирование отключено"
   },
   },
   "payments": {
   "payments": {
     "title": "Платежи",
     "title": "Платежи",

+ 16 - 1
messages/ru/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Присвоить номера клиентам?",
     "assignCustomerNumbersConfirmTitle": "Присвоить номера клиентам?",
     "assignCustomerNumbersConfirmDescription": "{count} клиентов без номера получат последовательные номера в порядке создания, продолжая после наибольшего существующего номера. Клиенты с номером не изменяются. Номера можно редактировать позже.",
     "assignCustomerNumbersConfirmDescription": "{count} клиентов без номера получат последовательные номера в порядке создания, продолжая после наибольшего существующего номера. Клиенты с номером не изменяются. Номера можно редактировать позже.",
     "layoutColorsHint": "Цвета, шрифты и стиль бланка находятся в Шаблонах.",
     "layoutColorsHint": "Цвета, шрифты и стиль бланка находятся в Шаблонах.",
-    "goToTemplates": "Открыть шаблоны"
+    "goToTemplates": "Открыть шаблоны",
+    "lockTitle": "Блокировка завершённых документов",
+    "lockDescription": "Запрещает изменять счёт или предложение после того, как он закрыт.",
+    "lockWhatItDoes": "Блокируются: запчасти, работы, количество, цены, скидка, налог, номер документа и дата. Удаление документа также запрещено.",
+    "lockWhatItAllows": "Продолжает работать: внесение оплат, онлайн-оплата картой, смена статуса, отправка и повторная отправка, вложения и внутренние заметки. Заблокированный счёт всегда можно оплатить.",
+    "lockUnlockNote": "Владельцы и администраторы могут разблокировать отдельный документ на его странице, если что-то действительно нужно исправить. Каждая разблокировка записывается в журнал аудита.",
+    "lockInvoicesLabel": "Блокировать счета",
+    "lockTriggerSent": "После отправки счёта",
+    "lockTriggerPaid": "После полной оплаты счёта",
+    "lockTriggerSentHint": "Блокирует, как только счёт отправлен по почте или создана ссылка для доступа. Счета, отправленные до включения, не затрагиваются.",
+    "lockTriggerPaidHint": "Блокирует после погашения всей суммы или отметки об оплате вручную. Частично оплаченный счёт остаётся редактируемым.",
+    "lockQuotesLabel": "Блокировать предложения",
+    "quoteLockTriggerSent": "После отправки предложения",
+    "quoteLockTriggerAccepted": "После принятия предложения",
+    "quoteLockTriggerSentHint": "Блокирует, как только предложение уходит клиенту. Выбирайте, только если вы никогда не пересматриваете предложение в ходе переговоров.",
+    "quoteLockTriggerAcceptedHint": "Блокирует после принятия предложения клиентом или его преобразования в заказ. Отправленные предложения остаются редактируемыми."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Перетаскивайте для изменения порядка. Используйте переключатель ширины для размещения разделов бок о бок. Последовательные разделы половинной ширины (Л/П) будут отображаться в два столбца.",
     "helpText": "Перетаскивайте для изменения порядка. Используйте переключатель ширины для размещения разделов бок о бок. Последовательные разделы половинной ширины (Л/П) будут отображаться в два столбца.",

+ 27 - 0
messages/tr/documentLock.json

@@ -0,0 +1,27 @@
+{
+  "lockedTitle": {
+    "invoice": {
+      "sent": "This invoice is locked because it has been sent",
+      "paid": "This invoice is locked because it has been paid"
+    },
+    "quote": {
+      "sent": "This quote is locked because it has been sent",
+      "accepted": "This quote is locked because it has been accepted"
+    }
+  },
+  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
+  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
+  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
+  "unlock": "Unlock for editing",
+  "relock": "Lock again",
+  "unlockConfirmTitle": "Unlock this document?",
+  "unlockConfirmDescription": {
+    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
+    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+  },
+  "unlocked": "Unlocked for editing",
+  "relocked": "Locked again",
+  "reopenedTitle": "This document has been unlocked for editing",
+  "reopenedBody": "It stays editable until it is locked again.",
+  "failed": "Could not change the lock"
+}

+ 5 - 1
messages/tr/service.json

@@ -185,7 +185,11 @@
     "paidBadge": "ÖDENDİ",
     "paidBadge": "ÖDENDİ",
     "entityLabel": "Fatura",
     "entityLabel": "Fatura",
     "emailSubject": "Ödeme Onayı",
     "emailSubject": "Ödeme Onayı",
-    "statusEmailSubject": "Servis durumu güncellemesi"
+    "statusEmailSubject": "Servis durumu güncellemesi",
+    "markCompletedTitle": "Bu fatura tamamlandı olarak işaretlensin mi?",
+    "markCompletedDescription": "Müşteriye gitti ama hâlâ bitmedi olarak işaretli, bu yüzden iş panosunda görünmeye devam edecek.",
+    "markCompletedConfirm": "Tamamlandı olarak işaretle",
+    "lockedFieldsetLabel": "Fatura kilitli, düzenleme kapalı"
   },
   },
   "payments": {
   "payments": {
     "title": "Ödemeler",
     "title": "Ödemeler",

+ 16 - 1
messages/tr/settings.json

@@ -557,7 +557,22 @@
     "assignCustomerNumbersConfirmTitle": "Müşteri numaraları atansın mı?",
     "assignCustomerNumbersConfirmTitle": "Müşteri numaraları atansın mı?",
     "assignCustomerNumbersConfirmDescription": "Numarası olmayan {count} müşteriye, oluşturulma sırasına göre ve mevcut en yüksek numaradan devam eden sıralı numaralar verilecek. Numarası olan müşteriler değişmez. Numaralar sonradan düzenlenebilir.",
     "assignCustomerNumbersConfirmDescription": "Numarası olmayan {count} müşteriye, oluşturulma sırasına göre ve mevcut en yüksek numaradan devam eden sıralı numaralar verilecek. Numarası olan müşteriler değişmez. Numaralar sonradan düzenlenebilir.",
     "layoutColorsHint": "Renkler, yazı tipleri ve antet stili Şablonlar’da bulunur.",
     "layoutColorsHint": "Renkler, yazı tipleri ve antet stili Şablonlar’da bulunur.",
-    "goToTemplates": "Şablonları aç"
+    "goToTemplates": "Şablonları aç",
+    "lockTitle": "Tamamlanan belgeleri kilitleme",
+    "lockDescription": "Kapanmış bir faturanın veya teklifin değiştirilmesini engeller.",
+    "lockWhatItDoes": "Kilitlenenler: parçalar, işçilik, miktarlar, fiyatlar, indirim, vergi, belge numarası ve tarihi. Belgeyi silmek de engellenir.",
+    "lockWhatItAllows": "Çalışmaya devam edenler: ödeme kaydetme, çevrim içi kartla ödeme, durum değişiklikleri, gönderme ve yeniden gönderme, ekler ve dahili notlar. Kilitli bir fatura her zaman ödenebilir.",
+    "lockUnlockNote": "Sahipler ve yöneticiler, gerçekten düzeltilmesi gereken bir şey olduğunda belgenin kendi sayfasından tek bir belgenin kilidini açabilir. Her kilit açma denetim günlüğüne yazılır.",
+    "lockInvoicesLabel": "Faturaları kilitle",
+    "lockTriggerSent": "Fatura gönderildiğinde",
+    "lockTriggerPaid": "Fatura tamamen ödendiğinde",
+    "lockTriggerSentHint": "Fatura e-postayla gönderilir gönderilmez veya paylaşım bağlantısı oluşturulduğunda kilitlenir. Bunu açmadan önce gönderilen faturalar etkilenmez.",
+    "lockTriggerPaidHint": "Tutarın tamamı kapandığında veya fatura elle ödendi olarak işaretlendiğinde kilitlenir. Kısmen ödenmiş fatura düzenlenebilir kalır.",
+    "lockQuotesLabel": "Teklifleri kilitle",
+    "quoteLockTriggerSent": "Teklif gönderildiğinde",
+    "quoteLockTriggerAccepted": "Teklif kabul edildiğinde",
+    "quoteLockTriggerSentHint": "Teklif müşteriye ulaşır ulaşmaz kilitlenir. Yalnızca pazarlık sırasında teklifi hiç revize etmiyorsanız seçin.",
+    "quoteLockTriggerAcceptedHint": "Müşteri teklifi kabul ettiğinde veya teklif bir işe dönüştürüldüğünde kilitlenir. Gönderilmiş teklifler düzenlenebilir kalır."
   },
   },
   "layoutEditor": {
   "layoutEditor": {
     "helpText": "Sıralamak için sürükleyin. Bölümleri yan yana yerleştirmek için genişlik değiştiriciyi kullanın. Ardışık yarım genişlik bölümleri (S/S) iki sütun halinde görüntülenir.",
     "helpText": "Sıralamak için sürükleyin. Bölümleri yan yana yerleştirmek için genişlik değiştiriciyi kullanın. Ardışık yarım genişlik bölümleri (S/S) iki sütun halinde görüntülenir.",

+ 24 - 0
prisma/migrations/20260831190000_document_edit_lock/migration.sql

@@ -0,0 +1,24 @@
+-- Edit lock for invoices and quotes.
+--
+-- Additive and nullable throughout. Locking is off until an org turns it on,
+-- so every existing document stays exactly as editable as it is today and
+-- nothing needs backfilling.
+--
+-- sentAt is deliberately separate from sharedAt: sharedAt tracks the public
+-- link and is cleared when the link is revoked, whereas having sent an invoice
+-- is not undone by withdrawing the link. Existing invoices leave it null, so
+-- an org that locks on "sent" locks only what it sends from now on. That is
+-- the safe direction to be wrong in: nothing already issued is frozen out of
+-- reach on the day the setting is switched on.
+
+ALTER TABLE "service_records" ADD COLUMN "sentAt" TIMESTAMP(3);
+ALTER TABLE "service_records" ADD COLUMN "editUnlockedAt" TIMESTAMP(3);
+ALTER TABLE "service_records" ADD COLUMN "editUnlockedById" TEXT;
+
+-- Quotes get the same sentAt so an admin unlock is spent when the quote is
+-- issued again, exactly as it is for invoices. Without it an unlocked quote
+-- would stay editable forever, because its status does not change when a
+-- corrected copy is re-sent and re-accepted.
+ALTER TABLE "quotes" ADD COLUMN "sentAt" TIMESTAMP(3);
+ALTER TABLE "quotes" ADD COLUMN "editUnlockedAt" TIMESTAMP(3);
+ALTER TABLE "quotes" ADD COLUMN "editUnlockedById" TEXT;

+ 21 - 0
prisma/schema.prisma

@@ -247,6 +247,17 @@ model ServiceRecord {
   createdAt    DateTime  @default(now())
   createdAt    DateTime  @default(now())
   updatedAt    DateTime  @updatedAt
   updatedAt    DateTime  @updatedAt
 
 
+  /// First time this invoice reached the customer, by email or share link.
+  /// Distinct from sharedAt, which tracks only the link and is cleared when
+  /// the link is revoked; sending is not undone by withdrawing the link.
+  sentAt DateTime?
+
+  /// Set when an owner or admin deliberately reopens a locked document.
+  /// Nullable rather than a boolean so the audit trail can show when, and the
+  /// screen can say the document was reopened rather than never locked.
+  editUnlockedAt   DateTime?
+  editUnlockedById String?
+
   /// The stored set this job is about, when it came out of the tire hotel.
   /// The stored set this job is about, when it came out of the tire hotel.
   /// Without it a tire-change work order says nothing about which tires, and
   /// Without it a tire-change work order says nothing about which tires, and
   /// the technician has to go and ask.
   /// the technician has to go and ask.
@@ -739,6 +750,16 @@ model Quote {
   createdAt       DateTime  @default(now())
   createdAt       DateTime  @default(now())
   updatedAt       DateTime  @updatedAt
   updatedAt       DateTime  @updatedAt
 
 
+  /// Most recent time this quote reached the customer, by email or share
+  /// link. Exists so an unlock is spent when the quote is issued again; the
+  /// status alone cannot show that, because re-sending an accepted quote does
+  /// not change it.
+  sentAt DateTime?
+
+  /// See ServiceRecord.editUnlockedAt.
+  editUnlockedAt   DateTime?
+  editUnlockedById String?
+
   userId String
   userId String
   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
   user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
 
 

+ 3 - 0
src/__tests__/features/counter-sales.test.ts

@@ -68,6 +68,9 @@ function setupCreateEnvironment() {
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
+  // Locking is read before any edit to an invoice or quote; no settings
+  // rows means locking is off, which is how these tests expect to run.
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
 })
 })
 
 
 describe('createServiceRecord — counter sale (no vehicle)', () => {
 describe('createServiceRecord — counter sale (no vehicle)', () => {

+ 101 - 0
src/__tests__/features/quotes/locked-quote-autosave.test.ts

@@ -0,0 +1,101 @@
+/**
+ * A locked quote must not queue a save it cannot make.
+ *
+ * The quote page has the same five-second autosave as the invoice page, and
+ * the same trap was open here after the invoice side closed it: a quote that
+ * locks (sent or accepted, depending on the trigger) kept marking itself
+ * dirty and firing autosaves the server refuses.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/components/glass-modal', () => ({ useGlassModal: () => ({ open: vi.fn() }) }))
+vi.mock('@/components/confirm-dialog', () => ({ useConfirm: () => vi.fn(async () => true) }))
+vi.mock('@/features/quotes/Actions/quoteActions', () => ({
+  updateQuote: vi.fn(),
+  deleteQuote: vi.fn(),
+  convertQuoteToServiceRecord: vi.fn(),
+}))
+vi.mock('@/features/quotes/Actions/quoteResponseActions', () => ({
+  acknowledgeQuoteResponse: vi.fn(),
+}))
+
+import { useQuoteFormState } from '@/features/quotes/Components/useQuoteFormState'
+
+const AUTOSAVE_DELAY = 5000
+
+const quote = {
+  id: 'quote-1',
+  status: 'accepted',
+  customer: null,
+  vehicle: null,
+  partItems: [],
+  laborItems: [],
+  taxRate: 0,
+  taxInclusive: false,
+  discountType: 'none',
+  discountValue: 0,
+  description: '',
+  notes: '',
+  validUntil: null,
+} as any
+
+function renderForm(locked: boolean) {
+  return renderHook(
+    (props: { locked: boolean }) =>
+      useQuoteFormState({
+        quote,
+        currencyCode: 'USD',
+        defaultTaxRate: 0,
+        taxEnabled: true,
+        defaultLaborRate: 0,
+        locked: props.locked,
+        t: (key: string) => key,
+      }),
+    { initialProps: { locked } }
+  )
+}
+
+beforeEach(() => vi.useFakeTimers())
+afterEach(() => vi.useRealTimers())
+
+describe('an editable quote', () => {
+  it('marks the form dirty on an edit', () => {
+    const { result } = renderForm(false)
+
+    act(() => result.current.markDirty())
+
+    expect(result.current.hasUnsavedChanges).toBe(true)
+  })
+})
+
+describe('a locked quote', () => {
+  it('never reports unsaved changes', () => {
+    const { result } = renderForm(true)
+
+    act(() => result.current.markDirty())
+
+    expect(result.current.hasUnsavedChanges).toBe(false)
+  })
+})
+
+describe('the lock engaging mid-session', () => {
+  it('clears the queued state once the page learns of the lock', () => {
+    // Edit, then send the quote: the refresh flips the locked prop, and the
+    // pending save and "Unsaved changes" must go with it.
+    const { result, rerender } = renderForm(false)
+
+    act(() => result.current.markDirty())
+    expect(result.current.hasUnsavedChanges).toBe(true)
+
+    rerender({ locked: true })
+
+    expect(result.current.hasUnsavedChanges).toBe(false)
+
+    act(() => void vi.advanceTimersByTime(AUTOSAVE_DELAY * 2))
+    expect(result.current.hasUnsavedChanges).toBe(false)
+  })
+})

+ 164 - 0
src/__tests__/features/settings/document-lock-unlock.test.ts

@@ -0,0 +1,164 @@
+/**
+ * Who may reopen a locked invoice or quote.
+ *
+ * This is the one route around the lock, so the check that guards it is worth
+ * testing from both sides: an ordinary member must not get through, and an
+ * owner or admin must not be blocked. The audit entry matters too, because an
+ * unlock nobody can trace is indistinguishable from the lock never having been
+ * there.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
+    quote: { findFirst: vi.fn(), update: vi.fn() },
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import {
+  setInvoiceEditUnlocked,
+  setQuoteEditUnlocked,
+} from '@/features/settings/Actions/documentLockActions'
+
+const ORG = 'org-1'
+
+function signInAs(membership: Record<string, unknown>) {
+  vi.mocked(getCachedSession).mockResolvedValue({ user: { id: 'user-1' } } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    roleId: null,
+    customRole: null,
+    ...membership,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+/** A member holding every permission, but not owner or admin. */
+const PERMISSIVE_MEMBER = {
+  role: 'member',
+  customRole: {
+    isAdmin: false,
+    permissions: [
+      { action: 'update', subject: 'services' },
+      { action: 'update', subject: 'quotes' },
+    ],
+  },
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+    id: 'rec-1',
+    vehicleId: 'veh-1',
+    invoiceNumber: 'INV-1001',
+  } as any)
+  vi.mocked(db.quote.findFirst).mockResolvedValue({ id: 'quote-1', quoteNumber: 'QT-1001' } as any)
+})
+
+describe('unlocking an invoice', () => {
+  it('is refused for a member, even one with permission to edit invoices', async () => {
+    // The permission to edit is exactly what the lock is overriding, so it
+    // cannot be the permission that grants the override.
+    signInAs(PERMISSIVE_MEMBER)
+
+    const result = await setInvoiceEditUnlocked('rec-1', true)
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/owner or admin/i)
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it('is allowed for the org owner', async () => {
+    signInAs({ role: 'owner' })
+
+    const result = await setInvoiceEditUnlocked('rec-1', true)
+
+    expect(result.success).toBe(true)
+    const data = vi.mocked(db.serviceRecord.update).mock.calls[0]?.[0]?.data as any
+    expect(data.editUnlockedAt).toBeInstanceOf(Date)
+    expect(data.editUnlockedById).toBe('user-1')
+  })
+
+  it('is allowed for an admin', async () => {
+    signInAs({ role: 'admin' })
+    expect((await setInvoiceEditUnlocked('rec-1', true)).success).toBe(true)
+  })
+
+  it('is allowed for a custom role marked as admin', async () => {
+    signInAs({ role: 'member', customRole: { isAdmin: true, permissions: [] } })
+    expect((await setInvoiceEditUnlocked('rec-1', true)).success).toBe(true)
+  })
+
+  it('clears both fields when locking again', async () => {
+    signInAs({ role: 'owner' })
+
+    const result = await setInvoiceEditUnlocked('rec-1', false)
+
+    expect(result.success).toBe(true)
+    const data = vi.mocked(db.serviceRecord.update).mock.calls[0]?.[0]?.data as any
+    expect(data.editUnlockedAt).toBeNull()
+    // Left over, this would still name whoever last unlocked it.
+    expect(data.editUnlockedById).toBeNull()
+  })
+
+  it('refuses a record belonging to another org', async () => {
+    signInAs({ role: 'owner' })
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null as any)
+
+    const result = await setInvoiceEditUnlocked('someone-elses', true)
+
+    expect(result.success).toBe(false)
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it('records the unlock and the re-lock as separate audited actions', async () => {
+    signInAs({ role: 'owner' })
+    const unlocked = await setInvoiceEditUnlocked('rec-1', true)
+    const relocked = await setInvoiceEditUnlocked('rec-1', false)
+    expect(unlocked.data?.unlocked).toBe(true)
+    expect(relocked.data?.unlocked).toBe(false)
+    expect(unlocked.data?.reference).toBe('INV-1001')
+  })
+})
+
+describe('unlocking a quote', () => {
+  it('is refused for a member', async () => {
+    signInAs(PERMISSIVE_MEMBER)
+
+    const result = await setQuoteEditUnlocked('quote-1', true)
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/owner or admin/i)
+    expect(db.quote.update).not.toHaveBeenCalled()
+  })
+
+  it('is allowed for the org owner', async () => {
+    signInAs({ role: 'owner' })
+
+    const result = await setQuoteEditUnlocked('quote-1', true)
+
+    expect(result.success).toBe(true)
+    const data = vi.mocked(db.quote.update).mock.calls[0]?.[0]?.data as any
+    expect(data.editUnlockedAt).toBeInstanceOf(Date)
+    expect(data.editUnlockedById).toBe('user-1')
+  })
+
+  it('refuses a quote belonging to another org', async () => {
+    signInAs({ role: 'owner' })
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null as any)
+
+    expect((await setQuoteEditUnlocked('someone-elses', true)).success).toBe(false)
+    expect(db.quote.update).not.toHaveBeenCalled()
+  })
+})

+ 143 - 0
src/__tests__/features/settings/tax-backfill-respects-lock.test.ts

@@ -0,0 +1,143 @@
+/**
+ * The bulk tax backfill must not rewrite locked documents.
+ *
+ * It retotals every zero-tax record in the org, which is exactly the rewrite
+ * the lock exists to prevent when the customer already holds the document.
+ * Locked records are skipped and counted, not silently changed; unlocking
+ * them (or turning locking off) makes them eligible again.
+ */
+
+import { it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    serviceRecord: { findMany: vi.fn(), update: vi.fn() },
+    quote: { findMany: vi.fn(), update: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { applyTaxRateToExisting } from '@/features/settings/Actions/applyTaxRateToExisting'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+const ORG = 'org-1'
+
+const SETTINGS: Record<string, string> = {
+  [SETTING_KEYS.TAX_ENABLED]: 'true',
+  [SETTING_KEYS.DEFAULT_TAX_RATE]: '25',
+  [SETTING_KEYS.INVOICE_LOCK_ENABLED]: 'true',
+  [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: 'paid',
+  [SETTING_KEYS.QUOTE_LOCK_ENABLED]: 'true',
+  [SETTING_KEYS.QUOTE_LOCK_TRIGGER]: 'accepted',
+}
+
+const MONEY = { subtotal: 100, discountAmount: 0, taxInclusive: false }
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({ user: { id: 'user-1' } } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+  // Every settings read comes through the same table; answer each query with
+  // the rows whose keys it asked for.
+  vi.mocked(db.appSetting.findMany).mockImplementation((async (args: any) => {
+    const wanted: string[] = args?.where?.key?.in ?? Object.keys(SETTINGS)
+    return wanted
+      .filter((key) => key in SETTINGS)
+      .map((key) => ({ key, value: SETTINGS[key] }))
+  }) as any)
+  vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db))
+})
+
+it('updates open documents and skips locked ones, reporting both', async () => {
+  vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+    {
+      id: 'rec-open',
+      ...MONEY,
+      sentAt: null,
+      manuallyPaid: false,
+      totalAmount: 100,
+      cost: 0,
+      editUnlockedAt: null,
+      payments: [],
+    },
+    {
+      id: 'rec-locked',
+      ...MONEY,
+      sentAt: null,
+      manuallyPaid: true,
+      totalAmount: 100,
+      cost: 0,
+      editUnlockedAt: null,
+      payments: [],
+    },
+  ] as any)
+  vi.mocked(db.quote.findMany).mockResolvedValue([
+    { id: 'quote-open', ...MONEY, status: 'draft', sentAt: null, editUnlockedAt: null },
+    { id: 'quote-locked', ...MONEY, status: 'accepted', sentAt: null, editUnlockedAt: null },
+  ] as any)
+
+  const result = await applyTaxRateToExisting()
+
+  expect(result.success).toBe(true)
+  expect(result.data).toMatchObject({
+    serviceRecordsUpdated: 1,
+    quotesUpdated: 1,
+    serviceRecordsSkipped: 1,
+    quotesSkipped: 1,
+  })
+
+  const recordIds = vi.mocked(db.serviceRecord.update).mock.calls.map((c) => c[0].where.id)
+  expect(recordIds).toEqual(['rec-open'])
+  const quoteIds = vi.mocked(db.quote.update).mock.calls.map((c) => c[0].where.id)
+  expect(quoteIds).toEqual(['quote-open'])
+})
+
+it('touches everything while locking is off', async () => {
+  SETTINGS[SETTING_KEYS.INVOICE_LOCK_ENABLED] = 'false'
+  SETTINGS[SETTING_KEYS.QUOTE_LOCK_ENABLED] = 'false'
+  try {
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        id: 'rec-paid',
+        ...MONEY,
+        sentAt: null,
+        manuallyPaid: true,
+        totalAmount: 100,
+        cost: 0,
+        editUnlockedAt: null,
+        payments: [],
+      },
+    ] as any)
+    vi.mocked(db.quote.findMany).mockResolvedValue([
+      { id: 'quote-accepted', ...MONEY, status: 'accepted', sentAt: null, editUnlockedAt: null },
+    ] as any)
+
+    const result = await applyTaxRateToExisting()
+
+    expect(result.data).toMatchObject({
+      serviceRecordsUpdated: 1,
+      quotesUpdated: 1,
+      serviceRecordsSkipped: 0,
+      quotesSkipped: 0,
+    })
+  } finally {
+    SETTINGS[SETTING_KEYS.INVOICE_LOCK_ENABLED] = 'true'
+    SETTINGS[SETTING_KEYS.QUOTE_LOCK_ENABLED] = 'true'
+  }
+})

+ 98 - 0
src/__tests__/features/tire-hotel/tire-lines-respect-lock.test.ts

@@ -0,0 +1,98 @@
+/**
+ * The tire hotel writes money onto existing work orders, so it goes through
+ * the same lock as every other edit. This flow was the one money-mutation
+ * path the lock originally missed: "add to work order" appends part and labor
+ * lines and retotals the job without passing through updateServiceRecord.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/features/tire-hotel/Lib/tireHotelSettings', () => ({
+  requireTireHotel: vi.fn(),
+  isTireHotelEnabled: vi.fn(),
+}))
+vi.mock('@/features/tire-hotel/Lib/serverMessages', () => ({
+  invoiceLineWords: vi.fn(),
+  jobNoteWords: vi.fn(),
+  seasonNames: vi.fn(async () => ({})),
+  treatmentNames: vi.fn(async () => ({})),
+}))
+vi.mock('@/features/tire-hotel/Lib/addTireLine', () => ({ addTireLineToRecord: vi.fn() }))
+vi.mock('@/features/vehicles/Lib/createDraftRecord', () => ({ createDraftRecord: vi.fn() }))
+vi.mock('@/features/vehicles/Lib/retotalServiceRecord', () => ({
+  retotalServiceRecord: vi.fn(),
+}))
+vi.mock('@/features/inventory/Lib/onInventoryChanged', () => ({ onInventoryChanged: vi.fn() }))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
+    tireSet: { findFirst: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { addTireSetToWorkOrder } from '@/features/tire-hotel/Actions/tireJobActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+const ORG = 'org-1'
+
+const LOCKED_PAID_INVOICE = {
+  sentAt: null,
+  manuallyPaid: true,
+  totalAmount: 500,
+  cost: 0,
+  editUnlockedAt: null,
+  payments: [{ amount: 500 }],
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({ user: { id: 'user-1' } } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+})
+
+describe('adding a stored set to an existing work order', () => {
+  it('is refused while the invoice is locked, before anything is written', async () => {
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: SETTING_KEYS.INVOICE_LOCK_ENABLED, value: 'true' },
+      { key: SETTING_KEYS.INVOICE_LOCK_TRIGGER, value: 'paid' },
+    ] as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(LOCKED_PAID_INVOICE as any)
+
+    const result = await addTireSetToWorkOrder({ tireSetId: 'set-1', serviceRecordId: 'rec-1' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    // No lines, no retotal, no stock movement.
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+
+  it('gets past the lock while locking is off', async () => {
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(LOCKED_PAID_INVOICE as any)
+    // The set lookup fails afterwards, which is fine: the point is only that
+    // the lock was not what stopped it.
+    vi.mocked(db.tireSet.findFirst).mockResolvedValue(null as any)
+
+    const result = await addTireSetToWorkOrder({ tireSetId: 'set-1', serviceRecordId: 'rec-1' })
+
+    expect(result.error ?? '').not.toMatch(/locked/i)
+  })
+})

+ 4 - 0
src/__tests__/features/workorders/add-part.test.ts

@@ -20,6 +20,7 @@ vi.mock('@/lib/db', () => {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const db: any = {
   const db: any = {
     user: { findUnique: vi.fn() },
     user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
     servicePart: { create: vi.fn(), aggregate: vi.fn() },
     servicePart: { create: vi.fn(), aggregate: vi.fn() },
     serviceLabor: { aggregate: vi.fn() },
     serviceLabor: { aggregate: vi.fn() },
@@ -55,6 +56,9 @@ function setupAuth() {
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
+  // Locking is read before a part is added; no settings rows means locking
+  // is off, which is how these tests expect to run.
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
   // resetAllMocks clears implementations, so restore the transaction runner
   // resetAllMocks clears implementations, so restore the transaction runner
   // (invoke the callback with the mock db as the transaction client).
   // (invoke the callback with the mock db as the transaction client).
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 532 - 0
src/__tests__/features/workorders/document-lock-enforcement.test.ts

@@ -0,0 +1,532 @@
+/**
+ * Enforcement tests for the invoice and quote edit lock.
+ *
+ * The rules themselves are covered in lib/document-lock.test.ts. What is
+ * tested here is that every way of changing what a document says it is owed
+ * actually goes through them, and that the things a locked document must still
+ * be able to do are not caught by mistake.
+ *
+ * The second half matters as much as the first. A lock that also stopped
+ * payments being recorded would make a sent invoice unpayable, which is a
+ * worse failure than no locking at all.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/resolve-upload-path', () => ({
+  resolveUploadPath: vi.fn((url: string) => `/uploads/${url}`),
+}))
+vi.mock('@/lib/notification-bus', () => ({
+  notificationBus: { publish: vi.fn(), emit: vi.fn() },
+}))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    serviceRecord: {
+      findFirst: vi.fn(),
+      update: vi.fn(),
+      updateMany: vi.fn(),
+      delete: vi.fn(),
+      deleteMany: vi.fn(),
+    },
+    quote: { findFirst: vi.fn(), update: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn() },
+    servicePart: { findMany: vi.fn(), create: vi.fn() },
+    payment: { create: vi.fn(), findFirst: vi.fn(), delete: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import {
+  updateServiceRecord,
+  deleteServiceRecord,
+  updateServiceStatus,
+  toggleManuallyPaid,
+  generatePublicLink,
+} from '@/features/vehicles/Actions/serviceActions'
+import { updateQuote, deleteQuote, updateQuoteStatus } from '@/features/quotes/Actions/quoteActions'
+import { generateQuotePublicLink } from '@/features/quotes/Actions/quoteShareActions'
+import { deletePayment } from '@/features/payments/Actions/paymentActions'
+import { addPart } from '@/features/vehicles/Lib/addPart'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+const ORG = 'org-1'
+
+function signInAsOwner() {
+  vi.mocked(getCachedSession).mockResolvedValue({ user: { id: 'user-1' } } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+/** The org's lock configuration, as rows in the settings table. */
+function lockSettings(rows: Record<string, string>) {
+  vi.mocked(db.appSetting.findMany).mockResolvedValue(
+    Object.entries(rows).map(([key, value]) => ({ key, value })) as any
+  )
+}
+
+const LOCK_INVOICES_WHEN_PAID = {
+  [SETTING_KEYS.INVOICE_LOCK_ENABLED]: 'true',
+  [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: 'paid',
+}
+const LOCK_QUOTES_WHEN_ACCEPTED = {
+  [SETTING_KEYS.QUOTE_LOCK_ENABLED]: 'true',
+  [SETTING_KEYS.QUOTE_LOCK_TRIGGER]: 'accepted',
+}
+
+/**
+ * Asserts the lock let this through. The action may still fail for its own
+ * reasons against these mocks, or succeed outright and carry no error at all,
+ * so this checks only that the lock was not what stopped it.
+ */
+function expectNotBlockedByLock(result: { error?: string }) {
+  expect(result.error ?? '').not.toMatch(/locked/i)
+}
+
+const PAID_INVOICE = {
+  sentAt: null,
+  manuallyPaid: true,
+  totalAmount: 500,
+  cost: 0,
+  editUnlockedAt: null,
+  payments: [{ amount: 500 }],
+}
+const UNPAID_INVOICE = { ...PAID_INVOICE, manuallyPaid: false, payments: [] }
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(db.$transaction).mockImplementation(async (cb: any) => cb(db))
+  vi.mocked(db.servicePart.findMany).mockResolvedValue([] as any)
+  signInAsOwner()
+})
+
+describe('a locked invoice refuses edits', () => {
+  beforeEach(() => lockSettings(LOCK_INVOICES_WHEN_PAID))
+
+  it('refuses updateServiceRecord', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(PAID_INVOICE as any)
+
+    const result = await updateServiceRecord({ id: 'rec-1', title: 'Rewritten' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    // Nothing may reach the database, not even a partial write.
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+    expect(db.serviceRecord.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('refuses deleteServiceRecord', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(PAID_INVOICE as any)
+
+    const result = await deleteServiceRecord('rec-1')
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    expect(db.serviceRecord.delete).not.toHaveBeenCalled()
+    expect(db.serviceRecord.deleteMany).not.toHaveBeenCalled()
+  })
+
+  it('refuses addPart, which both the editor and the technician API come through', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(PAID_INVOICE as any)
+
+    await expect(
+      addPart({
+        organizationId: ORG,
+        userId: 'user-1',
+        input: {
+          serviceRecordId: 'rec-1',
+          name: 'Brake pads',
+          quantity: 1,
+          unitPrice: 100,
+          total: 100,
+          unitCost: 60,
+        },
+      })
+    ).rejects.toThrow(/locked/i)
+
+    expect(db.servicePart.create).not.toHaveBeenCalled()
+    // No stock may move either: a line that was refused must not leave the
+    // shelf count short.
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+
+  it('names the rule that applied, so the message can explain itself', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(PAID_INVOICE as any)
+    const result = await updateServiceRecord({ id: 'rec-1', title: 'Rewritten' })
+    expect(result.error).toContain('paid')
+  })
+})
+
+describe('an invoice that is not locked still edits', () => {
+  it('allows an edit while the invoice is unpaid', async () => {
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(UNPAID_INVOICE as any)
+
+    const result = await updateServiceRecord({ id: 'rec-1', title: 'Still a draft' })
+
+    // It gets past the lock; whatever happens next is not the lock's doing.
+    expectNotBlockedByLock(result)
+  })
+
+  it('allows an edit to a paid invoice while locking is switched off', async () => {
+    lockSettings({})
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(PAID_INVOICE as any)
+
+    const result = await updateServiceRecord({ id: 'rec-1', title: 'Corrected' })
+
+    expectNotBlockedByLock(result)
+  })
+
+  it('allows an edit once an admin has unlocked it', async () => {
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...PAID_INVOICE,
+      editUnlockedAt: new Date('2026-02-01'),
+    } as any)
+
+    const result = await updateServiceRecord({ id: 'rec-1', title: 'Corrected' })
+
+    expectNotBlockedByLock(result)
+  })
+
+  it('allows an edit to a paid invoice when the trigger is "sent" and it never was', async () => {
+    lockSettings({
+      [SETTING_KEYS.INVOICE_LOCK_ENABLED]: 'true',
+      [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: 'sent',
+    })
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(PAID_INVOICE as any)
+
+    const result = await updateServiceRecord({ id: 'rec-1', title: 'Corrected' })
+
+    expectNotBlockedByLock(result)
+  })
+})
+
+describe('a locked quote refuses edits', () => {
+  beforeEach(() => lockSettings(LOCK_QUOTES_WHEN_ACCEPTED))
+
+  it('refuses updateQuote', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      status: 'accepted',
+      editUnlockedAt: null,
+    } as any)
+
+    const result = await updateQuote({ id: 'quote-1', title: 'Rewritten' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    expect(db.quote.update).not.toHaveBeenCalled()
+  })
+
+  it('refuses deleteQuote', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      status: 'converted',
+      editUnlockedAt: null,
+    } as any)
+
+    const result = await deleteQuote('quote-1')
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    expect(db.quote.deleteMany).not.toHaveBeenCalled()
+  })
+
+  it('still allows editing a quote that has only been sent', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ status: 'sent', editUnlockedAt: null } as any)
+
+    const result = await updateQuote({ id: 'quote-1', title: 'Revised after a call' })
+
+    expectNotBlockedByLock(result)
+  })
+})
+
+describe('the settings the lock reads', () => {
+  it('asks only for the four lock keys, scoped to the org', async () => {
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(UNPAID_INVOICE as any)
+
+    await updateServiceRecord({ id: 'rec-1', title: 'x' })
+
+    const call = vi.mocked(db.appSetting.findMany).mock.calls[0]?.[0] as any
+    expect(call.where.organizationId).toBe(ORG)
+    expect(call.where.key.in).toEqual(
+      expect.arrayContaining([
+        SETTING_KEYS.INVOICE_LOCK_ENABLED,
+        SETTING_KEYS.INVOICE_LOCK_TRIGGER,
+        SETTING_KEYS.QUOTE_LOCK_ENABLED,
+        SETTING_KEYS.QUOTE_LOCK_TRIGGER,
+      ])
+    )
+  })
+
+  it('treats a record from another org as editable, leaving the caller to 404', async () => {
+    // findFirst is scoped by organizationId, so a foreign id returns null. The
+    // lock must not turn that into "locked", which would confirm that a record
+    // with that id exists somewhere.
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null as any)
+
+    const result = await updateServiceRecord({ id: 'someone-elses', title: 'x' })
+
+    expectNotBlockedByLock(result)
+    expect(result.error).toMatch(/not found/i)
+  })
+})
+
+describe('what a locked invoice must still be able to do', () => {
+  beforeEach(() => lockSettings(LOCK_INVOICES_WHEN_PAID))
+
+  it('still accepts a status change', async () => {
+    // Status is workflow, not money. Freezing it would strand a paid job as
+    // permanently in-progress on the work board.
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...PAID_INVOICE,
+      id: 'rec-1',
+      status: 'in-progress',
+      vehicleId: null,
+      vehicle: null,
+    } as any)
+
+    const result = await updateServiceStatus('rec-1', 'completed')
+
+    expectNotBlockedByLock(result)
+    expect(db.serviceRecord.update).toHaveBeenCalled()
+  })
+
+  it('still accepts being marked paid or unpaid', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...PAID_INVOICE,
+      id: 'rec-1',
+      vehicleId: null,
+    } as any)
+
+    const result = await toggleManuallyPaid('rec-1')
+
+    expectNotBlockedByLock(result)
+    expect(db.serviceRecord.update).toHaveBeenCalled()
+  })
+
+  it('still allows a share link to be created, so it can be paid online', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...PAID_INVOICE,
+      id: 'rec-1',
+      vehicleId: null,
+    } as any)
+
+    const result = await generatePublicLink('rec-1')
+
+    expectNotBlockedByLock(result)
+    expect(result.success).toBe(true)
+  })
+})
+
+describe('transitions that would release the lock', () => {
+  // The lock derives from paid status and quote status, so the toggles that
+  // move those fields are the admin-only unlock in disguise whenever the move
+  // would release the lock. Moves that keep it locked (or lock it further)
+  // stay open to everyone.
+
+  it('refuses unmarking paid when the manual mark alone holds the lock', async () => {
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...PAID_INVOICE,
+      id: 'rec-1',
+      vehicleId: null,
+      payments: [],
+    } as any)
+
+    const result = await toggleManuallyPaid('rec-1')
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    expect(db.serviceRecord.update).not.toHaveBeenCalled()
+  })
+
+  it('still allows unmarking paid while recorded payments keep it settled', async () => {
+    // The flip changes nothing the lock cares about, so it is workflow.
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...PAID_INVOICE,
+      id: 'rec-1',
+      vehicleId: null,
+    } as any)
+
+    const result = await toggleManuallyPaid('rec-1')
+
+    expectNotBlockedByLock(result)
+    expect(db.serviceRecord.update).toHaveBeenCalled()
+  })
+
+  it('still allows marking an unpaid invoice as paid', async () => {
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...UNPAID_INVOICE,
+      id: 'rec-1',
+      vehicleId: null,
+    } as any)
+
+    const result = await toggleManuallyPaid('rec-1')
+
+    expectNotBlockedByLock(result)
+    expect(db.serviceRecord.update).toHaveBeenCalled()
+  })
+
+  it('refuses deleting the payment that settled a locked invoice', async () => {
+    lockSettings(LOCK_INVOICES_WHEN_PAID)
+    vi.mocked(db.payment.findFirst).mockResolvedValue({
+      id: 'pay-1',
+      serviceRecord: {
+        id: 'rec-1',
+        vehicleId: null,
+        sentAt: null,
+        manuallyPaid: false,
+        totalAmount: 500,
+        cost: 0,
+        editUnlockedAt: null,
+        payments: [{ id: 'pay-1', amount: 500 }],
+      },
+    } as any)
+
+    const result = await deletePayment('pay-1')
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    expect(db.payment.delete).not.toHaveBeenCalled()
+  })
+
+  it('still allows deleting a payment under the "sent" trigger', async () => {
+    // Sending is what holds the lock there; the money record is workflow.
+    lockSettings({
+      [SETTING_KEYS.INVOICE_LOCK_ENABLED]: 'true',
+      [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: 'sent',
+    })
+    vi.mocked(db.payment.findFirst).mockResolvedValue({
+      id: 'pay-1',
+      serviceRecord: {
+        id: 'rec-1',
+        vehicleId: null,
+        sentAt: new Date('2026-01-01'),
+        manuallyPaid: false,
+        totalAmount: 500,
+        cost: 0,
+        editUnlockedAt: null,
+        payments: [{ id: 'pay-1', amount: 500 }],
+      },
+    } as any)
+
+    const result = await deletePayment('pay-1')
+
+    expectNotBlockedByLock(result)
+    expect(db.payment.delete).toHaveBeenCalled()
+  })
+
+  it('refuses moving an accepted quote back to draft', async () => {
+    lockSettings(LOCK_QUOTES_WHEN_ACCEPTED)
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      status: 'accepted',
+      sentAt: null,
+      editUnlockedAt: null,
+    } as any)
+
+    const result = await updateQuoteStatus('quote-1', 'draft')
+
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/locked/i)
+    expect(db.quote.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('still allows converting an accepted quote, which stays locked', async () => {
+    lockSettings(LOCK_QUOTES_WHEN_ACCEPTED)
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      status: 'accepted',
+      sentAt: null,
+      editUnlockedAt: null,
+    } as any)
+
+    const result = await updateQuoteStatus('quote-1', 'converted')
+
+    expectNotBlockedByLock(result)
+    expect(db.quote.updateMany).toHaveBeenCalled()
+  })
+
+  it('refuses a status the app does not know, locked or not', async () => {
+    // The old accept-any-string behaviour was itself a way out: a made-up
+    // status is not in any locked list, so it would have released the lock.
+    lockSettings(LOCK_QUOTES_WHEN_ACCEPTED)
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      status: 'accepted',
+      sentAt: null,
+      editUnlockedAt: null,
+    } as any)
+
+    const result = await updateQuoteStatus('quote-1', 'Accepted')
+
+    expect(result.success).toBe(false)
+    expect(db.quote.updateMany).not.toHaveBeenCalled()
+  })
+})
+
+describe('sharing a quote link counts as sending it', () => {
+  it('stamps sentAt and moves a draft to sent', async () => {
+    lockSettings({})
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ id: 'quote-1' } as any)
+
+    const result = await generateQuotePublicLink('quote-1')
+
+    expect(result.success).toBe(true)
+    const stampCalls = vi.mocked(db.quote.updateMany).mock.calls.map((c) => c[0] as any)
+    expect(stampCalls.some((c) => c.data.sentAt instanceof Date)).toBe(true)
+    // The status write leaves accepted and converted quotes alone.
+    const statusCall = stampCalls.find((c) => c.data.status === 'sent')
+    expect(statusCall?.where.status).toEqual({ notIn: ['accepted', 'converted'] })
+  })
+})
+
+describe('recording when an invoice was sent', () => {
+  beforeEach(() => lockSettings({}))
+
+  it('stamps sentAt when a share link is first created', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...UNPAID_INVOICE,
+      id: 'rec-1',
+      vehicleId: null,
+      sentAt: null,
+    } as any)
+
+    await generatePublicLink('rec-1')
+
+    const data = vi.mocked(db.serviceRecord.update).mock.calls[0]?.[0]?.data as any
+    expect(data.sentAt).toBeInstanceOf(Date)
+  })
+
+  it('moves sentAt forward when the link is created again', async () => {
+    // Sharing again re-issues the document, and the lock compares this against
+    // any unlock to decide whether the corrected copy locks itself.
+    const firstSent = new Date('2026-01-01')
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...UNPAID_INVOICE,
+      id: 'rec-1',
+      vehicleId: null,
+      sentAt: firstSent,
+    } as any)
+
+    await generatePublicLink('rec-1')
+
+    const data = vi.mocked(db.serviceRecord.update).mock.calls[0]?.[0]?.data as any
+    expect(data.sentAt.getTime()).toBeGreaterThan(firstSent.getTime())
+  })
+})

+ 124 - 0
src/__tests__/features/workorders/locked-invoice-autosave.test.ts

@@ -0,0 +1,124 @@
+/**
+ * A locked invoice must not queue a save it cannot make.
+ *
+ * The invoice page autosaves five seconds after any edit, and marking the form
+ * dirty is also what puts "Unsaved changes" in the header. On a locked invoice
+ * both are traps: the header offers a save the server refuses, and the autosave
+ * fires the same refusal on its own a moment later.
+ *
+ * This showed up through the "mark completed" prompt after sending. The status
+ * had already been saved by updateServiceStatus, but setting it locally went
+ * through the dirty setter, so a change that was already stored asked to be
+ * saved again into an invoice that had just locked.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { useServiceFormState } from '@/features/vehicles/Components/service-page/useServiceFormState'
+
+const AUTOSAVE_DELAY = 5000
+
+const initialData = {
+  id: 'rec-1',
+  title: 'Brake job',
+  description: '',
+  type: 'repair',
+  status: 'pending',
+  serviceDate: '2026-01-01',
+  partItems: [],
+  laborItems: [],
+  concerns: [],
+} as any
+
+const record = { id: 'rec-1', payments: [], manuallyPaid: false, attachments: [] } as any
+
+function renderForm(locked: boolean) {
+  return renderHook(
+    (props: { locked: boolean }) =>
+      useServiceFormState({
+        vehicleId: null,
+        initialData,
+        defaultTaxRate: 0,
+        currentUserName: 'Tester',
+        record,
+        locked: props.locked,
+      }),
+    { initialProps: { locked } }
+  )
+}
+
+beforeEach(() => vi.useFakeTimers())
+afterEach(() => vi.useRealTimers())
+
+describe('an editable invoice', () => {
+  it('marks the form dirty and queues an autosave', () => {
+    const { result } = renderForm(false)
+
+    act(() => result.current.markDirty())
+
+    expect(result.current.hasUnsavedChanges).toBe(true)
+    expect(result.current.autosaveTimer.current).not.toBeNull()
+  })
+})
+
+describe('a locked invoice', () => {
+  it('never reports unsaved changes', () => {
+    const { result } = renderForm(true)
+
+    act(() => result.current.markDirty())
+
+    expect(result.current.hasUnsavedChanges).toBe(false)
+  })
+
+  it('queues no autosave to be refused later', () => {
+    const { result } = renderForm(true)
+
+    act(() => result.current.markDirty())
+    act(() => void vi.advanceTimersByTime(AUTOSAVE_DELAY * 2))
+
+    expect(result.current.autosaveTimer.current).toBeNull()
+  })
+})
+
+describe('the lock engaging mid-session', () => {
+  it('cancels the queued autosave and clears "Unsaved changes"', () => {
+    // Edit first, then send the invoice: the edit queued a save the lock has
+    // now closed every route for. Left alone, the timer would fire into a
+    // refusal and the header would offer a save that can only fail.
+    const { result, rerender } = renderForm(false)
+
+    act(() => result.current.markDirty())
+    expect(result.current.hasUnsavedChanges).toBe(true)
+
+    rerender({ locked: true })
+
+    expect(result.current.hasUnsavedChanges).toBe(false)
+    expect(result.current.autosaveTimer.current).toBeNull()
+
+    act(() => void vi.advanceTimersByTime(AUTOSAVE_DELAY * 2))
+    expect(result.current.autosaveTimer.current).toBeNull()
+  })
+})
+
+describe('setting a status that is already saved', () => {
+  it('updates the status without asking to be saved again', () => {
+    // What the "mark completed" prompt does after sending: the status has
+    // already been stored by updateServiceStatus.
+    const { result } = renderForm(false)
+
+    act(() => result.current.setStatus('completed'))
+
+    expect(result.current.status).toBe('completed')
+    expect(result.current.hasUnsavedChanges).toBe(false)
+  })
+
+  it('still marks dirty when the status is changed by hand', () => {
+    // The ordinary dropdown must keep offering a save.
+    const { result } = renderForm(false)
+
+    act(() => result.current.dirtySetStatus('completed'))
+
+    expect(result.current.status).toBe('completed')
+    expect(result.current.hasUnsavedChanges).toBe(true)
+  })
+})

+ 402 - 0
src/__tests__/lib/document-lock.test.ts

@@ -0,0 +1,402 @@
+/**
+ * Tests for the invoice and quote edit lock.
+ *
+ * This guards money that has already left the workshop, so the cases that
+ * matter most are the ones where the lock must NOT engage: a lock that catches
+ * too much strands a document someone still has to correct, and the usual
+ * outcome is that the whole feature gets switched off. Each rule is therefore
+ * tested from both sides.
+ */
+
+import { describe, it, expect } from 'vitest'
+import {
+  DOCUMENT_LOCK_DEFAULTS,
+  DocumentLockedError,
+  assertEditable,
+  invoiceLockState,
+  invoicePaymentStatus,
+  quoteLockState,
+  readDocumentLockSettings,
+  type DocumentLockSettings,
+} from '@/lib/document-lock'
+
+const KEYS = {
+  invoiceLockEnabled: 'workshop.invoiceLockEnabled',
+  invoiceLockTrigger: 'workshop.invoiceLockTrigger',
+  quoteLockEnabled: 'workshop.quoteLockEnabled',
+  quoteLockTrigger: 'workshop.quoteLockTrigger',
+}
+
+const settingsFor = (over: Partial<DocumentLockSettings> = {}): DocumentLockSettings => ({
+  ...DOCUMENT_LOCK_DEFAULTS,
+  ...over,
+})
+
+const invoice = (over: Partial<Parameters<typeof invoiceLockState>[0]> = {}) => ({
+  sentAt: null,
+  manuallyPaid: false,
+  totalAmount: 500,
+  cost: 0,
+  payments: [],
+  editUnlockedAt: null,
+  ...over,
+})
+
+describe('readDocumentLockSettings', () => {
+  it('is off by default, so an upgrade changes nothing', () => {
+    const read = readDocumentLockSettings({}, KEYS)
+    expect(read.invoiceLockEnabled).toBe(false)
+    expect(read.quoteLockEnabled).toBe(false)
+  })
+
+  it('defaults each trigger to the later, less disruptive point', () => {
+    const read = readDocumentLockSettings({}, KEYS)
+    expect(read.invoiceLockTrigger).toBe('paid')
+    expect(read.quoteLockTrigger).toBe('accepted')
+  })
+
+  it('reads the stored strings', () => {
+    const read = readDocumentLockSettings(
+      {
+        [KEYS.invoiceLockEnabled]: 'true',
+        [KEYS.invoiceLockTrigger]: 'sent',
+        [KEYS.quoteLockEnabled]: 'true',
+        [KEYS.quoteLockTrigger]: 'sent',
+      },
+      KEYS
+    )
+    expect(read).toEqual({
+      invoiceLockEnabled: true,
+      invoiceLockTrigger: 'sent',
+      quoteLockEnabled: true,
+      quoteLockTrigger: 'sent',
+    })
+  })
+
+  it('treats anything other than the string "true" as off', () => {
+    for (const value of ['false', '1', 'yes', 'TRUE', '']) {
+      expect(
+        readDocumentLockSettings({ [KEYS.invoiceLockEnabled]: value }, KEYS).invoiceLockEnabled
+      ).toBe(false)
+    }
+  })
+
+  it('falls back to the default trigger for an unrecognised value', () => {
+    // A hand-edited settings row must not take out every invoice page.
+    const read = readDocumentLockSettings(
+      { [KEYS.invoiceLockTrigger]: 'whenever', [KEYS.quoteLockTrigger]: 'rejected' },
+      KEYS
+    )
+    expect(read.invoiceLockTrigger).toBe('paid')
+    expect(read.quoteLockTrigger).toBe('accepted')
+  })
+})
+
+describe('invoicePaymentStatus', () => {
+  it('counts what has been recorded against the total', () => {
+    expect(
+      invoicePaymentStatus({ manuallyPaid: false, totalAmount: 500, cost: 0, payments: [] })
+    ).toBe('unpaid')
+    expect(
+      invoicePaymentStatus({
+        manuallyPaid: false,
+        totalAmount: 500,
+        cost: 0,
+        payments: [{ amount: 200 }],
+      })
+    ).toBe('partial')
+    expect(
+      invoicePaymentStatus({
+        manuallyPaid: false,
+        totalAmount: 500,
+        cost: 0,
+        payments: [{ amount: 200 }, { amount: 300 }],
+      })
+    ).toBe('paid')
+  })
+
+  it('treats an overpayment as paid', () => {
+    expect(
+      invoicePaymentStatus({
+        manuallyPaid: false,
+        totalAmount: 500,
+        cost: 0,
+        payments: [{ amount: 600 }],
+      })
+    ).toBe('paid')
+  })
+
+  it('honours a manual mark regardless of what is recorded', () => {
+    expect(
+      invoicePaymentStatus({ manuallyPaid: true, totalAmount: 500, cost: 0, payments: [] })
+    ).toBe('paid')
+  })
+
+  it('falls back to cost for records predating itemised totals', () => {
+    expect(
+      invoicePaymentStatus({
+        manuallyPaid: false,
+        totalAmount: 0,
+        cost: 400,
+        payments: [{ amount: 400 }],
+      })
+    ).toBe('paid')
+  })
+
+  it('calls an empty draft unpaid rather than settled', () => {
+    // Otherwise every zero-total draft locks itself the moment the setting is
+    // turned on, which is the worst possible first impression of the feature.
+    expect(
+      invoicePaymentStatus({ manuallyPaid: false, totalAmount: 0, cost: 0, payments: [] })
+    ).toBe('unpaid')
+  })
+
+  it('reads a missing payments list as nothing paid', () => {
+    expect(invoicePaymentStatus({ manuallyPaid: false, totalAmount: 500, cost: 0 })).toBe('unpaid')
+  })
+
+  it('treats a deposit on a zero-total draft as partial, not settled', () => {
+    // A prepayment on work not yet priced must not lock the draft before any
+    // lines exist; and the billing list, which requires a positive total to
+    // call anything paid, must agree with the lock about the same record.
+    expect(
+      invoicePaymentStatus({
+        manuallyPaid: false,
+        totalAmount: 0,
+        cost: 0,
+        payments: [{ amount: 200 }],
+      })
+    ).toBe('partial')
+  })
+})
+
+describe('invoiceLockState, with locking switched off', () => {
+  it('leaves a sent and fully paid invoice editable', () => {
+    const state = invoiceLockState(
+      invoice({ sentAt: new Date('2026-01-01'), manuallyPaid: true }),
+      settingsFor({ invoiceLockEnabled: false })
+    )
+    expect(state).toEqual({ locked: false, reason: null, unlockedAt: null })
+  })
+})
+
+describe('invoiceLockState, locking when sent', () => {
+  const settings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'sent' })
+
+  it('locks once the invoice has reached the customer', () => {
+    const state = invoiceLockState(invoice({ sentAt: new Date('2026-01-01') }), settings)
+    expect(state.locked).toBe(true)
+    expect(state.reason).toBe('sent')
+  })
+
+  it('leaves an unsent invoice editable, however large', () => {
+    expect(invoiceLockState(invoice({ totalAmount: 99999 }), settings).locked).toBe(false)
+  })
+
+  it('ignores payment entirely', () => {
+    // Paid but never sent: this trigger is about the customer holding a copy.
+    expect(invoiceLockState(invoice({ manuallyPaid: true }), settings).locked).toBe(false)
+  })
+})
+
+describe('invoiceLockState, locking when paid', () => {
+  const settings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'paid' })
+
+  it('locks once the balance is settled', () => {
+    const state = invoiceLockState(invoice({ payments: [{ amount: 500 }] }), settings)
+    expect(state.locked).toBe(true)
+    expect(state.reason).toBe('paid')
+  })
+
+  it('locks on a manual mark', () => {
+    expect(invoiceLockState(invoice({ manuallyPaid: true }), settings).locked).toBe(true)
+  })
+
+  it('leaves a part-paid invoice editable', () => {
+    // The outstanding balance is often exactly what is being discussed.
+    expect(invoiceLockState(invoice({ payments: [{ amount: 200 }] }), settings).locked).toBe(false)
+  })
+
+  it('ignores sending entirely', () => {
+    expect(invoiceLockState(invoice({ sentAt: new Date('2026-01-01') }), settings).locked).toBe(
+      false
+    )
+  })
+
+  it('leaves an empty draft editable', () => {
+    expect(invoiceLockState(invoice({ totalAmount: 0, cost: 0 }), settings).locked).toBe(false)
+  })
+})
+
+describe('invoiceLockState, after an owner or admin unlocks it', () => {
+  const paidSettings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'paid' })
+  const sentSettings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'sent' })
+  const unlockedAt = new Date('2026-02-01')
+
+  it('reopens the document and says when', () => {
+    const state = invoiceLockState(
+      invoice({ manuallyPaid: true, editUnlockedAt: unlockedAt }),
+      paidSettings
+    )
+    expect(state).toEqual({ locked: false, reason: null, unlockedAt })
+  })
+
+  it('reopens a sent invoice that has not been sent again since', () => {
+    const record = invoice({ sentAt: new Date('2026-01-01'), editUnlockedAt: unlockedAt })
+    expect(invoiceLockState(record, sentSettings)).toEqual({
+      locked: false,
+      reason: null,
+      unlockedAt,
+    })
+  })
+
+  describe('and it is then sent again', () => {
+    it('locks the corrected copy the customer now holds', () => {
+      // The unlock was permission to correct that version. Issuing it again
+      // spends it, or an invoice unlocked once would stay open for good.
+      const record = invoice({ sentAt: new Date('2026-02-02'), editUnlockedAt: unlockedAt })
+      expect(invoiceLockState(record, sentSettings)).toEqual({
+        locked: true,
+        reason: 'sent',
+        unlockedAt: null,
+      })
+    })
+
+    it('treats a send at the same instant as not superseding the unlock', () => {
+      // Strictly later, so the ordering of two events in the same millisecond
+      // cannot decide whether someone gets to finish their edit.
+      const record = invoice({ sentAt: unlockedAt, editUnlockedAt: unlockedAt })
+      expect(invoiceLockState(record, sentSettings).locked).toBe(false)
+    })
+
+    it('leaves the unlock standing under the "paid" trigger', () => {
+      // Sending does not lock under this rule, and the invoice was already
+      // paid when it was reopened, so nothing new has happened to it.
+      const record = invoice({
+        manuallyPaid: true,
+        sentAt: new Date('2026-02-02'),
+        editUnlockedAt: unlockedAt,
+      })
+      expect(invoiceLockState(record, paidSettings).locked).toBe(false)
+    })
+  })
+})
+
+describe('quoteLockState', () => {
+  const accepted = settingsFor({ quoteLockEnabled: true, quoteLockTrigger: 'accepted' })
+  const sent = settingsFor({ quoteLockEnabled: true, quoteLockTrigger: 'sent' })
+
+  it('leaves everything editable while switched off', () => {
+    expect(quoteLockState({ status: 'accepted' }, settingsFor()).locked).toBe(false)
+  })
+
+  describe('locking when accepted', () => {
+    it('locks an accepted quote', () => {
+      expect(quoteLockState({ status: 'accepted' }, accepted)).toEqual({
+        locked: true,
+        reason: 'accepted',
+        unlockedAt: null,
+      })
+    })
+
+    it('locks a converted quote, whose job is already running', () => {
+      expect(quoteLockState({ status: 'converted' }, accepted).locked).toBe(true)
+    })
+
+    it('leaves a sent quote editable, because quotes get negotiated', () => {
+      expect(quoteLockState({ status: 'sent' }, accepted).locked).toBe(false)
+    })
+
+    it('leaves drafts, rejections and expiries editable', () => {
+      for (const status of ['draft', 'rejected', 'expired']) {
+        expect(quoteLockState({ status }, accepted).locked).toBe(false)
+      }
+    })
+  })
+
+  describe('locking when sent', () => {
+    it('locks a sent quote and everything past it', () => {
+      for (const status of ['sent', 'accepted', 'converted']) {
+        expect(quoteLockState({ status }, sent).locked).toBe(true)
+      }
+    })
+
+    it('leaves a draft editable', () => {
+      expect(quoteLockState({ status: 'draft' }, sent).locked).toBe(false)
+    })
+
+    it('leaves a rejected or expired quote editable, so it can be revised', () => {
+      for (const status of ['rejected', 'expired']) {
+        expect(quoteLockState({ status }, sent).locked).toBe(false)
+      }
+    })
+  })
+
+  it('reopens after an unlock', () => {
+    const unlockedAt = new Date('2026-02-01')
+    expect(quoteLockState({ status: 'accepted', editUnlockedAt: unlockedAt }, accepted)).toEqual({
+      locked: false,
+      reason: null,
+      unlockedAt,
+    })
+  })
+
+  describe('an unlock is spent when the quote is issued again', () => {
+    // A quote's status survives a re-send — an accepted quote stays accepted —
+    // so unlike an invoice the status cannot show that a corrected copy went
+    // out. sentAt carries that instead, under both triggers.
+    const unlockedAt = new Date('2026-02-01')
+
+    it('locks the corrected copy once it is re-sent', () => {
+      const record = {
+        status: 'accepted',
+        sentAt: new Date('2026-02-02'),
+        editUnlockedAt: unlockedAt,
+      }
+      expect(quoteLockState(record, accepted)).toEqual({
+        locked: true,
+        reason: 'accepted',
+        unlockedAt: null,
+      })
+      expect(quoteLockState(record, sent).locked).toBe(true)
+    })
+
+    it('leaves the unlock standing while nothing has been re-sent', () => {
+      const record = {
+        status: 'accepted',
+        sentAt: new Date('2026-01-01'),
+        editUnlockedAt: unlockedAt,
+      }
+      expect(quoteLockState(record, accepted)).toEqual({
+        locked: false,
+        reason: null,
+        unlockedAt,
+      })
+    })
+
+    it('treats a send at the same instant as not superseding the unlock', () => {
+      const record = { status: 'accepted', sentAt: unlockedAt, editUnlockedAt: unlockedAt }
+      expect(quoteLockState(record, accepted).locked).toBe(false)
+    })
+  })
+
+  it('does not lock on an unknown status', () => {
+    expect(quoteLockState({ status: 'something-new' }, sent).locked).toBe(false)
+  })
+})
+
+describe('assertEditable', () => {
+  it('passes an editable document through', () => {
+    expect(() => assertEditable({ locked: false, reason: null, unlockedAt: null })).not.toThrow()
+  })
+
+  it('throws with the reason attached, so the message can name the rule', () => {
+    try {
+      assertEditable({ locked: true, reason: 'paid', unlockedAt: null })
+      expect.unreachable('should have thrown')
+    } catch (err) {
+      expect(err).toBeInstanceOf(DocumentLockedError)
+      expect((err as DocumentLockedError).reason).toBe('paid')
+    }
+  })
+})

+ 64 - 0
src/__tests__/lib/with-api-auth-document-lock.test.ts

@@ -0,0 +1,64 @@
+/**
+ * A locked document surfacing through the technician API.
+ *
+ * The mobile app cannot read a stack trace: a lock refusal that came back as
+ * a generic 500 told the technician to retry a request that can never
+ * succeed. It must arrive as a 409 with a code the app can react to and a
+ * message that names the rule.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } } }))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    organizationMember: { findFirst: vi.fn() },
+    technician: { findMany: vi.fn() },
+  },
+}))
+
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { withApiAuth, apiOk } from '@/lib/with-api-auth'
+import { DocumentLockedError } from '@/lib/document-lock'
+
+function apiRequest() {
+  return new Request('https://app.test/api/v1/tech/jobs/rec-1/labor', {
+    method: 'POST',
+    headers: { authorization: 'Bearer token-1' },
+  })
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: 'user-1' } } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+  vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+    organizationId: 'org-1',
+    role: 'member',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.technician.findMany).mockResolvedValue([{ id: 'tech-1' }] as any)
+})
+
+describe('a handler that hits a locked document', () => {
+  it('returns 409 document_locked with the reason, not a 500', async () => {
+    const response = await withApiAuth(apiRequest(), async () => {
+      throw new DocumentLockedError('paid')
+    })
+
+    expect(response.status).toBe(409)
+    const body = await response.json()
+    expect(body.error.code).toBe('document_locked')
+    expect(body.error.message).toMatch(/locked/i)
+    expect(body.error.message).toMatch(/paid/i)
+  })
+
+  it('leaves ordinary handlers untouched', async () => {
+    const response = await withApiAuth(apiRequest(), async () => apiOk({ ok: true }))
+    expect(response.status).toBe(200)
+  })
+})

+ 3 - 0
src/__tests__/multitenancy/quote-inspection-isolation.test.ts

@@ -111,6 +111,9 @@ const ORG_A_INSPECTION = {
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
+  // Locking is read before any edit to an invoice or quote; no settings
+  // rows means locking is off, which is how these tests expect to run.
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
 })
 })
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------

+ 4 - 0
src/__tests__/multitenancy/service-record-isolation.test.ts

@@ -20,6 +20,7 @@ vi.mock('@/lib/notification-bus', () => ({
 vi.mock('@/lib/db', () => ({
 vi.mock('@/lib/db', () => ({
   db: {
   db: {
     user: { findUnique: vi.fn() },
     user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
     vehicle: { findFirst: vi.fn(), update: vi.fn() },
     vehicle: { findFirst: vi.fn(), update: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), delete: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), delete: vi.fn() },
     $transaction: vi.fn(),
     $transaction: vi.fn(),
@@ -90,6 +91,9 @@ const orgWhereClause = expect.objectContaining({
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
+  // Locking is read before any edit to an invoice or quote; no settings
+  // rows means locking is off, which is how these tests expect to run.
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
 })
 })
 
 
 describe('getServiceRecord — cross-org isolation', () => {
 describe('getServiceRecord — cross-org isolation', () => {

+ 4 - 0
src/__tests__/multitenancy/vehicle-service-isolation.test.ts

@@ -24,6 +24,7 @@ vi.mock('@/lib/resolve-upload-path', () => ({
 vi.mock('@/lib/db', () => ({
 vi.mock('@/lib/db', () => ({
   db: {
   db: {
     user: { findUnique: vi.fn() },
     user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
     vehicle: {
     vehicle: {
       findFirst: vi.fn(),
       findFirst: vi.fn(),
       findMany: vi.fn(),
       findMany: vi.fn(),
@@ -94,6 +95,9 @@ const ORG_A_VEHICLE = {
 
 
 beforeEach(() => {
 beforeEach(() => {
   vi.resetAllMocks()
   vi.resetAllMocks()
+  // Locking is read before any edit to an invoice or quote; no settings
+  // rows means locking is off, which is how these tests expect to run.
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
   // deleteServiceRecord restocks inventory inside a transaction; run the
   // deleteServiceRecord restocks inventory inside a transaction; run the
   // callback against the mock db and default to a record with no parts.
   // callback against the mock db and default to a record with no parts.
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 6 - 0
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -4,6 +4,7 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPresetActions'
 import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPresetActions'
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getAuthContext } from '@/lib/get-auth-context'
+import { getQuoteLockState } from '@/lib/document-lock.server'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { QuotePageClient } from '@/features/quotes/Components/QuotePageClient'
 import { QuotePageClient } from '@/features/quotes/Components/QuotePageClient'
@@ -26,6 +27,9 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
 
 
   const orgId = authContext?.organizationId
   const orgId = authContext?.organizationId
   const features = orgId ? await getFeatures(orgId) : null
   const features = orgId ? await getFeatures(orgId) : null
+  const lockState = orgId
+    ? await getQuoteLockState(id, orgId)
+    : { locked: false, reason: null, unlockedAt: null }
 
 
   if (!result.success || !result.data) {
   if (!result.success || !result.data) {
     return (
     return (
@@ -60,6 +64,8 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
       <QuotePageClient
       <QuotePageClient
         quote={result.data}
         quote={result.data}
         organizationId={organizationId}
         organizationId={organizationId}
+        lockState={lockState}
+        canUnlock={authContext?.isAdmin ?? false}
         imageAttachments={imageAttachments}
         imageAttachments={imageAttachments}
         documentAttachments={documentAttachments}
         documentAttachments={documentAttachments}
         maxImages={features?.maxImagesPerService}
         maxImages={features?.maxImagesPerService}

+ 114 - 1
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -9,12 +9,19 @@ import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
 import { Switch } from '@/components/ui/switch'
 import { Switch } from '@/components/ui/switch'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
 import { Textarea } from '@/components/ui/textarea'
 import { Textarea } from '@/components/ui/textarea'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { backfillCustomerNumbers } from '@/features/customers/Actions/customerActions'
 import { backfillCustomerNumbers } from '@/features/customers/Actions/customerActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { FileText, Loader2, Save } from 'lucide-react'
+import { FileText, Loader2, Lock, Save } from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { useConfirm } from '@/components/confirm-dialog'
 import { useConfirm } from '@/components/confirm-dialog'
@@ -99,6 +106,18 @@ export function InvoiceSettings({
   const [markupAppliesToInventory, setMarkupAppliesToInventory] = useState(
   const [markupAppliesToInventory, setMarkupAppliesToInventory] = useState(
     settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
     settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
   )
   )
+  const [invoiceLockEnabled, setInvoiceLockEnabled] = useState(
+    settings[SETTING_KEYS.INVOICE_LOCK_ENABLED] === 'true'
+  )
+  const [invoiceLockTrigger, setInvoiceLockTrigger] = useState(
+    settings[SETTING_KEYS.INVOICE_LOCK_TRIGGER] || 'paid'
+  )
+  const [quoteLockEnabled, setQuoteLockEnabled] = useState(
+    settings[SETTING_KEYS.QUOTE_LOCK_ENABLED] === 'true'
+  )
+  const [quoteLockTrigger, setQuoteLockTrigger] = useState(
+    settings[SETTING_KEYS.QUOTE_LOCK_TRIGGER] || 'accepted'
+  )
 
 
   const handleSaveGeneral = async () => {
   const handleSaveGeneral = async () => {
     setSaving(true)
     setSaving(true)
@@ -111,6 +130,10 @@ export function InvoiceSettings({
       [SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
       [SETTING_KEYS.INVOICE_FOOTER_NOTE]: footerNote,
       [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
       [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
       [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
       [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory ? 'true' : 'false',
+      [SETTING_KEYS.INVOICE_LOCK_ENABLED]: invoiceLockEnabled ? 'true' : 'false',
+      [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: invoiceLockTrigger,
+      [SETTING_KEYS.QUOTE_LOCK_ENABLED]: quoteLockEnabled ? 'true' : 'false',
+      [SETTING_KEYS.QUOTE_LOCK_TRIGGER]: quoteLockTrigger,
     })
     })
     setSaving(false)
     setSaving(false)
     router.refresh()
     router.refresh()
@@ -313,6 +336,96 @@ export function InvoiceSettings({
 
 
             <Separator />
             <Separator />
 
 
+            <div className="space-y-3">
+              <div>
+                <h3 className="flex items-center gap-2 text-sm font-semibold">
+                  <Lock className="h-3.5 w-3.5" />
+                  {t('invoice.lockTitle')}
+                </h3>
+                <p className="text-xs text-muted-foreground">{t('invoice.lockDescription')}</p>
+              </div>
+
+              <div className="rounded-md border border-dashed p-3">
+                <p className="text-xs text-muted-foreground">{t('invoice.lockWhatItDoes')}</p>
+                <p className="mt-1.5 text-xs text-muted-foreground">
+                  {t('invoice.lockWhatItAllows')}
+                </p>
+                <p className="mt-1.5 text-xs text-muted-foreground">
+                  {t('invoice.lockUnlockNote')}
+                </p>
+              </div>
+
+              <div className="grid gap-4 sm:grid-cols-2">
+                <div className="space-y-2">
+                  <Label
+                    htmlFor="invoiceLockEnabled"
+                    className="flex items-center justify-between gap-3"
+                  >
+                    <span>{t('invoice.lockInvoicesLabel')}</span>
+                    <Switch
+                      id="invoiceLockEnabled"
+                      checked={invoiceLockEnabled}
+                      onCheckedChange={setInvoiceLockEnabled}
+                    />
+                  </Label>
+                  <Select
+                    value={invoiceLockTrigger}
+                    onValueChange={setInvoiceLockTrigger}
+                    disabled={!invoiceLockEnabled}
+                  >
+                    <SelectTrigger id="invoiceLockTrigger" className="w-full">
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="sent">{t('invoice.lockTriggerSent')}</SelectItem>
+                      <SelectItem value="paid">{t('invoice.lockTriggerPaid')}</SelectItem>
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    {invoiceLockTrigger === 'sent'
+                      ? t('invoice.lockTriggerSentHint')
+                      : t('invoice.lockTriggerPaidHint')}
+                  </p>
+                </div>
+
+                <div className="space-y-2">
+                  <Label
+                    htmlFor="quoteLockEnabled"
+                    className="flex items-center justify-between gap-3"
+                  >
+                    <span>{t('invoice.lockQuotesLabel')}</span>
+                    <Switch
+                      id="quoteLockEnabled"
+                      checked={quoteLockEnabled}
+                      onCheckedChange={setQuoteLockEnabled}
+                    />
+                  </Label>
+                  <Select
+                    value={quoteLockTrigger}
+                    onValueChange={setQuoteLockTrigger}
+                    disabled={!quoteLockEnabled}
+                  >
+                    <SelectTrigger id="quoteLockTrigger" className="w-full">
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="sent">{t('invoice.quoteLockTriggerSent')}</SelectItem>
+                      <SelectItem value="accepted">
+                        {t('invoice.quoteLockTriggerAccepted')}
+                      </SelectItem>
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    {quoteLockTrigger === 'sent'
+                      ? t('invoice.quoteLockTriggerSentHint')
+                      : t('invoice.quoteLockTriggerAcceptedHint')}
+                  </p>
+                </div>
+              </div>
+            </div>
+
+            <Separator />
+
             <div className="space-y-3">
             <div className="space-y-3">
               <div>
               <div>
                 <h3 className="text-sm font-semibold">{t('invoice.partsMarkupTitle')}</h3>
                 <h3 className="text-sm font-semibold">{t('invoice.partsMarkupTitle')}</h3>

+ 4 - 0
src/app/api/v1/tech/jobs/[id]/labor/route.ts

@@ -5,6 +5,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { roundMoney } from '@/features/inventory/Lib/partPricing'
 import { roundMoney } from '@/features/inventory/Lib/partPricing'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { assertInvoiceEditable } from '@/lib/document-lock.server'
 
 
 /**
 /**
  * Adds a line of work to the job.
  * Adds a line of work to the job.
@@ -34,6 +35,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       const { id } = await params
       const { id } = await params
       const { description, hours } = bodySchema.parse(await request.json())
       const { description, hours } = bodySchema.parse(await request.json())
 
 
+      // Labour is money on the invoice, so a locked job refuses it here too.
+      await assertInvoiceEditable(id, ctx.organizationId)
+
       const job = await db.serviceRecord.findFirst({
       const job = await db.serviceRecord.findFirst({
         where: {
         where: {
           id,
           id,

+ 112 - 0
src/components/document-lock-banner.tsx

@@ -0,0 +1,112 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Lock, LockOpen, Loader2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { toast } from 'sonner'
+import { useConfirm } from '@/components/confirm-dialog'
+import type { LockState } from '@/lib/document-lock'
+
+/**
+ * Says why a document cannot be edited, and offers the way out to the people
+ * who have one.
+ *
+ * The server refuses the edit either way, so this exists to stop someone
+ * discovering the lock only after retyping a line. It names the rule that
+ * applied and what still works, because "you cannot edit this" with no reason
+ * reads as a fault rather than a policy.
+ */
+export function DocumentLockBanner({
+  state,
+  kind,
+  canUnlock,
+  onSetUnlocked,
+}: {
+  state: LockState
+  kind: 'invoice' | 'quote'
+  /** Owners and admins only; everyone else is told who to ask. */
+  canUnlock: boolean
+  onSetUnlocked: (unlocked: boolean) => Promise<{ success: boolean; error?: string }>
+}) {
+  const t = useTranslations('documentLock')
+  const router = useRouter()
+  const confirm = useConfirm()
+  const [isPending, startTransition] = useTransition()
+  const [busy, setBusy] = useState(false)
+
+  const run = async (unlocked: boolean) => {
+    if (unlocked) {
+      const ok = await confirm({
+        title: t('unlockConfirmTitle'),
+        description: t(`unlockConfirmDescription.${kind}`),
+        confirmLabel: t('unlock'),
+      })
+      if (!ok) return
+    }
+    setBusy(true)
+    const result = await onSetUnlocked(unlocked)
+    setBusy(false)
+    if (!result.success) {
+      toast.error(result.error || t('failed'))
+      return
+    }
+    toast.success(unlocked ? t('unlocked') : t('relocked'))
+    startTransition(() => router.refresh())
+  }
+
+  const working = busy || isPending
+
+  // Reopened by an admin: a quieter note, since nothing is being blocked.
+  if (!state.locked && state.unlockedAt) {
+    return (
+      <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-amber-500/40 bg-amber-500/5 px-4 py-3">
+        <div className="flex items-start gap-2.5">
+          <LockOpen className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
+          <div>
+            <p className="text-sm font-medium">{t('reopenedTitle')}</p>
+            <p className="text-xs text-muted-foreground">{t('reopenedBody')}</p>
+          </div>
+        </div>
+        {canUnlock && (
+          <Button variant="outline" size="sm" disabled={working} onClick={() => run(false)}>
+            {working ? (
+              <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+            ) : (
+              <Lock className="mr-1.5 h-3.5 w-3.5" />
+            )}
+            {t('relock')}
+          </Button>
+        )}
+      </div>
+    )
+  }
+
+  if (!state.locked || !state.reason) return null
+
+  return (
+    <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-muted/40 px-4 py-3">
+      <div className="flex items-start gap-2.5">
+        <Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+        <div>
+          <p className="text-sm font-medium">{t(`lockedTitle.${kind}.${state.reason}`)}</p>
+          <p className="text-xs text-muted-foreground">{t('lockedBody')}</p>
+          <p className="mt-1 text-xs text-muted-foreground">
+            {canUnlock ? t('lockedAdminHint') : t('lockedMemberHint')}
+          </p>
+        </div>
+      </div>
+      {canUnlock && (
+        <Button variant="outline" size="sm" disabled={working} onClick={() => run(true)}>
+          {working ? (
+            <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+          ) : (
+            <LockOpen className="mr-1.5 h-3.5 w-3.5" />
+          )}
+          {t('unlock')}
+        </Button>
+      )}
+    </div>
+  )
+}

+ 6 - 7
src/features/email/Actions/emailActions.ts

@@ -15,6 +15,7 @@ import { InspectionPDF } from '@/features/inspections/Components/InspectionPDF'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
 import {
 import {
   mergeWithDefaults,
   mergeWithDefaults,
   type InvoiceLayoutConfig,
   type InvoiceLayoutConfig,
@@ -210,13 +211,9 @@ export async function sendQuoteEmail(input: {
         ],
         ],
       })
       })
 
 
-      // Update quote status to "sent" (skip if already accepted or converted)
-      if (quote.status !== 'accepted' && quote.status !== 'converted') {
-        await db.quote.update({
-          where: { id: quoteId },
-          data: { status: 'sent' },
-        })
-      }
+      // Stamps sentAt and moves a draft to "sent" (accepted and converted
+      // quotes keep their status).
+      await markQuoteSent(quoteId, organizationId)
 
 
       return { sent: true, quoteId, recipientEmail }
       return { sent: true, quoteId, recipientEmail }
     },
     },
@@ -400,6 +397,8 @@ export async function sendInvoiceEmail(input: {
         ],
         ],
       })
       })
 
 
+      await markInvoiceSent(serviceRecordId, organizationId)
+
       return { sent: true, serviceRecordId, recipientEmail }
       return { sent: true, serviceRecordId, recipientEmail }
     },
     },
     {
     {

+ 33 - 1
src/features/payments/Actions/paymentActions.ts

@@ -6,6 +6,8 @@ import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { createPaymentSchema } from '../Schema/paymentSchema'
 import { createPaymentSchema } from '../Schema/paymentSchema'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
+import { getDocumentLockSettings } from '@/lib/document-lock.server'
+import { DocumentLockedError, invoiceLockState } from '@/lib/document-lock'
 
 
 export async function createPayment(input: unknown) {
 export async function createPayment(input: unknown) {
   return withAuth(
   return withAuth(
@@ -69,10 +71,40 @@ export async function deletePayment(paymentId: string) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const payment = await db.payment.findFirst({
       const payment = await db.payment.findFirst({
         where: { id: paymentId, serviceRecord: { organizationId } },
         where: { id: paymentId, serviceRecord: { organizationId } },
-        include: { serviceRecord: { select: { vehicleId: true, id: true } } },
+        include: {
+          serviceRecord: {
+            select: {
+              vehicleId: true,
+              id: true,
+              sentAt: true,
+              manuallyPaid: true,
+              totalAmount: true,
+              cost: true,
+              editUnlockedAt: true,
+              payments: { select: { id: true, amount: true } },
+            },
+          },
+        },
       })
       })
       if (!payment) throw new Error('Payment not found')
       if (!payment) throw new Error('Payment not found')
 
 
+      // Recording payments is always allowed, but removing the payment that
+      // settled a locked invoice would release the lock — the admin-only
+      // unlock in disguise. Deleting a partial payment stays open to everyone,
+      // since it never changes the lock.
+      const settings = await getDocumentLockSettings(organizationId)
+      const before = invoiceLockState(payment.serviceRecord, settings)
+      const after = invoiceLockState(
+        {
+          ...payment.serviceRecord,
+          payments: payment.serviceRecord.payments.filter((p) => p.id !== paymentId),
+        },
+        settings
+      )
+      if (before.locked && !after.locked && before.reason) {
+        throw new DocumentLockedError(before.reason)
+      }
+
       await db.payment.delete({ where: { id: paymentId } })
       await db.payment.delete({ where: { id: paymentId } })
 
 
       const { vehicleId, id: serviceId } = payment.serviceRecord
       const { vehicleId, id: serviceId } = payment.serviceRecord

+ 17 - 2
src/features/quotes/Actions/quoteActions.ts

@@ -1,8 +1,10 @@
 'use server'
 'use server'
 
 
+import { assertQuoteEditable, getDocumentLockSettings } from '@/lib/document-lock.server'
+import { DocumentLockedError, quoteLockState } from '@/lib/document-lock'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
-import { createQuoteSchema, updateQuoteSchema } from '../Schema/quoteSchema'
+import { createQuoteSchema, quoteStatusSchema, updateQuoteSchema } from '../Schema/quoteSchema'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
 import { resolveInvoicePrefix, toSafeDate } from '@/lib/invoice-utils'
 import { resolveInvoicePrefix, toSafeDate } from '@/lib/invoice-utils'
@@ -291,6 +293,7 @@ export async function updateQuote(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
       const data = updateQuoteSchema.parse(input)
       const data = updateQuoteSchema.parse(input)
+      await assertQuoteEditable(data.id, organizationId)
       const existing = await db.quote.findFirst({
       const existing = await db.quote.findFirst({
         where: { id: data.id, organizationId },
         where: { id: data.id, organizationId },
       })
       })
@@ -349,14 +352,25 @@ export async function updateQuote(input: unknown) {
 export async function updateQuoteStatus(quoteId: string, status: string) {
 export async function updateQuoteStatus(quoteId: string, status: string) {
   return withAuth(
   return withAuth(
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
+      const parsedStatus = quoteStatusSchema.parse(status)
       const quote = await db.quote.findFirst({
       const quote = await db.quote.findFirst({
         where: { id: quoteId, organizationId },
         where: { id: quoteId, organizationId },
       })
       })
       if (!quote) throw new Error('Quote not found')
       if (!quote) throw new Error('Quote not found')
 
 
+      // Status changes are workflow and stay open, except the ones that would
+      // release the lock: the lock derives from the status, so moving an
+      // accepted quote back to draft is the admin-only unlock in disguise.
+      const settings = await getDocumentLockSettings(organizationId)
+      const before = quoteLockState(quote, settings)
+      const after = quoteLockState({ ...quote, status: parsedStatus }, settings)
+      if (before.locked && !after.locked && before.reason) {
+        throw new DocumentLockedError(before.reason)
+      }
+
       await db.quote.updateMany({
       await db.quote.updateMany({
         where: { id: quoteId, organizationId },
         where: { id: quoteId, organizationId },
-        data: { status },
+        data: { status: parsedStatus },
       })
       })
 
 
       revalidatePath('/quotes')
       revalidatePath('/quotes')
@@ -379,6 +393,7 @@ export async function updateQuoteStatus(quoteId: string, status: string) {
 export async function deleteQuote(quoteId: string) {
 export async function deleteQuote(quoteId: string) {
   return withAuth(
   return withAuth(
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
+      await assertQuoteEditable(quoteId, organizationId)
       const quote = await db.quote.findFirst({
       const quote = await db.quote.findFirst({
         where: { id: quoteId, organizationId },
         where: { id: quoteId, organizationId },
       })
       })

+ 5 - 0
src/features/quotes/Actions/quoteShareActions.ts

@@ -5,6 +5,7 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { markQuoteSent } from '@/lib/document-lock.server'
 
 
 export async function generateQuotePublicLink(quoteId: string) {
 export async function generateQuotePublicLink(quoteId: string) {
   return withAuth(
   return withAuth(
@@ -19,6 +20,10 @@ export async function generateQuotePublicLink(quoteId: string) {
         where: { id: quoteId },
         where: { id: quoteId },
         data: { publicToken: token, sharedAt: new Date() },
         data: { publicToken: token, sharedAt: new Date() },
       })
       })
+      // Handing over a link is a way of sending the quote, exactly as it is
+      // for invoices: it stamps sentAt (which "lock when sent" keys off, and
+      // which spends any admin unlock) and moves a draft to "sent".
+      await markQuoteSent(quoteId, organizationId)
 
 
       revalidatePath(`/quotes/${quoteId}`)
       revalidatePath(`/quotes/${quoteId}`)
       return { token, organizationId }
       return { token, organizationId }

+ 59 - 23
src/features/quotes/Components/QuotePageClient.tsx

@@ -1,5 +1,8 @@
 'use client'
 'use client'
 
 
+import { DocumentLockBanner } from '@/components/document-lock-banner'
+import { setQuoteEditUnlocked } from '@/features/settings/Actions/documentLockActions'
+import type { LockState } from '@/lib/document-lock'
 import { useState, useCallback, useEffect } from 'react'
 import { useState, useCallback, useEffect } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import Link from 'next/link'
@@ -61,6 +64,8 @@ function useIsLargeScreen() {
 export function QuotePageClient({
 export function QuotePageClient({
   quote,
   quote,
   organizationId,
   organizationId,
+  lockState,
+  canUnlock,
   currencyCode = 'USD',
   currencyCode = 'USD',
   defaultTaxRate = 0,
   defaultTaxRate = 0,
   taxEnabled = true,
   taxEnabled = true,
@@ -76,6 +81,12 @@ export function QuotePageClient({
 }: {
 }: {
   quote: QuoteRecord
   quote: QuoteRecord
   organizationId: string
   organizationId: string
+  /**
+   * Required rather than defaulted: a call site that forgot to wire it would
+   * render a locked quote as editable, which is the trap this exists to close.
+   */
+  lockState: LockState
+  canUnlock: boolean
   currencyCode?: string
   currencyCode?: string
   defaultTaxRate?: number
   defaultTaxRate?: number
   taxEnabled?: boolean
   taxEnabled?: boolean
@@ -100,9 +111,15 @@ export function QuotePageClient({
     defaultTaxRate,
     defaultTaxRate,
     taxEnabled,
     taxEnabled,
     defaultLaborRate,
     defaultLaborRate,
+    locked: lockState.locked,
     t,
     t,
   })
   })
 
 
+  // Sending is what can lock the quote — by email or by link — so the page is
+  // re-rendered to pick up the lock rather than leaving the fieldset open and
+  // the next autosave to be refused.
+  const handleQuoteSent = useCallback(() => router.refresh(), [router])
+
   useSaveShortcut(() => {
   useSaveShortcut(() => {
     if (state.hasUnsavedChanges) return state.saveNow()
     if (state.hasUnsavedChanges) return state.saveNow()
   })
   })
@@ -355,6 +372,17 @@ export function QuotePageClient({
         </div>
         </div>
       </div>
       </div>
 
 
+      {(lockState.locked || lockState.unlockedAt) && (
+        <div className="shrink-0 px-4 pt-3">
+          <DocumentLockBanner
+            state={lockState}
+            kind="quote"
+            canUnlock={canUnlock}
+            onSetUnlocked={(unlocked) => setQuoteEditUnlocked(quote.id, unlocked)}
+          />
+        </div>
+      )}
+
       {/* Tab Content */}
       {/* Tab Content */}
       {state.activeTab === 'details' && (
       {state.activeTab === 'details' && (
         <form
         <form
@@ -363,28 +391,33 @@ export function QuotePageClient({
           onSubmit={state.handleSubmit}
           onSubmit={state.handleSubmit}
           className="flex min-h-0 flex-1 flex-col overflow-hidden"
           className="flex min-h-0 flex-1 flex-col overflow-hidden"
         >
         >
-          {isLarge ? (
-            <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
-              <ResizablePanel defaultSize={75} minSize={40}>
-                <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
-                  <div className="space-y-3 pb-40">{leftColumn}</div>
+          {/* A locked quote offers no editing, rather than letting someone
+              retype a line and meet the refusal on save. display:contents
+              keeps the layout exactly as it was. */}
+          <fieldset disabled={lockState.locked} className="contents">
+            {isLarge ? (
+              <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
+                <ResizablePanel defaultSize={75} minSize={40}>
+                  <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
+                    <div className="space-y-3 pb-40">{leftColumn}</div>
+                  </div>
+                </ResizablePanel>
+                <ResizableHandle withHandle />
+                <ResizablePanel defaultSize={25} minSize={15}>
+                  <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
+                    <div className="space-y-3 pb-40">{rightColumn}</div>
+                  </div>
+                </ResizablePanel>
+              </ResizablePanelGroup>
+            ) : (
+              <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+                <div className="space-y-3 pb-40">
+                  {leftColumn}
+                  {rightColumn}
                 </div>
                 </div>
-              </ResizablePanel>
-              <ResizableHandle withHandle />
-              <ResizablePanel defaultSize={25} minSize={15}>
-                <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
-                  <div className="space-y-3 pb-40">{rightColumn}</div>
-                </div>
-              </ResizablePanel>
-            </ResizablePanelGroup>
-          ) : (
-            <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-              <div className="space-y-3 pb-40">
-                {leftColumn}
-                {rightColumn}
               </div>
               </div>
-            </div>
-          )}
+            )}
+          </fieldset>
         </form>
         </form>
       )}
       )}
 
 
@@ -425,9 +458,11 @@ export function QuotePageClient({
         onOpenChange={state.setShowEmailDialog}
         onOpenChange={state.setShowEmailDialog}
         defaultEmail={quote.customer?.email || ''}
         defaultEmail={quote.customer?.email || ''}
         entityLabel={t('page.entityLabel')}
         entityLabel={t('page.entityLabel')}
-        onSend={async (email, message) =>
-          sendQuoteEmail({ quoteId: quote.id, recipientEmail: email, message })
-        }
+        onSend={async (email, message) => {
+          const result = await sendQuoteEmail({ quoteId: quote.id, recipientEmail: email, message })
+          if (result.success) handleQuoteSent()
+          return result
+        }}
       />
       />
 
 
       <QuoteShareDialog
       <QuoteShareDialog
@@ -436,6 +471,7 @@ export function QuotePageClient({
         quoteId={quote.id}
         quoteId={quote.id}
         organizationId={organizationId}
         organizationId={organizationId}
         initialToken={quote.publicToken}
         initialToken={quote.publicToken}
+        onSent={handleQuoteSent}
         customer={quote.customer}
         customer={quote.customer}
         smsEnabled={smsEnabled}
         smsEnabled={smsEnabled}
         emailEnabled={emailEnabled}
         emailEnabled={emailEnabled}

+ 10 - 1
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -24,6 +24,8 @@ interface QuoteShareDialogProps {
   quoteId: string
   quoteId: string
   organizationId: string
   organizationId: string
   initialToken: string | null
   initialToken: string | null
+  /** Called once the quote has actually gone to the customer. */
+  onSent?: () => void
   customer?: {
   customer?: {
     id: string
     id: string
     name: string
     name: string
@@ -40,6 +42,7 @@ export function QuoteShareDialog({
   quoteId,
   quoteId,
   organizationId,
   organizationId,
   initialToken,
   initialToken,
+  onSent,
   customer,
   customer,
   smsEnabled = false,
   smsEnabled = false,
   emailEnabled = false,
   emailEnabled = false,
@@ -63,7 +66,11 @@ export function QuoteShareDialog({
   const handleGenerate = async () => {
   const handleGenerate = async () => {
     setGeneratingLink(true)
     setGeneratingLink(true)
     const result = await generateQuotePublicLink(quoteId)
     const result = await generateQuotePublicLink(quoteId)
-    if (result.success && result.data) setPublicToken(result.data.token)
+    if (result.success && result.data) {
+      setPublicToken(result.data.token)
+      // Handing over a link is a way of sending the quote.
+      onSent?.()
+    }
     setGeneratingLink(false)
     setGeneratingLink(false)
   }
   }
 
 
@@ -121,6 +128,8 @@ export function QuoteShareDialog({
       toast.success(results.join(' & '))
       toast.success(results.join(' & '))
       setNotifyEmail(false)
       setNotifyEmail(false)
       setNotifySms(false)
       setNotifySms(false)
+      // Either channel re-issues the quote, and the lock may have engaged.
+      onSent?.()
     }
     }
     setSending(false)
     setSending(false)
   }
   }

+ 18 - 1
src/features/quotes/Components/useQuoteFormState.ts

@@ -39,6 +39,7 @@ export function useQuoteFormState({
   defaultTaxRate,
   defaultTaxRate,
   taxEnabled,
   taxEnabled,
   defaultLaborRate,
   defaultLaborRate,
+  locked = false,
   t,
   t,
 }: {
 }: {
   quote: QuoteRecord
   quote: QuoteRecord
@@ -46,6 +47,8 @@ export function useQuoteFormState({
   defaultTaxRate: number
   defaultTaxRate: number
   taxEnabled: boolean
   taxEnabled: boolean
   defaultLaborRate: number
   defaultLaborRate: number
+  /** A locked quote refuses saves, so it must not queue one. */
+  locked?: boolean
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string
   t: (key: string, values?: any) => string
 }) {
 }) {
@@ -143,6 +146,9 @@ export function useQuoteFormState({
   const isSavingRef = useRef(false)
   const isSavingRef = useRef(false)
 
 
   const markDirty = useCallback(() => {
   const markDirty = useCallback(() => {
+    // A locked quote cannot be saved, so nothing may queue an autosave that
+    // the server will only refuse five seconds later.
+    if (locked) return
     setHasUnsavedChanges(true)
     setHasUnsavedChanges(true)
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     autosaveTimer.current = setTimeout(() => {
     autosaveTimer.current = setTimeout(() => {
@@ -150,7 +156,18 @@ export function useQuoteFormState({
         formRef.current.requestSubmit()
         formRef.current.requestSubmit()
       }
       }
     }, 5000)
     }, 5000)
-  }, [])
+  }, [locked])
+
+  // When the lock engages mid-session, a save already queued can only be
+  // refused, and "Unsaved changes" would offer one that can never complete.
+  useEffect(() => {
+    if (!locked) return
+    if (autosaveTimer.current) {
+      clearTimeout(autosaveTimer.current)
+      autosaveTimer.current = null
+    }
+    setHasUnsavedChanges(false)
+  }, [locked])
 
 
   useEffect(() => {
   useEffect(() => {
     return () => {
     return () => {

+ 11 - 3
src/features/quotes/Schema/quoteSchema.ts

@@ -43,12 +43,20 @@ export const quoteAttachmentSchema = z.object({
   includeInInvoice: z.boolean().default(true),
   includeInInvoice: z.boolean().default(true),
 })
 })
 
 
+export const quoteStatusSchema = z.enum([
+  'draft',
+  'sent',
+  'accepted',
+  'rejected',
+  'expired',
+  'converted',
+  'changes_requested',
+])
+
 export const createQuoteSchema = z.object({
 export const createQuoteSchema = z.object({
   title: z.string().min(1, 'Title is required'),
   title: z.string().min(1, 'Title is required'),
   description: z.string().optional(),
   description: z.string().optional(),
-  status: z
-    .enum(['draft', 'sent', 'accepted', 'rejected', 'expired', 'converted', 'changes_requested'])
-    .default('draft'),
+  status: quoteStatusSchema.default('draft'),
   validUntil: z.string().optional(),
   validUntil: z.string().optional(),
   customerId: z.string().optional(),
   customerId: z.string().optional(),
   vehicleId: z.string().optional(),
   vehicleId: z.string().optional(),

+ 28 - 2
src/features/settings/Actions/applyTaxRateToExisting.ts

@@ -6,6 +6,8 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { SETTING_KEYS } from '../Schema/settingsSchema'
 import { SETTING_KEYS } from '../Schema/settingsSchema'
 import { calculateTotals } from '@/lib/tax'
 import { calculateTotals } from '@/lib/tax'
+import { getDocumentLockSettings } from '@/lib/document-lock.server'
+import { invoiceLockState, quoteLockState } from '@/lib/document-lock'
 
 
 /**
 /**
  * Apply the current default tax rate from settings to existing ServiceRecords
  * Apply the current default tax rate from settings to existing ServiceRecords
@@ -37,7 +39,7 @@ export async function applyTaxRateToExisting() {
       }
       }
 
 
       // --- Service records (work orders / invoices) ---
       // --- Service records (work orders / invoices) ---
-      const serviceRecords = await db.serviceRecord.findMany({
+      const allServiceRecords = await db.serviceRecord.findMany({
         where: {
         where: {
           organizationId,
           organizationId,
           taxRate: 0,
           taxRate: 0,
@@ -47,11 +49,17 @@ export async function applyTaxRateToExisting() {
           subtotal: true,
           subtotal: true,
           discountAmount: true,
           discountAmount: true,
           taxInclusive: true,
           taxInclusive: true,
+          sentAt: true,
+          manuallyPaid: true,
+          totalAmount: true,
+          cost: true,
+          editUnlockedAt: true,
+          payments: { select: { amount: true } },
         },
         },
       })
       })
 
 
       // --- Quotes ---
       // --- Quotes ---
-      const quotes = await db.quote.findMany({
+      const allQuotes = await db.quote.findMany({
         where: {
         where: {
           organizationId,
           organizationId,
           taxRate: 0,
           taxRate: 0,
@@ -61,9 +69,23 @@ export async function applyTaxRateToExisting() {
           subtotal: true,
           subtotal: true,
           discountAmount: true,
           discountAmount: true,
           taxInclusive: true,
           taxInclusive: true,
+          status: true,
+          sentAt: true,
+          editUnlockedAt: true,
         },
         },
       })
       })
 
 
+      // A locked document is exactly the one whose total must not change
+      // under it, so the sweep steps around it. Anything skipped stays
+      // eligible: unlock it (or turn locking off) and run the backfill again.
+      const lockSettings = await getDocumentLockSettings(organizationId)
+      const serviceRecords = allServiceRecords.filter(
+        (r) => !invoiceLockState(r, lockSettings).locked
+      )
+      const quotes = allQuotes.filter((q) => !quoteLockState(q, lockSettings).locked)
+      const serviceRecordsSkipped = allServiceRecords.length - serviceRecords.length
+      const quotesSkipped = allQuotes.length - quotes.length
+
       let serviceRecordsUpdated = 0
       let serviceRecordsUpdated = 0
       let quotesUpdated = 0
       let quotesUpdated = 0
 
 
@@ -112,6 +134,8 @@ export async function applyTaxRateToExisting() {
       return {
       return {
         serviceRecordsUpdated,
         serviceRecordsUpdated,
         quotesUpdated,
         quotesUpdated,
+        serviceRecordsSkipped,
+        quotesSkipped,
         taxRate: defaultTaxRate,
         taxRate: defaultTaxRate,
       }
       }
     },
     },
@@ -134,6 +158,8 @@ export async function applyTaxRateToExisting() {
           taxRate: result.taxRate,
           taxRate: result.taxRate,
           serviceRecordsUpdated: result.serviceRecordsUpdated,
           serviceRecordsUpdated: result.serviceRecordsUpdated,
           quotesUpdated: result.quotesUpdated,
           quotesUpdated: result.quotesUpdated,
+          serviceRecordsSkippedLocked: result.serviceRecordsSkipped,
+          quotesSkippedLocked: result.quotesSkipped,
         },
         },
       }),
       }),
     }
     }

+ 106 - 0
src/features/settings/Actions/documentLockActions.ts

@@ -0,0 +1,106 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { revalidatePath } from 'next/cache'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+
+/**
+ * Reopening a locked invoice or quote, and closing it again.
+ *
+ * Restricted to owners and admins, and written to the audit log, because this
+ * is the one way to change a document the rules say is settled. Without it the
+ * first genuinely wrong locked invoice has no route to a fix, and the usual
+ * outcome is that locking gets turned off for everyone and never turned back
+ * on. A narrow, recorded exception keeps the guardrail useful.
+ *
+ * The unlock has no expiry. Re-locking is a deliberate second action, so the
+ * document keeps saying it was reopened until someone closes it, and the
+ * screen can show that.
+ */
+
+function requireAdmin(isAdmin: boolean) {
+  if (!isAdmin) {
+    throw new Error('Only an owner or admin can unlock a document')
+  }
+}
+
+export async function setInvoiceEditUnlocked(recordId: string, unlocked: boolean) {
+  return withAuth(
+    async ({ userId, organizationId, isAdmin }) => {
+      requireAdmin(isAdmin)
+
+      const record = await db.serviceRecord.findFirst({
+        where: { id: recordId, organizationId },
+        select: { id: true, vehicleId: true, invoiceNumber: true },
+      })
+      if (!record) throw new Error('Record not found')
+
+      await db.serviceRecord.update({
+        where: { id: recordId },
+        data: {
+          editUnlockedAt: unlocked ? new Date() : null,
+          editUnlockedById: unlocked ? userId : null,
+        },
+      })
+
+      revalidatePath(
+        record.vehicleId
+          ? `/vehicles/${record.vehicleId}/service/${recordId}`
+          : `/sales/${recordId}`
+      )
+      return { recordId, unlocked, reference: record.invoiceNumber || recordId }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      audit: ({ result }) => ({
+        action: result.unlocked ? 'invoice.unlock' : 'invoice.relock',
+        entity: 'ServiceRecord',
+        entityId: result.recordId,
+        message: result.unlocked
+          ? `Unlocked invoice ${result.reference} for editing`
+          : `Re-locked invoice ${result.reference}`,
+        metadata: { serviceRecordId: result.recordId },
+      }),
+    }
+  )
+}
+
+export async function setQuoteEditUnlocked(quoteId: string, unlocked: boolean) {
+  return withAuth(
+    async ({ userId, organizationId, isAdmin }) => {
+      requireAdmin(isAdmin)
+
+      const quote = await db.quote.findFirst({
+        where: { id: quoteId, organizationId },
+        select: { id: true, quoteNumber: true },
+      })
+      if (!quote) throw new Error('Quote not found')
+
+      await db.quote.update({
+        where: { id: quoteId },
+        data: {
+          editUnlockedAt: unlocked ? new Date() : null,
+          editUnlockedById: unlocked ? userId : null,
+        },
+      })
+
+      revalidatePath(`/quotes/${quoteId}`)
+      return { quoteId, unlocked, reference: quote.quoteNumber || quoteId }
+    },
+    {
+      requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES }],
+      audit: ({ result }) => ({
+        action: result.unlocked ? 'quote.unlock' : 'quote.relock',
+        entity: 'Quote',
+        entityId: result.quoteId,
+        message: result.unlocked
+          ? `Unlocked quote ${result.reference} for editing`
+          : `Re-locked quote ${result.reference}`,
+        metadata: { quoteId: result.quoteId },
+      }),
+    }
+  )
+}

+ 9 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -24,6 +24,11 @@ export const SETTING_KEYS = {
   INVOICE_SHOW_BANK_ACCOUNT: 'invoice.showBankAccount',
   INVOICE_SHOW_BANK_ACCOUNT: 'invoice.showBankAccount',
   INVOICE_SHOW_ORG_NUMBER: 'invoice.showOrgNumber',
   INVOICE_SHOW_ORG_NUMBER: 'invoice.showOrgNumber',
   INVOICE_DUE_DAYS: 'invoice.dueDays',
   INVOICE_DUE_DAYS: 'invoice.dueDays',
+  /** See src/lib/document-lock.ts for what these freeze and when. */
+  INVOICE_LOCK_ENABLED: 'invoice.lockEnabled',
+  INVOICE_LOCK_TRIGGER: 'invoice.lockTrigger',
+  QUOTE_LOCK_ENABLED: 'quote.lockEnabled',
+  QUOTE_LOCK_TRIGGER: 'quote.lockTrigger',
   UNIT_SYSTEM: 'workshop.unitSystem',
   UNIT_SYSTEM: 'workshop.unitSystem',
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
   DEFAULT_TECHNICIAN: 'workshop.defaultTechnician',
   DEFAULT_TECHNICIAN_ID: 'workshop.defaultTechnicianId',
   DEFAULT_TECHNICIAN_ID: 'workshop.defaultTechnicianId',
@@ -221,6 +226,10 @@ export const invoiceSettingsSchema = z.object({
   [SETTING_KEYS.INVOICE_SHOW_BANK_ACCOUNT]: z.string().optional(),
   [SETTING_KEYS.INVOICE_SHOW_BANK_ACCOUNT]: z.string().optional(),
   [SETTING_KEYS.INVOICE_SHOW_ORG_NUMBER]: z.string().optional(),
   [SETTING_KEYS.INVOICE_SHOW_ORG_NUMBER]: z.string().optional(),
   [SETTING_KEYS.INVOICE_DUE_DAYS]: z.string().optional(),
   [SETTING_KEYS.INVOICE_DUE_DAYS]: z.string().optional(),
+  [SETTING_KEYS.INVOICE_LOCK_ENABLED]: z.string().optional(),
+  [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: z.string().optional(),
+  [SETTING_KEYS.QUOTE_LOCK_ENABLED]: z.string().optional(),
+  [SETTING_KEYS.QUOTE_LOCK_TRIGGER]: z.string().optional(),
 })
 })
 
 
 export type WorkshopSettings = z.infer<typeof workshopSettingsSchema>
 export type WorkshopSettings = z.infer<typeof workshopSettingsSchema>

+ 11 - 0
src/features/sms/Actions/smsActions.ts

@@ -8,6 +8,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS } from '@/lib/sms-templates'
 import { SMS_TEMPLATE_DEFAULTS } from '@/lib/sms-templates'
 import { demoGuard } from '@/lib/demo'
 import { demoGuard } from '@/lib/demo'
+import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
 
 
 export async function sendSmsToCustomer(input: {
 export async function sendSmsToCustomer(input: {
   customerId: string
   customerId: string
@@ -74,6 +75,16 @@ export async function sendSmsToCustomer(input: {
           },
           },
         })
         })
 
 
+        // These entity types are set only by the share dialogs, whose SMS
+        // carries the document's link: texting it is a way of sending the
+        // document, so it counts for "lock when sent". Plain conversations
+        // and reminders carry no entity type and stamp nothing.
+        if (input.relatedEntityType === 'invoice' && input.relatedEntityId) {
+          await markInvoiceSent(input.relatedEntityId, organizationId)
+        } else if (input.relatedEntityType === 'quote' && input.relatedEntityId) {
+          await markQuoteSent(input.relatedEntityId, organizationId)
+        }
+
         return {
         return {
           id: message.id,
           id: message.id,
           status: 'sent' as const,
           status: 'sent' as const,

+ 4 - 0
src/features/tire-hotel/Actions/tireJobActions.ts

@@ -18,6 +18,7 @@ import { TREATMENT_TYPES, billableTreatments, parseTreatmentPrices } from '../Li
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { invoiceLineWords, jobNoteWords, seasonNames, treatmentNames } from '../Lib/serverMessages'
 import { invoiceLineWords, jobNoteWords, seasonNames, treatmentNames } from '../Lib/serverMessages'
 import { isTireHotelEnabled, requireTireHotel } from '../Lib/tireHotelSettings'
 import { isTireHotelEnabled, requireTireHotel } from '../Lib/tireHotelSettings'
+import { assertInvoiceEditable } from '@/lib/document-lock.server'
 
 
 const READ = [{ action: PermissionAction.READ, subject: PermissionSubject.TIRE_HOTEL }]
 const READ = [{ action: PermissionAction.READ, subject: PermissionSubject.TIRE_HOTEL }]
 const QUOTE = [
 const QUOTE = [
@@ -699,6 +700,9 @@ export async function addTireSetToWorkOrder(input: unknown) {
     async ({ organizationId, userId }) => {
     async ({ organizationId, userId }) => {
       await requireTireHotel(organizationId)
       await requireTireHotel(organizationId)
       const data = fromSetSchema.extend({ serviceRecordId: z.string().min(1) }).parse(input)
       const data = fromSetSchema.extend({ serviceRecordId: z.string().min(1) }).parse(input)
+      // Adds part and labor lines and retotals the job, so it is an edit to
+      // what the invoice says it is owed and a locked one refuses it.
+      await assertInvoiceEditable(data.serviceRecordId, organizationId)
       const set = await loadSet(organizationId, data.tireSetId)
       const set = await loadSet(organizationId, data.tireSetId)
       const seasons = await seasonNames()
       const seasons = await seasonNames()
 
 

+ 32 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -13,6 +13,8 @@ import { serviceDateOrderBy } from '@/lib/date-sort'
 import { notificationBus } from '@/lib/notification-bus'
 import { notificationBus } from '@/lib/notification-bus'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
+import { assertInvoiceEditable, getDocumentLockSettings } from '@/lib/document-lock.server'
+import { DocumentLockedError, invoiceLockState } from '@/lib/document-lock'
 
 
 export async function getServiceRecords(vehicleId: string) {
 export async function getServiceRecords(vehicleId: string) {
   return withAuth(
   return withAuth(
@@ -527,6 +529,9 @@ export async function updateServiceRecord(input: unknown) {
   return withAuth(
   return withAuth(
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
       const data = updateServiceSchema.parse(input)
       const data = updateServiceSchema.parse(input)
+      // Refused before anything is read or written: this is the main way the
+      // money on an invoice changes.
+      await assertInvoiceEditable(data.id, organizationId)
       const existing = await db.serviceRecord.findFirst({
       const existing = await db.serviceRecord.findFirst({
         where: { id: data.id, organizationId },
         where: { id: data.id, organizationId },
         include: {
         include: {
@@ -880,9 +885,22 @@ export async function toggleManuallyPaid(recordId: string) {
     async ({ organizationId }) => {
     async ({ organizationId }) => {
       const record = await db.serviceRecord.findFirst({
       const record = await db.serviceRecord.findFirst({
         where: { id: recordId, organizationId },
         where: { id: recordId, organizationId },
+        include: { payments: { select: { amount: true } } },
       })
       })
       if (!record) throw new Error('Record not found')
       if (!record) throw new Error('Record not found')
 
 
+      // Marking paid is always allowed — it only ever locks the document
+      // further. But a flip that would *release* the lock is the admin-only
+      // unlock in disguise: without this check, anyone with edit permission
+      // could unmark a locked invoice, rewrite it, and mark it paid again,
+      // leaving no unlock in the audit trail.
+      const settings = await getDocumentLockSettings(organizationId)
+      const before = invoiceLockState(record, settings)
+      const after = invoiceLockState({ ...record, manuallyPaid: !record.manuallyPaid }, settings)
+      if (before.locked && !after.locked && before.reason) {
+        throw new DocumentLockedError(before.reason)
+      }
+
       await db.serviceRecord.update({
       await db.serviceRecord.update({
         where: { id: recordId },
         where: { id: recordId },
         data: { manuallyPaid: !record.manuallyPaid },
         data: { manuallyPaid: !record.manuallyPaid },
@@ -1020,6 +1038,8 @@ export async function getWorkOrders(params: {
 export async function deleteServiceRecord(recordId: string) {
 export async function deleteServiceRecord(recordId: string) {
   return withAuth(
   return withAuth(
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
+      // A locked invoice cannot be edited into nothing either.
+      await assertInvoiceEditable(recordId, organizationId)
       const record = await db.serviceRecord.findFirst({
       const record = await db.serviceRecord.findFirst({
         where: { id: recordId, organizationId },
         where: { id: recordId, organizationId },
         include: { attachments: true },
         include: { attachments: true },
@@ -1121,7 +1141,18 @@ export async function generatePublicLink(serviceRecordId: string) {
       const token = randomUUID()
       const token = randomUUID()
       await db.serviceRecord.update({
       await db.serviceRecord.update({
         where: { id: serviceRecordId },
         where: { id: serviceRecordId },
-        data: { publicToken: token, sharedAt: new Date() },
+        data: {
+          publicToken: token,
+          sharedAt: new Date(),
+          // Handing over a link is a way of sending the invoice, so it counts
+          // for "lock when sent". Unlike sharedAt this is never cleared:
+          // revoking the link does not unsend what the customer already saw.
+          // It tracks the latest share, so re-sharing after an unlock locks
+          // the document again. Kept inline (rather than markInvoiceSent,
+          // which owns every other channel) so the token and the stamp land
+          // in one write.
+          sentAt: new Date(),
+        },
       })
       })
 
 
       revalidatePath(
       revalidatePath(

+ 12 - 1
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -21,6 +21,8 @@ interface ShareDialogProps {
   recordId: string
   recordId: string
   organizationId: string
   organizationId: string
   initialToken: string | null
   initialToken: string | null
+  /** Called once the invoice has actually gone to the customer. */
+  onSent?: () => void
   customer?: {
   customer?: {
     id: string
     id: string
     name: string
     name: string
@@ -37,6 +39,7 @@ export function ShareDialog({
   recordId,
   recordId,
   organizationId,
   organizationId,
   initialToken,
   initialToken,
+  onSent,
   customer,
   customer,
   smsEnabled = false,
   smsEnabled = false,
   emailEnabled = false,
   emailEnabled = false,
@@ -61,7 +64,11 @@ export function ShareDialog({
   const handleGenerate = async () => {
   const handleGenerate = async () => {
     setGeneratingLink(true)
     setGeneratingLink(true)
     const result = await generatePublicLink(recordId)
     const result = await generatePublicLink(recordId)
-    if (result.success && result.data) setPublicToken(result.data.token)
+    if (result.success && result.data) {
+      setPublicToken(result.data.token)
+      // Handing over a link is a way of sending the invoice.
+      onSent?.()
+    }
     setGeneratingLink(false)
     setGeneratingLink(false)
   }
   }
 
 
@@ -125,6 +132,10 @@ export function ShareDialog({
       toast.success(results.join(' & '))
       toast.success(results.join(' & '))
       setNotifyEmail(false)
       setNotifyEmail(false)
       setNotifySms(false)
       setNotifySms(false)
+      // Both channels count as sending the invoice — email stamps sentAt on
+      // the server, and the SMS carries the link — so the lock may have just
+      // engaged and the page needs to hear about it.
+      onSent?.()
     }
     }
     setSending(false)
     setSending(false)
   }
   }

+ 119 - 49
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -1,8 +1,12 @@
 'use client'
 'use client'
 
 
+import { DocumentLockBanner } from '@/components/document-lock-banner'
+import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import { useState, useCallback, useMemo, useRef } from 'react'
 import { useState, useCallback, useMemo, useRef } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
+import { updateServiceStatus } from '@/features/vehicles/Actions/serviceActions'
+import { useConfirm } from '@/components/confirm-dialog'
 import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
 import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 
 
@@ -55,6 +59,8 @@ export function ServicePageClient({
   record,
   record,
   vehicleId,
   vehicleId,
   organizationId,
   organizationId,
+  lockState,
+  canUnlock,
   currencyCode,
   currencyCode,
   unitSystem,
   unitSystem,
   tireHotelEnabled = false,
   tireHotelEnabled = false,
@@ -142,6 +148,7 @@ export function ServicePageClient({
     defaultTaxRate,
     defaultTaxRate,
     currentUserName,
     currentUserName,
     record,
     record,
+    locked: lockState.locked,
   })
   })
 
 
   const checkDates = useCallback(async () => {
   const checkDates = useCallback(async () => {
@@ -289,6 +296,38 @@ export function ServicePageClient({
     formState,
     formState,
   })
   })
 
 
+  const confirmComplete = useConfirm()
+
+  /**
+   * Run after the invoice has gone to the customer, by email or by link.
+   *
+   * Sending is what the "lock when sent" rule keys off, so the page is
+   * re-rendered to pick up the lock rather than leaving the banner to appear
+   * on the next reload. It is also the moment a job is in practice finished,
+   * and an invoice that locks while still marked pending leaves the work board
+   * showing work nobody is doing. The status is not changed silently, since
+   * plenty of shops invoice up front.
+   */
+  const handleInvoiceSent = useCallback(async () => {
+    router.refresh()
+    if (formState.status === 'completed') return
+
+    const ok = await confirmComplete({
+      title: t('invoice.markCompletedTitle'),
+      description: t('invoice.markCompletedDescription'),
+      confirmLabel: t('invoice.markCompletedConfirm'),
+    })
+    if (!ok) return
+
+    const result = await updateServiceStatus(record.id, 'completed')
+    if (result.success) {
+      // Already persisted by updateServiceStatus, so this must not dirty the
+      // form: the invoice may have locked on the same send.
+      formState.setStatus('completed')
+      router.refresh()
+    }
+  }, [router, confirmComplete, formState, record.id, t])
+
   useSaveShortcut(() => {
   useSaveShortcut(() => {
     if (formState.hasUnsavedChanges) return actions.saveNow()
     if (formState.hasUnsavedChanges) return actions.saveNow()
   })
   })
@@ -333,6 +372,17 @@ export function ServicePageClient({
         hasCustomer={!!customer}
         hasCustomer={!!customer}
       />
       />
 
 
+      {(lockState.locked || lockState.unlockedAt) && (
+        <div className="shrink-0 px-4 pt-3">
+          <DocumentLockBanner
+            state={lockState}
+            kind="invoice"
+            canUnlock={canUnlock}
+            onSetUnlocked={(unlocked) => setInvoiceEditUnlocked(record.id, unlocked)}
+          />
+        </div>
+      )}
+
       {activeTab === 'details' && (
       {activeTab === 'details' && (
         <>
         <>
           <form
           <form
@@ -342,52 +392,62 @@ export function ServicePageClient({
             onInput={formState.markDirty}
             onInput={formState.markDirty}
             className="flex min-h-0 flex-1 flex-col"
             className="flex min-h-0 flex-1 flex-col"
           >
           >
-            <ServiceDetailContent
-              leftColumn={
-                <DetailsLeftColumn
-                  formState={formState}
-                  actions={actions}
-                  record={record}
-                  tireSet={record.tireSet ?? null}
-                  tireHotelEnabled={tireHotelEnabled}
-                  tireThresholds={tireThresholds}
-                  unitSystem={unitSystem}
-                  currencyCode={currencyCode}
-                  defaultLaborRate={defaultLaborRate}
-                  inventoryParts={inventoryParts}
-                  defaultMarkupPercent={defaultMarkupPercent}
-                  markupAppliesToInventory={markupAppliesToInventory}
-                  hasPresets={laborPresets.length > 0}
-                  onOpenPresets={() => formState.setShowPresetPicker(true)}
-                  onScanBarcode={() => formState.setShowBarcodeScanner(true)}
-                  aiEnabled={aiEnabled}
-                  vehicleId={vehicleId}
-                  findings={findings}
-                  onAddFinding={() => obsControlsRef.current?.onAddFinding()}
-                  onEditFinding={(f) => obsControlsRef.current?.onEditFinding(f)}
-                  openObservationsCount={otherObsCount}
-                  onShowExistingObservations={() =>
-                    obsControlsRef.current?.onShowExistingObservations()
-                  }
-                />
-              }
-              rightColumn={
-                <DetailsRightColumn
-                  formState={formState}
-                  actions={actions}
-                  record={record}
-                  vehicleId={vehicleId}
-                  organizationId={organizationId}
-                  currencyCode={currencyCode}
-                  taxEnabled={taxEnabled}
-                  initialVehicle={initialVehicle}
-                  boardTechnicians={boardTechnicians}
-                  workBays={workBays}
-                  orgMembers={orgMembers}
-                  notificationHistory={notificationHistory}
-                />
-              }
-            />
+            {/* A locked invoice offers no editing at all, rather than letting
+                someone retype a line and meet the refusal on save. The
+                fieldset disables every control inside it natively;
+                display:contents keeps the layout exactly as it was. */}
+            <fieldset
+              disabled={lockState.locked}
+              className="contents"
+              aria-label={lockState.locked ? t('invoice.lockedFieldsetLabel') : undefined}
+            >
+              <ServiceDetailContent
+                leftColumn={
+                  <DetailsLeftColumn
+                    formState={formState}
+                    actions={actions}
+                    record={record}
+                    tireSet={record.tireSet ?? null}
+                    tireHotelEnabled={tireHotelEnabled}
+                    tireThresholds={tireThresholds}
+                    unitSystem={unitSystem}
+                    currencyCode={currencyCode}
+                    defaultLaborRate={defaultLaborRate}
+                    inventoryParts={inventoryParts}
+                    defaultMarkupPercent={defaultMarkupPercent}
+                    markupAppliesToInventory={markupAppliesToInventory}
+                    hasPresets={laborPresets.length > 0}
+                    onOpenPresets={() => formState.setShowPresetPicker(true)}
+                    onScanBarcode={() => formState.setShowBarcodeScanner(true)}
+                    aiEnabled={aiEnabled}
+                    vehicleId={vehicleId}
+                    findings={findings}
+                    onAddFinding={() => obsControlsRef.current?.onAddFinding()}
+                    onEditFinding={(f) => obsControlsRef.current?.onEditFinding(f)}
+                    openObservationsCount={otherObsCount}
+                    onShowExistingObservations={() =>
+                      obsControlsRef.current?.onShowExistingObservations()
+                    }
+                  />
+                }
+                rightColumn={
+                  <DetailsRightColumn
+                    formState={formState}
+                    actions={actions}
+                    record={record}
+                    vehicleId={vehicleId}
+                    organizationId={organizationId}
+                    currencyCode={currencyCode}
+                    taxEnabled={taxEnabled}
+                    initialVehicle={initialVehicle}
+                    boardTechnicians={boardTechnicians}
+                    workBays={workBays}
+                    orgMembers={orgMembers}
+                    notificationHistory={notificationHistory}
+                  />
+                }
+              />
+            </fieldset>
           </form>
           </form>
           {vehicleId && (
           {vehicleId && (
             <ObservationsManager
             <ObservationsManager
@@ -499,9 +559,18 @@ export function ServicePageClient({
         onOpenChange={actions.setShowEmailDialog}
         onOpenChange={actions.setShowEmailDialog}
         defaultEmail={customer?.email || ''}
         defaultEmail={customer?.email || ''}
         entityLabel={t('invoice.entityLabel')}
         entityLabel={t('invoice.entityLabel')}
-        onSend={async (email, message) =>
-          sendInvoiceEmail({ serviceRecordId: record.id, recipientEmail: email, message })
-        }
+        onSend={async (email, message) => {
+          const result = await sendInvoiceEmail({
+            serviceRecordId: record.id,
+            recipientEmail: email,
+            message,
+          })
+          // Deliberately not awaited: the email dialog should show "sent" the
+          // moment it is, not sit spinning behind the "mark completed"
+          // confirmation that handleInvoiceSent may raise.
+          if (result.success) void handleInvoiceSent()
+          return result
+        }}
       />
       />
 
 
       <ShareDialog
       <ShareDialog
@@ -510,6 +579,7 @@ export function ServicePageClient({
         recordId={record.id}
         recordId={record.id}
         organizationId={organizationId}
         organizationId={organizationId}
         initialToken={record.publicToken}
         initialToken={record.publicToken}
+        onSent={handleInvoiceSent}
         customer={customer}
         customer={customer}
         smsEnabled={smsEnabled}
         smsEnabled={smsEnabled}
         emailEnabled={emailEnabled}
         emailEnabled={emailEnabled}

+ 6 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -8,6 +8,7 @@ import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPrese
 
 
 import { getTechnicians, getOrgMembers } from '@/features/workboard/Actions/technicianActions'
 import { getTechnicians, getOrgMembers } from '@/features/workboard/Actions/technicianActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getAuthContext } from '@/lib/get-auth-context'
+import { getInvoiceLockState } from '@/lib/document-lock.server'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { getTireHotelSettings } from '@/features/tire-hotel/Lib/tireHotelSettings'
 import { getTireHotelSettings } from '@/features/tire-hotel/Lib/tireHotelSettings'
 import { getStatusReportsForService } from '@/features/status-reports/Actions/getStatusReportsForService'
 import { getStatusReportsForService } from '@/features/status-reports/Actions/getStatusReportsForService'
@@ -109,6 +110,9 @@ export async function ServiceRecordPage({
     (b) => ({ id: b.id, name: b.name })
     (b) => ({ id: b.id, name: b.name })
   )
   )
   const organizationId = authContext?.organizationId || ''
   const organizationId = authContext?.organizationId || ''
+  const lockState = organizationId
+    ? await getInvoiceLockState(serviceId, organizationId)
+    : { locked: false, reason: null, unlockedAt: null }
 
 
   // Fetch team members and features
   // Fetch team members and features
   const membership = session?.user?.id ? await getCachedMembership(session.user.id) : null
   const membership = session?.user?.id ? await getCachedMembership(session.user.id) : null
@@ -259,6 +263,8 @@ export async function ServiceRecordPage({
         record={result.data}
         record={result.data}
         vehicleId={vehicleId}
         vehicleId={vehicleId}
         organizationId={organizationId}
         organizationId={organizationId}
+        lockState={lockState}
+        canUnlock={authContext?.isAdmin ?? false}
         initialTab={initialTab}
         initialTab={initialTab}
         currencyCode={currencyCode}
         currencyCode={currencyCode}
         unitSystem={unitSystem}
         unitSystem={unitSystem}

+ 10 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -1,3 +1,4 @@
+import type { LockState } from '@/lib/document-lock'
 import type { ServicePartInput, ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { ServicePartInput, ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { ServiceDetail } from '../service-detail/types'
 import type { ServiceDetail } from '../service-detail/types'
 import type { InitialData, InventoryPartOption } from '../service-edit/form-types'
 import type { InitialData, InventoryPartOption } from '../service-edit/form-types'
@@ -36,6 +37,15 @@ export interface ServicePageClientProps {
   record: ServiceDetail
   record: ServiceDetail
   vehicleId: string | null
   vehicleId: string | null
   organizationId: string
   organizationId: string
+  /**
+   * Whether the invoice may still be edited; see src/lib/document-lock.ts.
+   * Required rather than defaulted: a call site that forgot to wire it would
+   * render a locked invoice as editable, which is the trap this exists to
+   * close.
+   */
+  lockState: LockState
+  /** Owners and admins can reopen a locked invoice. */
+  canUnlock: boolean
   currencyCode: string
   currencyCode: string
   unitSystem: 'metric' | 'imperial'
   unitSystem: 'metric' | 'imperial'
   defaultTaxRate: number
   defaultTaxRate: number

+ 31 - 0
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -12,12 +12,15 @@ export function useServiceFormState({
   defaultTaxRate,
   defaultTaxRate,
   currentUserName,
   currentUserName,
   record,
   record,
+  locked = false,
 }: {
 }: {
   vehicleId: string | null
   vehicleId: string | null
   initialData: InitialData
   initialData: InitialData
   defaultTaxRate: number
   defaultTaxRate: number
   currentUserName: string
   currentUserName: string
   record: ServiceDetail
   record: ServiceDetail
+  /** A locked invoice refuses saves, so it must not queue one. */
+  locked?: boolean
 }) {
 }) {
   // Form state
   // Form state
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
@@ -52,6 +55,10 @@ export function useServiceFormState({
   const [showSaved, setShowSaved] = useState(false)
   const [showSaved, setShowSaved] = useState(false)
   const formRef = useRef<HTMLFormElement>(null)
   const formRef = useRef<HTMLFormElement>(null)
   const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
   const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+  // Read through a ref so markDirty stays stable; it is a dependency of most
+  // of the setters below and rebuilding them all on a lock change is churn.
+  const lockedRef = useRef(locked)
+  lockedRef.current = locked
   const isSavingRef = useRef(false)
   const isSavingRef = useRef(false)
   const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
 
@@ -62,6 +69,9 @@ export function useServiceFormState({
   }, [])
   }, [])
 
 
   const markDirty = useCallback(() => {
   const markDirty = useCallback(() => {
+    // A locked invoice cannot be saved, so nothing may queue an autosave that
+    // the server will only refuse five seconds later.
+    if (lockedRef.current) return
     setHasUnsavedChanges(true)
     setHasUnsavedChanges(true)
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
     autosaveTimer.current = setTimeout(() => {
     autosaveTimer.current = setTimeout(() => {
@@ -71,6 +81,20 @@ export function useServiceFormState({
     }, 5000)
     }, 5000)
   }, [])
   }, [])
 
 
+  // When the lock engages mid-session — the invoice was just sent, or an
+  // admin re-locked it — any save already queued can only be refused, and
+  // "Unsaved changes" would offer a save that can never complete (and keep
+  // the beforeunload warning armed). Drop both: the lock has closed every
+  // route those edits could take.
+  useEffect(() => {
+    if (!locked) return
+    if (autosaveTimer.current) {
+      clearTimeout(autosaveTimer.current)
+      autosaveTimer.current = null
+    }
+    setHasUnsavedChanges(false)
+  }, [locked])
+
   useEffect(() => {
   useEffect(() => {
     if (!hasUnsavedChanges) return
     if (!hasUnsavedChanges) return
     const handler = (e: BeforeUnloadEvent) => {
     const handler = (e: BeforeUnloadEvent) => {
@@ -372,6 +396,13 @@ export function useServiceFormState({
     dirtySetTaxRate,
     dirtySetTaxRate,
     dirtySetType,
     dirtySetType,
     dirtySetStatus,
     dirtySetStatus,
+    /**
+     * Sets the status without marking the form dirty. For a status that has
+     * already been persisted by updateServiceStatus: dirtying it there would
+     * show "Unsaved changes" for a change that is already saved, and the save
+     * it invites is refused if the invoice has since locked.
+     */
+    setStatus,
     dirtySetSelectedVehicleId,
     dirtySetSelectedVehicleId,
     // Warranty
     // Warranty
     warrantyMonths,
     warrantyMonths,

+ 6 - 0
src/features/vehicles/Lib/addPart.ts

@@ -1,6 +1,7 @@
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { calculateTotals } from '@/lib/tax'
 import { calculateTotals } from '@/lib/tax'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
+import { assertInvoiceEditable } from '@/lib/document-lock.server'
 
 
 /**
 /**
  * Adds a part line to a job, recalculates the job's money, and moves the stock.
  * Adds a part line to a job, recalculates the job's money, and moves the stock.
@@ -42,6 +43,11 @@ export async function addPart(args: {
 }) {
 }) {
   const { organizationId, userId, input } = args
   const { organizationId, userId, input } = args
 
 
+  // Guarded here rather than at each caller: the server action and the
+  // technician API both come through this function, and a lock enforced in
+  // only one of them is not a lock.
+  await assertInvoiceEditable(input.serviceRecordId, organizationId)
+
   const record = await db.serviceRecord.findFirst({
   const record = await db.serviceRecord.findFirst({
     where: { id: input.serviceRecordId, organizationId },
     where: { id: input.serviceRecordId, organizationId },
     select: {
     select: {

+ 2 - 0
src/i18n/request.ts

@@ -39,6 +39,7 @@ export default getRequestConfig(async () => {
   const dashboard = (await import(`../../messages/${locale}/dashboard.json`)).default
   const dashboard = (await import(`../../messages/${locale}/dashboard.json`)).default
   const navigation = (await import(`../../messages/${locale}/navigation.json`)).default
   const navigation = (await import(`../../messages/${locale}/navigation.json`)).default
   const customers = (await import(`../../messages/${locale}/customers.json`)).default
   const customers = (await import(`../../messages/${locale}/customers.json`)).default
+  const documentLock = (await import(`../../messages/${locale}/documentLock.json`)).default
   const messages = (await import(`../../messages/${locale}/messages.json`)).default
   const messages = (await import(`../../messages/${locale}/messages.json`)).default
   const vehicles = (await import(`../../messages/${locale}/vehicles.json`)).default
   const vehicles = (await import(`../../messages/${locale}/vehicles.json`)).default
   const workOrders = (await import(`../../messages/${locale}/workOrders.json`)).default
   const workOrders = (await import(`../../messages/${locale}/workOrders.json`)).default
@@ -82,6 +83,7 @@ export default getRequestConfig(async () => {
       dashboard,
       dashboard,
       navigation,
       navigation,
       customers,
       customers,
+      documentLock,
       messages,
       messages,
       vehicles,
       vehicles,
       workOrders,
       workOrders,

+ 123 - 0
src/lib/document-lock.server.ts

@@ -0,0 +1,123 @@
+import 'server-only'
+
+import { cache } from 'react'
+import { db } from './db'
+import {
+  assertEditable,
+  invoiceLockState,
+  quoteLockState,
+  readDocumentLockSettings,
+  type DocumentLockSettings,
+  type LockState,
+} from './document-lock'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+const LOCK_SETTING_KEYS = {
+  invoiceLockEnabled: SETTING_KEYS.INVOICE_LOCK_ENABLED,
+  invoiceLockTrigger: SETTING_KEYS.INVOICE_LOCK_TRIGGER,
+  quoteLockEnabled: SETTING_KEYS.QUOTE_LOCK_ENABLED,
+  quoteLockTrigger: SETTING_KEYS.QUOTE_LOCK_TRIGGER,
+}
+
+/**
+ * The org's lock configuration, read straight from the settings table.
+ * Wrapped in React's per-request cache so a render or action that checks the
+ * lock more than once pays for the settings query once.
+ */
+export const getDocumentLockSettings = cache(
+  async (organizationId: string): Promise<DocumentLockSettings> => {
+    const rows = await db.appSetting.findMany({
+      where: { organizationId, key: { in: Object.values(LOCK_SETTING_KEYS) } },
+      select: { key: true, value: true },
+    })
+    const map: Record<string, string> = {}
+    for (const row of rows) map[row.key] = row.value
+    return readDocumentLockSettings(map, LOCK_SETTING_KEYS)
+  }
+)
+
+/**
+ * The lock state of one invoice, loading only the fields the rules need.
+ * A record that does not exist is reported as editable, leaving the caller's
+ * own not-found handling to produce the error.
+ */
+export async function getInvoiceLockState(
+  recordId: string,
+  organizationId: string
+): Promise<LockState> {
+  const [record, settings] = await Promise.all([
+    db.serviceRecord.findFirst({
+      where: { id: recordId, organizationId },
+      select: {
+        sentAt: true,
+        manuallyPaid: true,
+        totalAmount: true,
+        cost: true,
+        editUnlockedAt: true,
+        payments: { select: { amount: true } },
+      },
+    }),
+    getDocumentLockSettings(organizationId),
+  ])
+  if (!record) return { locked: false, reason: null, unlockedAt: null }
+  return invoiceLockState(record, settings)
+}
+
+export async function getQuoteLockState(
+  quoteId: string,
+  organizationId: string
+): Promise<LockState> {
+  const [quote, settings] = await Promise.all([
+    db.quote.findFirst({
+      where: { id: quoteId, organizationId },
+      select: { status: true, sentAt: true, editUnlockedAt: true },
+    }),
+    getDocumentLockSettings(organizationId),
+  ])
+  if (!quote) return { locked: false, reason: null, unlockedAt: null }
+  return quoteLockState(quote, settings)
+}
+
+/**
+ * Refuses an edit to a locked invoice. Call this before any change to what the
+ * document says it is owed; payments, status and sharing do not go through it.
+ */
+export async function assertInvoiceEditable(recordId: string, organizationId: string) {
+  assertEditable(await getInvoiceLockState(recordId, organizationId))
+}
+
+export async function assertQuoteEditable(quoteId: string, organizationId: string) {
+  assertEditable(await getQuoteLockState(quoteId, organizationId))
+}
+
+/**
+ * Records that the invoice reached the customer, which is what "lock when
+ * sent" keys off. Every send channel — email, share link, SMS with the link —
+ * must come through here (or stamp sentAt in the same write, as
+ * generatePublicLink does): a channel that forgets makes the lock silently
+ * wrong for everyone who only uses that channel. Every send counts, not just
+ * the first, so re-issuing after an unlock locks the corrected copy too.
+ */
+export async function markInvoiceSent(recordId: string, organizationId: string) {
+  await db.serviceRecord.updateMany({
+    where: { id: recordId, organizationId },
+    data: { sentAt: new Date() },
+  })
+}
+
+/**
+ * The quote counterpart of markInvoiceSent. Also moves a draft to "sent",
+ * matching what emailing a quote has always done, so a shared link and an
+ * emailed copy agree about whether the quote went to the customer. Accepted
+ * and converted quotes keep their status; only sentAt moves.
+ */
+export async function markQuoteSent(quoteId: string, organizationId: string) {
+  await db.quote.updateMany({
+    where: { id: quoteId, organizationId },
+    data: { sentAt: new Date() },
+  })
+  await db.quote.updateMany({
+    where: { id: quoteId, organizationId, status: { notIn: ['accepted', 'converted'] } },
+    data: { status: 'sent' },
+  })
+}

+ 238 - 0
src/lib/document-lock.ts

@@ -0,0 +1,238 @@
+/**
+ * Whether a finished invoice or quote may still be edited.
+ *
+ * A workshop that has sent an invoice, or been paid for one, generally cannot
+ * treat it as a draft any more: the customer holds a copy, the numbers are in
+ * the books, and in many places an issued invoice is a document that must be
+ * corrected by a credit note rather than quietly rewritten. Locking makes the
+ * document match that reality instead of relying on everyone remembering.
+ *
+ * The lock is deliberately narrow. It freezes what the document *says it is
+ * owed* — its line items, discount, tax, number and date — and nothing else.
+ * Payments, status changes, sharing, attachments and internal notes all keep
+ * working, because a locked invoice that could not be paid would be worse than
+ * no lock at all. See `LOCKED_FIELDS` for the exact list.
+ *
+ * Everything here is pure: the state is derived from the document and the
+ * org's settings, never stored. That means turning the setting off releases
+ * every document at once with nothing to migrate, and no record can drift into
+ * disagreeing with the rule that produced it. The single exception is a
+ * deliberate unlock by an owner or admin, which is recorded on the row.
+ */
+
+/** When an invoice stops being editable. */
+export type InvoiceLockTrigger = 'sent' | 'paid'
+
+/** When a quote stops being editable. */
+export type QuoteLockTrigger = 'sent' | 'accepted'
+
+export const INVOICE_LOCK_TRIGGERS: InvoiceLockTrigger[] = ['sent', 'paid']
+export const QUOTE_LOCK_TRIGGERS: QuoteLockTrigger[] = ['sent', 'accepted']
+
+export interface DocumentLockSettings {
+  invoiceLockEnabled: boolean
+  invoiceLockTrigger: InvoiceLockTrigger
+  quoteLockEnabled: boolean
+  quoteLockTrigger: QuoteLockTrigger
+}
+
+/**
+ * Why a document is locked, or `null` when it is editable. The reason is
+ * carried rather than a bare boolean so the screen and the error message can
+ * say which rule applied, instead of "you cannot edit this".
+ */
+export type LockReason = 'sent' | 'paid' | 'accepted'
+
+export interface LockState {
+  locked: boolean
+  reason: LockReason | null
+  /** Set when an owner or admin has deliberately reopened the document. */
+  unlockedAt: Date | null
+}
+
+const EDITABLE: LockState = { locked: false, reason: null, unlockedAt: null }
+
+/**
+ * Defaults are chosen so an upgrade changes nothing: locking is off until
+ * someone turns it on. The triggers default to the later, less disruptive
+ * point of the two, so switching it on for the first time locks as little as
+ * possible.
+ */
+export const DOCUMENT_LOCK_DEFAULTS: DocumentLockSettings = {
+  invoiceLockEnabled: false,
+  invoiceLockTrigger: 'paid',
+  quoteLockEnabled: false,
+  quoteLockTrigger: 'accepted',
+}
+
+function readTrigger<T extends string>(value: string | undefined, allowed: T[], fallback: T): T {
+  return allowed.includes(value as T) ? (value as T) : fallback
+}
+
+/**
+ * Reads the stored settings, which are strings and may be absent or hand-
+ * edited. An unrecognised trigger falls back to the default rather than
+ * throwing: a bad settings row must not take out every invoice page.
+ */
+export function readDocumentLockSettings(
+  settings: Record<string, string | undefined>,
+  keys: {
+    invoiceLockEnabled: string
+    invoiceLockTrigger: string
+    quoteLockEnabled: string
+    quoteLockTrigger: string
+  }
+): DocumentLockSettings {
+  return {
+    invoiceLockEnabled: settings[keys.invoiceLockEnabled] === 'true',
+    invoiceLockTrigger: readTrigger(
+      settings[keys.invoiceLockTrigger],
+      INVOICE_LOCK_TRIGGERS,
+      DOCUMENT_LOCK_DEFAULTS.invoiceLockTrigger
+    ),
+    quoteLockEnabled: settings[keys.quoteLockEnabled] === 'true',
+    quoteLockTrigger: readTrigger(
+      settings[keys.quoteLockTrigger],
+      QUOTE_LOCK_TRIGGERS,
+      DOCUMENT_LOCK_DEFAULTS.quoteLockTrigger
+    ),
+  }
+}
+
+export type PaymentStatus = 'paid' | 'partial' | 'unpaid'
+
+export interface LockableInvoice {
+  /** Most recent time the invoice reached the customer, by email or link. */
+  sentAt: Date | null
+  /** Marked paid by hand, regardless of what has been recorded against it. */
+  manuallyPaid: boolean
+  totalAmount: number
+  cost: number
+  payments?: { amount: number }[]
+  editUnlockedAt?: Date | null
+}
+
+export interface LockableQuote {
+  status: string
+  /** Most recent time the quote reached the customer, by email or link. */
+  sentAt?: Date | null
+  editUnlockedAt?: Date | null
+}
+
+/**
+ * What an invoice is owed and what has been paid against it.
+ *
+ * `cost` is the fallback total for records predating itemised totals, matching
+ * what the header and the PDF display. A zero-total invoice with nothing
+ * recorded is unpaid, not paid: otherwise every empty draft would count as
+ * settled and lock itself the moment the setting was turned on.
+ */
+export function invoicePaymentStatus(invoice: {
+  manuallyPaid: boolean
+  totalAmount: number
+  cost: number
+  payments?: { amount: number }[]
+}): PaymentStatus {
+  const total = invoice.totalAmount > 0 ? invoice.totalAmount : invoice.cost
+  if (invoice.manuallyPaid) return 'paid'
+  const paid = (invoice.payments ?? []).reduce((sum, p) => sum + p.amount, 0)
+  if (paid <= 0) return 'unpaid'
+  // A payment against a zero-total document is a deposit on work not yet
+  // priced, not a settled bill: without the total > 0 guard a prepayment on an
+  // un-itemised draft would lock it before any lines exist. This also matches
+  // how the billing list decides "paid", so the badge and the lock agree.
+  return total > 0 && paid >= total ? 'paid' : 'partial'
+}
+
+/**
+ * An unlock is permission to correct *this* version of the document, so
+ * issuing it again spends it: the customer now holds the corrected copy, and
+ * that copy deserves the same protection the first one had. Without this an
+ * invoice unlocked once would stay editable for good, which is the quiet way a
+ * guardrail stops being one.
+ *
+ * Only sending supersedes an unlock, because only sending is a new issue of
+ * the document. Under the "paid" trigger the invoice was already paid when it
+ * was reopened, so nothing new happens to it and the unlock stands until
+ * someone locks it again by hand.
+ */
+function unlockSupersededBy(unlockedAt: Date | null | undefined, event: Date | null): boolean {
+  if (!unlockedAt || !event) return false
+  return event.getTime() > unlockedAt.getTime()
+}
+
+export function invoiceLockState(
+  invoice: LockableInvoice,
+  settings: DocumentLockSettings
+): LockState {
+  if (!settings.invoiceLockEnabled) return EDITABLE
+
+  if (settings.invoiceLockTrigger === 'sent') {
+    if (!invoice.sentAt) return EDITABLE
+    if (unlockSupersededBy(invoice.editUnlockedAt, invoice.sentAt)) {
+      return { locked: true, reason: 'sent', unlockedAt: null }
+    }
+    return invoice.editUnlockedAt
+      ? { locked: false, reason: null, unlockedAt: invoice.editUnlockedAt }
+      : { locked: true, reason: 'sent', unlockedAt: null }
+  }
+
+  if (invoice.editUnlockedAt) {
+    return { locked: false, reason: null, unlockedAt: invoice.editUnlockedAt }
+  }
+
+  // Partly paid still edits: the balance is often exactly what is being
+  // discussed, and locking there would strand the document mid-negotiation.
+  return invoicePaymentStatus(invoice) === 'paid'
+    ? { locked: true, reason: 'paid', unlockedAt: null }
+    : EDITABLE
+}
+
+/**
+ * Quote statuses that mean the customer has agreed to these numbers. A
+ * converted quote is the source of a job that is already running, so it counts
+ * too.
+ */
+const AGREED_QUOTE_STATUSES = ['accepted', 'converted']
+
+/** Statuses that mean the quote has left the workshop. */
+const ISSUED_QUOTE_STATUSES = ['sent', ...AGREED_QUOTE_STATUSES]
+
+export function quoteLockState(quote: LockableQuote, settings: DocumentLockSettings): LockState {
+  if (!settings.quoteLockEnabled) return EDITABLE
+
+  const lockedByStatus =
+    settings.quoteLockTrigger === 'sent'
+      ? ISSUED_QUOTE_STATUSES.includes(quote.status)
+      : AGREED_QUOTE_STATUSES.includes(quote.status)
+  if (!lockedByStatus) return EDITABLE
+
+  // An unlock is spent by sending the quote again, under either trigger:
+  // unlike an invoice, a quote's status survives a re-send (an accepted quote
+  // stays accepted), so without this the unlock would never expire and the
+  // corrected copy the customer re-accepts would stay editable for good.
+  if (quote.editUnlockedAt && !unlockSupersededBy(quote.editUnlockedAt, quote.sentAt ?? null)) {
+    return { locked: false, reason: null, unlockedAt: quote.editUnlockedAt }
+  }
+
+  return {
+    locked: true,
+    reason: settings.quoteLockTrigger === 'sent' ? 'sent' : 'accepted',
+    unlockedAt: null,
+  }
+}
+
+/**
+ * Raised when an edit is refused. Carries the reason so the caller can explain
+ * which rule applied rather than reporting a bare failure.
+ */
+export class DocumentLockedError extends Error {
+  constructor(readonly reason: LockReason) {
+    super(`This document is locked because it has been ${reason}.`)
+    this.name = 'DocumentLockedError'
+  }
+}
+
+export function assertEditable(state: LockState): void {
+  if (state.locked && state.reason) throw new DocumentLockedError(state.reason)
+}

+ 9 - 0
src/lib/with-api-auth.ts

@@ -4,6 +4,7 @@ import { auth } from './auth'
 import { db } from './db'
 import { db } from './db'
 import { hasAllPermissions, type PermissionInput } from './permissions'
 import { hasAllPermissions, type PermissionInput } from './permissions'
 import { rateLimit } from './rate-limit'
 import { rateLimit } from './rate-limit'
+import { DocumentLockedError } from './document-lock'
 
 
 /**
 /**
  * Request wrapper for the token-authenticated API the technician app talks to.
  * Request wrapper for the token-authenticated API the technician app talks to.
@@ -59,6 +60,7 @@ export type ApiErrorCode =
   | 'not_found'
   | 'not_found'
   | 'invalid_request'
   | 'invalid_request'
   | 'conflict'
   | 'conflict'
+  | 'document_locked'
   | 'server_error'
   | 'server_error'
 
 
 export function apiError(status: number, code: ApiErrorCode, message: string, extra?: unknown) {
 export function apiError(status: number, code: ApiErrorCode, message: string, extra?: unknown) {
@@ -186,6 +188,13 @@ export async function withApiAuth(
   } catch (err) {
   } catch (err) {
     // Zod messages describe the caller's own payload, so they are safe and
     // Zod messages describe the caller's own payload, so they are safe and
     // genuinely useful to return. Everything else is ours and stays here.
     // genuinely useful to return. Everything else is ours and stays here.
+    // A locked document is a refusal, not a fault: the technician needs to be
+    // told the job is locked (and to see the office about it), not to retry a
+    // request that can never succeed. The message names the rule that applied
+    // and is written for the caller, so it is safe to return.
+    if (err instanceof DocumentLockedError) {
+      return apiError(409, 'document_locked', err.message)
+    }
     if (err instanceof ZodError) {
     if (err instanceof ZodError) {
       return apiError(
       return apiError(
         400,
         400,