Skip to content

Commit 9109c6d

Browse files
committed
Fix #109 implement override of edit and deletion protections
1 parent e10f7dd commit 9109c6d

13 files changed

Lines changed: 158 additions & 22 deletions

File tree

backend/src/controllers/invoices.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getDatabase,
55
getNextInvoiceNumber,
66
} from "../database/init.ts";
7+
import { getSetting } from "./settings.ts";
78
import {
89
CreateInvoiceRequest,
910
Invoice,
@@ -51,6 +52,18 @@ type PerLineCalc = {
5152
summary: Array<{ percent: number; taxable: number; amount: number }>;
5253
};
5354

55+
function isInvoiceProtectionOverrideEnabled(): boolean {
56+
const raw = getSetting("allowProtectedInvoiceChanges");
57+
if (raw === null || raw === undefined) return false;
58+
const normalized = String(raw).trim().toLowerCase();
59+
return (
60+
normalized === "true" ||
61+
normalized === "1" ||
62+
normalized === "yes" ||
63+
normalized === "on"
64+
);
65+
}
66+
5467
function calculatePerLineTotals(
5568
items: ItemInput[],
5669
discountPercentage = 0,
@@ -772,7 +785,8 @@ export const updateInvoice = async (
772785
}
773786

774787
const isIssued = existing.status !== "draft";
775-
if (isIssued) {
788+
const allowProtectedChanges = isInvoiceProtectionOverrideEnabled();
789+
if (isIssued && !allowProtectedChanges) {
776790
const forbidden = [
777791
"items",
778792
"discountAmount",
@@ -1091,8 +1105,13 @@ export const deleteInvoice = async (id: string): Promise<boolean> => {
10911105
const existing = await getInvoiceById(id);
10921106
if (!existing) throw new Error("Invoice not found");
10931107

1108+
const allowProtectedChanges = isInvoiceProtectionOverrideEnabled();
10941109
// Only draft invoices can be deleted; issued invoices must be voided for audit trail
1095-
if (existing.status !== "draft" && existing.status !== "voided") {
1110+
if (
1111+
!allowProtectedChanges &&
1112+
existing.status !== "draft" &&
1113+
existing.status !== "voided"
1114+
) {
10961115
throw new Error(
10971116
"Only draft or voided invoices can be deleted. Void the invoice first.",
10981117
);

backend/src/database/migrations.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
2222
-- Optional default invoice number pattern (tokens: {YYYY} {YY} {MM} {DD} {DATE} {RAND4})
2323
('invoiceNumberPattern', ''),
2424
('invoiceNumberingEnabled', 'true'),
25+
('allowProtectedInvoiceChanges', 'false'),
2526
('embedXmlInHtml', 'false'),
2627
-- Optional PEPPOL endpoint configuration (leave empty if not applicable)
2728
('peppolSellerEndpointId', ''),

backend/src/database/migrations_clean.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
9494
('paymentMethods', 'Bank Transfer, PayPal, Credit Card'),
9595
('bankAccount', 'Account: 1234567890, Routing: 987654321'),
9696
('paymentTerms', 'Due in 30 days'),
97-
('defaultNotes', 'Thank you for your business!');
97+
('defaultNotes', 'Thank you for your business!'),
98+
('allowProtectedInvoiceChanges', 'false');
9899

99100
-- Insert a simple default template
100101
INSERT OR IGNORE INTO templates (id, name, html, is_default) VALUES

backend/src/routes/admin.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,23 @@ function normalizeLocaleSettingPayload(data: Record<string, unknown>) {
271271
}
272272
}
273273

274+
function normalizeInvoiceProtectionSettingsPayload(data: Record<string, unknown>) {
275+
if (
276+
!Object.prototype.hasOwnProperty.call(data, "allowProtectedInvoiceChanges")
277+
) {
278+
return;
279+
}
280+
const raw = String(data.allowProtectedInvoiceChanges ?? "")
281+
.toLowerCase()
282+
.trim();
283+
const truthy = new Set(["1", "true", "yes", "y", "on"]);
284+
(data as Record<string, unknown>).allowProtectedInvoiceChanges = truthy.has(
285+
raw,
286+
)
287+
? "true"
288+
: "false";
289+
}
290+
274291
// Demo mode flag (mutations allowed; periodic resets handle reverting state)
275292
const DEMO_MODE = isDemoMode();
276293

@@ -885,6 +902,9 @@ adminRoutes.get("/settings", async (c) => {
885902
if (!map.dateFormat) map.dateFormat = "YYYY-MM-DD";
886903
if (!map.numberFormat) map.numberFormat = "comma";
887904
if (!map.postalCityFormat) map.postalCityFormat = "auto";
905+
if (!map.allowProtectedInvoiceChanges) {
906+
map.allowProtectedInvoiceChanges = "false";
907+
}
888908
// Expose demo mode to frontend UI
889909
(map as Record<string, unknown>).demoMode = DEMO_MODE ? "true" : "false";
890910
return c.json(map);
@@ -906,6 +926,7 @@ adminRoutes.put(
906926
// Normalize tax-related settings
907927
normalizeTaxSettingsPayload(data);
908928
normalizeLocaleSettingPayload(data);
929+
normalizeInvoiceProtectionSettingsPayload(data);
909930
const settings = await updateSettings(data);
910931
try {
911932
if ("logoUrl" in data) deleteSetting("logoUrl");
@@ -946,6 +967,7 @@ adminRoutes.patch(
946967
// Normalize tax-related settings
947968
normalizeTaxSettingsPayload(data);
948969
normalizeLocaleSettingPayload(data);
970+
normalizeInvoiceProtectionSettingsPayload(data);
949971
const settings = await updateSettings(data);
950972
if (typeof data.templateId === "string" && data.templateId) {
951973
try {
@@ -1013,6 +1035,9 @@ adminRoutes.get("/admin/settings", async (c) => {
10131035
if (!map.dateFormat) map.dateFormat = "YYYY-MM-DD";
10141036
if (!map.numberFormat) map.numberFormat = "comma";
10151037
if (!map.postalCityFormat) map.postalCityFormat = "auto";
1038+
if (!map.allowProtectedInvoiceChanges) {
1039+
map.allowProtectedInvoiceChanges = "false";
1040+
}
10161041
// Expose demo mode to frontend UI for admin-prefixed route as well
10171042
(map as Record<string, unknown>).demoMode = DEMO_MODE ? "true" : "false";
10181043
return c.json(map);
@@ -1030,6 +1055,7 @@ adminRoutes.put(
10301055
// Normalize tax-related settings
10311056
normalizeTaxSettingsPayload(data);
10321057
normalizeLocaleSettingPayload(data);
1058+
normalizeInvoiceProtectionSettingsPayload(data);
10331059
const settings = await updateSettings(data);
10341060
try {
10351061
if ("logoUrl" in data) deleteSetting("logoUrl");
@@ -1052,6 +1078,7 @@ adminRoutes.patch(
10521078
// Normalize tax-related settings
10531079
normalizeTaxSettingsPayload(data);
10541080
normalizeLocaleSettingPayload(data);
1081+
normalizeInvoiceProtectionSettingsPayload(data);
10551082
const settings = await updateSettings(data);
10561083
try {
10571084
if ("logoUrl" in data) deleteSetting("logoUrl");

frontend/src/lib/i18n/locales/de.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"Appearance heading": "Darstellung",
1919
"Applies to invoice headings and status labels": "Gilt für Rechnungstitel und Statusbezeichnungen",
2020
"Apply": "Anwenden",
21+
"Allow editing and deleting sent/paid invoices": "Bearbeiten und Löschen von gesendeten/bezahlten Rechnungen erlauben",
22+
"Allowing edits/deletes for sent or paid invoices can violate invoice retention laws. Only enable this if you understand the legal impact.": "Das Bearbeiten/Löschen von gesendeten oder bezahlten Rechnungen kann gegen Aufbewahrungspflichten verstoßen. Aktivieren Sie dies nur, wenn Sie die rechtlichen Folgen kennen.",
2123
"Are you sure?": "Sind Sie sicher?",
2224
"Back to Customer": "Zurück zum Kunden",
2325
"Bank Account": "Bankkonto",
@@ -336,6 +338,7 @@
336338
"View all": "Alle anzeigen",
337339
"View public link": "Öffentlichen Link anzeigen",
338340
"Void Invoice": "Rechnung stornieren",
341+
"Warning: you are editing a sent/paid invoice. Ensure this is legally allowed in your jurisdiction.": "Warnung: Sie bearbeiten eine gesendete/bezahlte Rechnung. Stellen Sie sicher, dass dies in Ihrer Rechtsordnung zulässig ist.",
339342
"Void this invoice? The invoice will be preserved but marked as voided.": "Diese Rechnung stornieren? Die Rechnung wird aufbewahrt, aber als storniert markiert.",
340343
"Voided": "Storniert",
341344
"What you need to do:": "Was Sie tun müssen:",
@@ -344,6 +347,8 @@
344347
"XML profiles helper": "Profile sind momentan nur eingebaut. UBL 2.1 ist der Standard und bevorzugt für E-Rechnungs Netzwerke (PEPPOL). Das stub Profile ist für interne Testzwecke.",
345348
"YYYY-MM-DD (2025-01-15)": "JJJJ-MM-TT (2025-01-15)",
346349
"Yes": "Ja",
350+
"You are about to delete a sent/paid invoice. This may violate invoice retention laws and cannot be undone. Continue?": "Sie sind dabei, eine gesendete/bezahlte Rechnung zu löschen. Dies kann gegen Aufbewahrungspflichten verstoßen und kann nicht rückgängig gemacht werden. Fortfahren?",
351+
"You are about to edit a sent/paid invoice. Ensure this is legally allowed in your jurisdiction. Continue?": "Sie sind dabei, eine gesendete/bezahlte Rechnung zu bearbeiten. Stellen Sie sicher, dass dies in Ihrer Rechtsordnung zulässig ist. Fortfahren?",
347352
"password": "Passwort",
348353
"username": "Benutzername",
349354
"{{count}} item(s)": "{{count}} Position(en)"

frontend/src/lib/i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"2FA code": "2FA code",
1111
"2FA QR code": "2FA QR code",
1212
"Adjust the look and feel of the application.": "Adjust the look and feel of the application.",
13+
"Allow editing and deleting sent/paid invoices": "Allow editing and deleting sent/paid invoices",
14+
"Allowing edits/deletes for sent or paid invoices can violate invoice retention laws. Only enable this if you understand the legal impact.": "Allowing edits/deletes for sent or paid invoices can violate invoice retention laws. Only enable this if you understand the legal impact.",
1315
"Admin": "Admin",
1416
"Administrator": "Administrator",
1517
"Administrators have full access to all features.": "Administrators have full access to all features.",
@@ -271,6 +273,7 @@
271273
"View HTML": "View HTML",
272274
"View public link": "View public link",
273275
"Void Invoice": "Void Invoice",
276+
"Warning: you are editing a sent/paid invoice. Ensure this is legally allowed in your jurisdiction.": "Warning: you are editing a sent/paid invoice. Ensure this is legally allowed in your jurisdiction.",
274277
"Void this invoice?": "Void this invoice?",
275278
"Voided": "Voided",
276279
"Welcome to Invio": "Welcome to Invio",
@@ -280,6 +283,8 @@
280283
"XML Profile ID": "XML Profile ID",
281284
"Yes": "Yes",
282285
"Yes, disable": "Yes, disable",
286+
"You are about to delete a sent/paid invoice. This may violate invoice retention laws and cannot be undone. Continue?": "You are about to delete a sent/paid invoice. This may violate invoice retention laws and cannot be undone. Continue?",
287+
"You are about to edit a sent/paid invoice. Ensure this is legally allowed in your jurisdiction. Continue?": "You are about to edit a sent/paid invoice. Ensure this is legally allowed in your jurisdiction. Continue?",
283288
"You do not have permission to modify settings.": "You do not have permission to modify settings.",
284289
"You do not have permission to view customers. Contact an administrator to request access.": "You do not have permission to view customers. Contact an administrator to request access.",
285290
"You do not have permission to view invoices. Contact an administrator to request access.": "You do not have permission to view invoices. Contact an administrator to request access.",

frontend/src/lib/i18n/locales/nl.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"Appearance heading": "Weergave",
1919
"Applies to invoice headings and status labels": "Van toepassing op factuurtitels en statuslabels",
2020
"Apply": "Toepassen",
21+
"Allow editing and deleting sent/paid invoices": "Bewerken en verwijderen van verzonden/betaalde facturen toestaan",
22+
"Allowing edits/deletes for sent or paid invoices can violate invoice retention laws. Only enable this if you understand the legal impact.": "Het bewerken/verwijderen van verzonden of betaalde facturen kan in strijd zijn met de bewaarplicht. Schakel dit alleen in als je de juridische gevolgen begrijpt.",
2123
"Are you sure?": "Weet je het zeker?",
2224
"Back to Customer": "Terug naar klant",
2325
"Bank Account": "Bankrekening",
@@ -336,6 +338,7 @@
336338
"View all": "Alles bekijken",
337339
"View public link": "Openbare link bekijken",
338340
"Void Invoice": "Factuur nietig verklaren",
341+
"Warning: you are editing a sent/paid invoice. Ensure this is legally allowed in your jurisdiction.": "Waarschuwing: je bewerkt een verzonden/betaalde factuur. Controleer of dit juridisch is toegestaan in jouw rechtsgebied.",
339342
"Void this invoice? The invoice will be preserved but marked as voided.": "Deze factuur nietig verklaren? De factuur wordt bewaard maar gemarkeerd als nietig.",
340343
"Voided": "Nietig",
341344
"What you need to do:": "Wat je moet doen:",
@@ -344,6 +347,8 @@
344347
"XML profiles helper": "Profielen zijn momenteel alleen ingebouwd. UBL 2.1 is de standaard en heeft de voorkeur voor e-factuurnetwerken (PEPPOL). Het stub-profiel is voor interne tests.",
345348
"YYYY-MM-DD (2025-01-15)": "JJJJ-MM-DD (2025-01-15)",
346349
"Yes": "Ja",
350+
"You are about to delete a sent/paid invoice. This may violate invoice retention laws and cannot be undone. Continue?": "Je staat op het punt een verzonden/betaalde factuur te verwijderen. Dit kan in strijd zijn met de bewaarplicht en kan niet ongedaan worden gemaakt. Doorgaan?",
351+
"You are about to edit a sent/paid invoice. Ensure this is legally allowed in your jurisdiction. Continue?": "Je staat op het punt een verzonden/betaalde factuur te bewerken. Controleer of dit juridisch is toegestaan in jouw rechtsgebied. Doorgaan?",
347352
"password": "wachtwoord",
348353
"username": "gebruikersnaam",
349354
"{{count}} item(s)": "{{count}} artikel(en)"

frontend/src/lib/i18n/locales/pt-br.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"Appearance heading": "Aparência",
1919
"Applies to invoice headings and status labels": "Aplica-se aos cabeçalhos da fatura e etiquetas de status",
2020
"Apply": "Aplicar",
21+
"Allow editing and deleting sent/paid invoices": "Permitir edição e exclusão de faturas enviadas/pagas",
22+
"Allowing edits/deletes for sent or paid invoices can violate invoice retention laws. Only enable this if you understand the legal impact.": "Permitir edições/exclusões em faturas enviadas ou pagas pode violar leis de retenção de faturas. Ative apenas se você entender o impacto legal.",
2123
"Are you sure?": "Tem certeza?",
2224
"Back to Customer": "Voltar ao Cliente",
2325
"Bank Account": "Conta Bancária",
@@ -336,6 +338,7 @@
336338
"View all": "Ver tudo",
337339
"View public link": "Ver link público",
338340
"Void Invoice": "Anular fatura",
341+
"Warning: you are editing a sent/paid invoice. Ensure this is legally allowed in your jurisdiction.": "Aviso: você está editando uma fatura enviada/paga. Garanta que isso seja legalmente permitido na sua jurisdição.",
339342
"Void this invoice? The invoice will be preserved but marked as voided.": "Anular esta fatura? A fatura será preservada, mas marcada como anulada.",
340343
"Voided": "Anulada",
341344
"What you need to do:": "O que você precisa fazer:",
@@ -344,6 +347,8 @@
344347
"XML profiles helper": "Os perfis atualmente são apenas integrados. O UBL 2.1 é o padrão e preferido para redes de faturamento eletrônico (PEPPOL). O perfil 'stub' é para testes internos.",
345348
"YYYY-MM-DD (2025-01-15)": "AAAA-MM-DD (2025-01-15)",
346349
"Yes": "Sim",
350+
"You are about to delete a sent/paid invoice. This may violate invoice retention laws and cannot be undone. Continue?": "Você está prestes a excluir uma fatura enviada/paga. Isso pode violar leis de retenção de faturas e não pode ser desfeito. Continuar?",
351+
"You are about to edit a sent/paid invoice. Ensure this is legally allowed in your jurisdiction. Continue?": "Você está prestes a editar uma fatura enviada/paga. Garanta que isso seja legalmente permitido na sua jurisdição. Continuar?",
347352
"password": "senha",
348353
"username": "usuário",
349354
"{{count}} item(s)": "{{count}} item(ns)"

frontend/src/routes/invoices/[id]/+page.server.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,26 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
1313
}
1414

1515
try {
16-
const invoice = await backendGet(
17-
`/api/v1/invoices/` + params.id,
18-
locals.authHeader,
19-
);
16+
const [invoiceRes, settingsRes] = await Promise.allSettled([
17+
backendGet(`/api/v1/invoices/` + params.id, locals.authHeader),
18+
backendGet("/api/v1/settings", locals.authHeader),
19+
]);
20+
if (invoiceRes.status !== "fulfilled") {
21+
throw error(404, "Invoice not found");
22+
}
23+
const settings =
24+
settingsRes.status === "fulfilled"
25+
? (settingsRes.value as Record<string, unknown>)
26+
: {};
27+
const allowProtectedInvoiceChanges =
28+
String(settings.allowProtectedInvoiceChanges || "false").toLowerCase() ===
29+
"true";
2030
const showPublishedBanner = url.searchParams.get("published") === "1";
21-
return { invoice, showPublishedBanner };
31+
return {
32+
invoice: invoiceRes.value,
33+
showPublishedBanner,
34+
allowProtectedInvoiceChanges,
35+
};
2236
} catch (err: any) {
2337
throw error(404, "Invoice not found");
2438
}

frontend/src/routes/invoices/[id]/+page.svelte

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
let canDelete = $derived(hasPermission(user, "invoices", "delete"));
3232
let canPublish = $derived(hasPermission(user, "invoices", "publish"));
3333
let canVoid = $derived(hasPermission(user, "invoices", "void"));
34+
let allowProtectedInvoiceChanges = $derived(Boolean(data.allowProtectedInvoiceChanges));
35+
let isRetentionProtectedInvoice = $derived(invoice?.status === "sent" || invoice?.status === "paid" || invoice?.status === "complete" || invoice?.status === "overdue");
36+
let canEditInvoice = $derived(canUpdate && Boolean(invoice && (invoice.status === "draft" || (allowProtectedInvoiceChanges && invoice.status !== "voided"))));
37+
let canDeleteInvoice = $derived(canDelete && Boolean(invoice && (invoice.status === "draft" || invoice.status === "voided" || (allowProtectedInvoiceChanges && invoice.status !== "voided"))));
3438
3539
let paidPaymentMethod = $state("");
3640
@@ -64,11 +68,19 @@
6468
alert(t("Link copied!"));
6569
}
6670
67-
function confirmAction(message: string): SubmitFunction {
71+
function confirmAction(message: string | (() => string)): SubmitFunction {
6872
return ({ cancel }) => {
69-
if (!confirm(message)) cancel();
73+
const text = typeof message === "function" ? message() : message;
74+
if (!confirm(text)) cancel();
7075
};
7176
}
77+
78+
function confirmEditNavigation(event: MouseEvent) {
79+
if (!allowProtectedInvoiceChanges || !isRetentionProtectedInvoice) return;
80+
if (!confirm(t("You are about to edit a sent/paid invoice. Ensure this is legally allowed in your jurisdiction. Continue?"))) {
81+
event.preventDefault();
82+
}
83+
}
7284
</script>
7385

7486
<div class="mb-6">
@@ -156,14 +168,23 @@
156168
<form id="inv-void" method="post" class="hidden" use:enhance={confirmAction(t("Void this invoice?"))}>
157169
<input type="hidden" name="intent" value="void" />
158170
</form>
159-
<form id="inv-delete" method="post" class="hidden" use:enhance={confirmAction(t("Delete this invoice? This cannot be undone."))}>
171+
<form
172+
id="inv-delete"
173+
method="post"
174+
class="hidden"
175+
use:enhance={confirmAction(() =>
176+
isRetentionProtectedInvoice
177+
? t("You are about to delete a sent/paid invoice. This may violate invoice retention laws and cannot be undone. Continue?")
178+
: t("Delete this invoice? This cannot be undone."),
179+
)}
180+
>
160181
<input type="hidden" name="intent" value="delete" />
161182
</form>
162183

163184
{#if invoice}
164185
<div class="flex flex-wrap items-center gap-2">
165-
{#if invoice.status === "draft" && !isOverdue && canUpdate}
166-
<a href="/invoices/{invoice.id}/edit" class="btn btn-sm">
186+
{#if canEditInvoice}
187+
<a href="/invoices/{invoice.id}/edit" class="btn btn-sm" onclick={confirmEditNavigation}>
167188
<Pencil size={16} />
168189
<span class="hidden sm:inline">{t("Edit")}</span>
169190
</a>
@@ -279,7 +300,7 @@
279300
</button>
280301
</li>
281302
{/if}
282-
{#if (invoice.status === "draft" || invoice.status === "voided") && canDelete}
303+
{#if canDeleteInvoice}
283304
<li>
284305
<button type="submit" form="inv-delete" class="text-error flex items-center gap-2 py-2">
285306
<Trash2 size={16} />

0 commit comments

Comments
 (0)