fix(budget): stop receipts deleting files, and share the upload path

Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
This commit is contained in:
Maurice
2026-09-08 21:37:49 +02:00
committed by Maurice
parent 06ecc5135c
commit 6a3d4b1281
34 changed files with 525 additions and 198 deletions
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'لا توجد إيصالات مرفقة',
'costs.deleteReceipt': 'حذف الإيصال',
'costs.viewReceipt': 'عرض الإيصال',
'costs.receiptLeftBehind': 'فشل الحفظ، وما زال هناك {count} إيصالات مرفوعة. احذفها من تبويب الملفات.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Nenhum comprovante anexado',
'costs.deleteReceipt': 'Remover comprovante',
'costs.viewReceipt': 'Ver comprovante',
'costs.receiptLeftBehind': 'Falha ao salvar, e {count} recibos enviados ainda estão lá. Remova-os na aba Arquivos.',
};
export default budget;
+1
View File
@@ -153,6 +153,7 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Cap rebut adjunt',
'costs.deleteReceipt': 'Eliminar rebut',
'costs.viewReceipt': 'Veure rebut',
'costs.receiptLeftBehind': 'No es pot desar i encara hi ha {count} rebuts pujats. Elimina aquests fitxers a la pestanya Fitxers.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Žádné připojené účtenky',
'costs.deleteReceipt': 'Odebrat účtenku',
'costs.viewReceipt': 'Zobrazit účtenku',
'costs.receiptLeftBehind': 'Uložení selhalo, {count} nahraných účtenek tam stále je. Odstraňte je v záložce Soubory.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Keine Belege angehängt',
'costs.deleteReceipt': 'Beleg entfernen',
'costs.viewReceipt': 'Beleg ansehen',
'costs.receiptLeftBehind': 'Speichern fehlgeschlagen, {count} hochgeladene Belege liegen noch da. Im Dateien-Tab entfernen.',
};
export default budget;
+1
View File
@@ -153,6 +153,7 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'No receipts attached',
'costs.deleteReceipt': 'Remove receipt',
'costs.viewReceipt': 'View receipt',
'costs.receiptLeftBehind': 'Save failed, and {count} uploaded receipts are still there. Remove them in the Files tab.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'No hay recibos adjuntos',
'costs.deleteReceipt': 'Eliminar recibo',
'costs.viewReceipt': 'Ver recibo',
'costs.receiptLeftBehind': 'Error al guardar y quedan {count} recibos subidos. Elimínalos en la pestaña Archivos.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Aucun reçu joint',
'costs.deleteReceipt': 'Supprimer le reçu',
'costs.viewReceipt': 'Voir le reçu',
'costs.receiptLeftBehind': 'Échec de la sauvegarde, {count} reçus téléversés sont toujours là. Supprimez-les dans les Fichiers.',
};
export default budget;
+1
View File
@@ -154,5 +154,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Δεν υπάρχουν συνημμένες αποδείξεις',
'costs.deleteReceipt': 'Αφαίρεση απόδειξης',
'costs.viewReceipt': 'Προβολή απόδειξης',
'costs.receiptLeftBehind': 'Η αποθήκευση απέτυχε και {count} ανεβασμένες αποδείξεις παραμένουν. Διαγράψτε τες στην καρτέλα Αρχεία.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Nincsenek csatolt nyugták',
'costs.deleteReceipt': 'Nyugta eltávolítása',
'costs.viewReceipt': 'Nyugta megtekintése',
'costs.receiptLeftBehind': 'A mentés nem sikerült, és {count} feltöltött blokk még ott van. Törölje őket a Fájlok fülön.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Tidak ada kuitansi terlampir',
'costs.deleteReceipt': 'Hapus kuitansi',
'costs.viewReceipt': 'Lihat kuitansi',
'costs.receiptLeftBehind': 'Gagal menyimpan, dan {count} struk yang diunggah masih ada. Hapus di tab Berkas.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Nessuna ricevuta allegata',
'costs.deleteReceipt': 'Rimuovi ricevuta',
'costs.viewReceipt': 'Visualizza ricevuta',
'costs.receiptLeftBehind': 'Salvataggio non riuscito e {count} ricevute caricate sono ancora lì. Rimuovile nella scheda File.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': '添付されたレシートはありません',
'costs.deleteReceipt': 'レシートを削除',
'costs.viewReceipt': 'レシートを表示',
'costs.receiptLeftBehind': '保存に失敗し、アップロード済みの領収書が {count} 件残っています。ファイルタブで削除してください。',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': '첨부된 영수증 없음',
'costs.deleteReceipt': '영수증 삭제',
'costs.viewReceipt': '영수증 보기',
'costs.receiptLeftBehind': '저장에 실패했고 업로드된 영수증 {count}개가 남아 있습니다. 파일 탭에서 삭제하세요.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Geen bonnetjes bijgevoegd',
'costs.deleteReceipt': 'Bonnetje verwijderen',
'costs.viewReceipt': 'Bonnetje bekijken',
'costs.receiptLeftBehind': 'Opslaan mislukt en er staan nog {count} geüploade bonnen. Verwijder ze op het tabblad Bestanden.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Brak załączonych paragonów',
'costs.deleteReceipt': 'Usuń paragon',
'costs.viewReceipt': 'Zobacz paragon',
'costs.receiptLeftBehind': 'Zapis nie powiódł się, a {count} przesłanych paragonów wciąż tam jest. Usuń je w zakładce Pliki.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Нет прикрепленных чеков',
'costs.deleteReceipt': 'Удалить чек',
'costs.viewReceipt': 'Просмотреть чек',
'costs.receiptLeftBehind': 'Не удалось сохранить, {count} загруженных чеков остались. Удалите их на вкладке «Файлы».',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Inga bifogade kvitton',
'costs.deleteReceipt': 'Ta bort kvitto',
'costs.viewReceipt': 'Visa kvitto',
'costs.receiptLeftBehind': 'Sparandet misslyckades och {count} uppladdade kvitton finns kvar. Ta bort dem under Filer.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Ekli fiş yok',
'costs.deleteReceipt': 'Fişi kaldır',
'costs.viewReceipt': 'Fişi görüntüle',
'costs.receiptLeftBehind': 'Kaydetme başarısız oldu ve yüklenen {count} fiş hâlâ duruyor. Dosyalar sekmesinden silin.',
};
export default budget;
+1
View File
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Немає доданих чеків',
'costs.deleteReceipt': 'Видалити чек',
'costs.viewReceipt': 'Переглянути чек',
'costs.receiptLeftBehind': 'Не вдалося зберегти, {count} завантажених чеків залишилися. Видаліть їх на вкладці «Файли».',
};
export default budget;
+1
View File
@@ -153,6 +153,7 @@ const budget: TranslationStrings = {
'costs.noReceipts': 'Chưa đính kèm hóa đơn nào',
'costs.deleteReceipt': 'Xóa hóa đơn',
'costs.viewReceipt': 'Xem hóa đơn',
'costs.receiptLeftBehind': 'Lưu thất bại và {count} biên lai đã tải lên vẫn còn. Hãy xoá chúng trong tab Tệp.',
};
export default budget;
+1
View File
@@ -152,5 +152,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': '未附加收據',
'costs.deleteReceipt': '刪除收據',
'costs.viewReceipt': '查看收據',
'costs.receiptLeftBehind': '儲存失敗,還有 {count} 張已上傳的收據。請到「檔案」分頁刪除。',
};
export default budget;
+1
View File
@@ -152,5 +152,6 @@ const budget: TranslationStrings = {
'costs.noReceipts': '未附加收据',
'costs.deleteReceipt': '删除收据',
'costs.viewReceipt': '查看收据',
'costs.receiptLeftBehind': '保存失败,还有 {count} 个已上传的收据。请在“文件”标签页中删除。',
};
export default budget;