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
+17 -50
View File
@@ -7,7 +7,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi, filesApi } from '../../api/client'
import { budgetApi } from '../../api/client'
import { saveWithReceipts } from './receiptUploads'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
@@ -1372,57 +1373,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
// Upload any pending receipt files
const uploadedFileIds: number[] = []
try {
for (const f of pendingReceiptFiles) {
const fd = new FormData()
fd.append('file', f)
if (editing) {
fd.append('budget_item_id', String(editing.id))
}
const res = await filesApi.upload(tripId, fd)
if (res?.file?.id) {
uploadedFileIds.push(res.file.id)
}
}
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
let createdOrUpdated: BudgetItem
if (editing) {
try {
createdOrUpdated = await updateBudgetItem(tripId, editing.id, {
...data,
receipt_file_ids: [...receipts.map(r => r.id), ...uploadedFileIds],
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
} else {
try {
createdOrUpdated = await addBudgetItem(tripId, {
...data,
receipt_file_ids: uploadedFileIds,
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
}
setUploadingReceipt(pendingReceiptFiles.length > 0)
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
editing
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
))
// Only cleared once the save went through, so a retry after a failure
// does not upload a second copy of every file.
setPendingReceiptFiles([])
onSaved()
} catch {
toast.error(t('common.unknownError'))
} catch (err) {
// A receipt the rollback could not remove is still on the trip, and the
// user is the only one who can clear it out of the Files tab.
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
} finally {
setUploadingReceipt(false)
setSaving(false)
}
}
@@ -0,0 +1,99 @@
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
// on top of the expense form.
import { vi } from 'vitest'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetItemReceipt } from '../../types'
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
return {
id: 1,
filename: 'stored.jpg',
original_name: 'lunch.jpg',
mime_type: 'image/jpeg',
file_size: 100,
url: '/uploads/files/stored.jpg',
...over,
} as BudgetItemReceipt
}
beforeEach(() => {
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
})
describe('ReceiptPreviewModal', () => {
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
})
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
// The viewer renders through a portal, so it lives on document.body.
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
expect(screen.queryByRole('img')).toBeNull()
})
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
expect(document.body.querySelector('object')).toBeNull()
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
authUrl.get.mockRejectedValue(new Error('403'))
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
fireEvent.keyDown(window, { key: 'ArrowRight' })
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
})
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
})
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
const onClose = vi.fn()
const parent = vi.fn()
document.addEventListener('keydown', parent)
try {
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
// The expense form registers its own Escape handler on `document`; one
// keypress must not close both and throw away the user's edits.
expect(parent).not.toHaveBeenCalled()
} finally {
document.removeEventListener('keydown', parent)
}
})
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
expect(document.body.querySelector('object')).toBeNull()
expect(screen.queryByRole('img')).toBeNull()
})
})
@@ -45,12 +45,23 @@ export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: Rec
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape') {
// The preview opens on top of the expense form, and both the desktop
// Modal and the phone MSheet close themselves on Escape from a listener
// on `document`. Without this the one keypress closed the form too and
// threw away whatever the user had typed into it.
e.preventDefault()
e.stopPropagation()
onClose()
return
}
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
// Capture phase, so this runs before the listeners the parents put on
// `document`; stopPropagation on the bubble phase would be too late.
window.addEventListener('keydown', handleKey, true)
return () => window.removeEventListener('keydown', handleKey, true)
}, [index, receipts.length, onClose])
if (!current) return null
@@ -371,7 +382,7 @@ export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: Rec
<button
type="button"
onClick={handleDownload}
className="bg-primary text-white"
className="bg-accent text-accent-text"
style={{
padding: '9px 18px',
borderRadius: 10,
@@ -0,0 +1,91 @@
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
// expense, and taking the uploads back out when the expense fails to save.
import { vi } from 'vitest'
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
const files = vi.hoisted(() => ({
upload: vi.fn(),
delete: vi.fn(),
permanentDelete: vi.fn(),
}))
vi.mock('../../api/client', () => ({ filesApi: files }))
function file(name: string) {
return new File(['x'], name, { type: 'image/jpeg' })
}
beforeEach(() => {
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
files.delete.mockReset().mockResolvedValue({})
files.permanentDelete.mockReset().mockResolvedValue({})
})
describe('uploadReceipts', () => {
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
await uploadReceipts(3, [file('a.jpg')], 42)
const fd = files.upload.mock.calls[0][1] as FormData
expect(fd.get('budget_item_id')).toBe('42')
})
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
await uploadReceipts(3, [file('a.jpg')], null)
const fd = files.upload.mock.calls[0][1] as FormData
expect(fd.get('budget_item_id')).toBeNull()
})
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
files.upload
.mockResolvedValueOnce({ file: { id: 11 } })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ file: { id: 13 } })
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
})
})
describe('discardReceipts', () => {
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
const order: string[] = []
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
expect(await discardReceipts(3, [9])).toEqual([])
expect(order).toEqual(['delete', 'permanent'])
})
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
files.delete.mockRejectedValueOnce(new Error('403'))
expect(await discardReceipts(3, [9, 10])).toEqual([9])
})
})
describe('saveWithReceipts', () => {
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
const save = vi.fn().mockResolvedValue('saved')
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
expect(save).toHaveBeenCalledWith([5, 6])
expect(out.result).toBe('saved')
expect(files.delete).not.toHaveBeenCalled()
})
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
const save = vi.fn().mockRejectedValue(new Error('nope'))
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
})
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
files.delete.mockRejectedValueOnce(new Error('403'))
const save = vi.fn()
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
expect(save).not.toHaveBeenCalled()
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
expect(err.stuckReceiptIds).toEqual([5])
})
})
@@ -0,0 +1,87 @@
import { filesApi } from '../../api/client'
/**
* Uploading receipts alongside an expense, and undoing it when the expense
* itself fails to save.
*
* One module for both shells. The desktop form and the phone sheet each had
* their own copy of this, three near-identical try/catch blocks apiece, which is
* six places for the same bug to sit and six copies against the duplication
* budget the PR gate measures.
*/
/** Upload each file and return the ids the server handed back, in order. */
export async function uploadReceipts(
tripId: number | string,
files: readonly File[],
budgetItemId: number | null,
): Promise<number[]> {
const ids: number[] = []
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
// Only an existing expense can be named here. A new one has no id yet, so
// its receipts are linked by the create call instead.
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
const res = await filesApi.upload(tripId, fd)
if (res?.file?.id) ids.push(res.file.id)
}
return ids
}
/**
* Undo an upload after the save it belonged to failed.
*
* Two calls per file, and the order matters: the permanent route only accepts a
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
* delete puts it there first. It also drops the receipt link the upload created,
* so a failed edit does not leave the file attached to the expense.
*
* Returns the ids it could not remove. The caller is expected to say so rather
* than swallow it: a file left behind is a file the user has to find in the
* Files tab and delete by hand.
*/
export async function discardReceipts(
tripId: number | string,
ids: readonly number[],
): Promise<number[]> {
const stuck: number[] = []
for (const id of ids) {
try {
await filesApi.delete(tripId, id)
await filesApi.permanentDelete(tripId, id)
} catch {
stuck.push(id)
}
}
return stuck
}
/**
* Upload the pending receipts, run the save, and take the uploads back out
* again if it throws.
*
* The rollback runs for a failure in the save AND for one partway through the
* upload loop, which is why the ids are collected outside the try.
*/
export async function saveWithReceipts<T>(
tripId: number | string,
files: readonly File[],
budgetItemId: number | null,
save: (uploadedIds: number[]) => Promise<T>,
): Promise<{ result: T; stuckIds: number[] }> {
const uploaded: number[] = []
try {
for (const file of files) {
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
if (id != null) uploaded.push(id)
}
const result = await save(uploaded)
return { result, stuckIds: [] }
} catch (err) {
const stuck = await discardReceipts(tripId, uploaded)
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
throw err
}
}
@@ -11,7 +11,7 @@ import { useTripStore } from '../../../../store/tripStore'
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters'
import { openFile } from '../../../../utils/fileDownload'
import { filesApi } from '../../../../api/client'
import { saveWithReceipts } from '../../../../components/Budget/receiptUploads'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { localToday } from '../../../../components/Planner/today'
@@ -291,56 +291,20 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
// Upload any pending receipt files
const uploadedFileIds: number[] = []
try {
for (const f of pendingReceiptFiles) {
const fd = new FormData()
fd.append('file', f)
if (editing) {
fd.append('budget_item_id', String(editing.id))
}
const res = await filesApi.upload(tripId, fd)
if (res?.file?.id) {
uploadedFileIds.push(res.file.id)
}
}
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
if (editing) {
try {
await updateBudgetItem(tripId, editing.id, {
...data,
receipt_file_ids: [...receipts.map(r => r.id), ...uploadedFileIds],
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
} else {
try {
await addBudgetItem(tripId, {
...data,
receipt_file_ids: uploadedFileIds,
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
}
setUploadingReceipt(pendingReceiptFiles.length > 0)
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
editing
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
))
setPendingReceiptFiles([])
onSaved()
} catch {
toast.error(t('common.unknownError'))
} catch (err) {
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
setSaving(false)
} finally {
setUploadingReceipt(false)
}
}
@@ -128,6 +128,9 @@ describe('MCostSheet', () => {
total_price: 85.5,
note: null,
ticket_json: null,
// Always sent, empty when nothing was attached: the server treats the
// array as the full set of receipts for the expense.
receipt_file_ids: [],
})
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
})
+8 -1
View File
@@ -4425,12 +4425,19 @@ function runMigrations(db: Database.Database): void {
* SQLite treats NULLs as distinct, so the rows that exist today, which all
* carry a NULL here, do not collide with each other.
*
* SET NULL rather than CASCADE, unlike the three columns beside it, because
* those predate the shared row: one row can carry a place link AND a receipt
* link for the same file, and a cascade would delete the whole row when the
* expense goes, silently detaching the file from the place as well. Deleting
* the expense drops the receipt link and nothing else; the budget service
* removes the row afterwards when it carries no other link.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const flCols = db.prepare("SELECT name FROM pragma_table_info('file_links')").all() as Array<{ name: string }>;
if (!flCols.some((c) => c.name === 'budget_item_id')) {
db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE CASCADE');
db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)');
+48 -58
View File
@@ -204,34 +204,36 @@ export class BudgetService {
}
/**
* Move a receipt file to trash if it is only linked to this budget item and
* not referenced by any other reservation, place, or day assignment.
* Drop this item's receipt links, leaving the files themselves alone.
*
* The files are deliberately NOT trashed. `budget_edit` and `file_delete` are
* separate, separately configurable permissions, and a receipt id is any file
* on the trip, so trashing here would let anyone who may edit an expense
* delete a document they may not delete, from three surfaces that never see a
* file permission at all: the REST route, the MCP tool and the plugin RPC.
* Cleaning up a file nobody references is what the Files trash is for.
*
* A row is only removed when the receipt link was all it carried. The same
* row can also tie the file to a place or a booking, and those links have
* nothing to do with the expense. A link whose file already sits in the trash
* is left in place, so restoring that file brings it back attached.
*/
private trashOrphanReceipt(fileId: number | string, budgetItemId: number | string) {
const file = this.db.get<{ id: number; place_id: number | null; reservation_id: number | null }>(
'SELECT id, place_id, reservation_id FROM trip_files WHERE id = ? AND deleted_at IS NULL',
fileId,
private unlinkReceipts(budgetItemId: number | string, keep: ReadonlySet<number> = new Set()) {
const rows = this.db.all<{ id: number; file_id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null }>(
`SELECT fl.id, fl.file_id, fl.reservation_id, fl.assignment_id, fl.place_id
FROM file_links fl
JOIN trip_files f ON f.id = fl.file_id
WHERE fl.budget_item_id = ? AND f.deleted_at IS NULL`,
budgetItemId,
);
if (!file) return;
// Check if file is tied directly to another entity via legacy columns
if (file.place_id || file.reservation_id) return;
// Check if file is linked via file_links to another reservation, place, assignment, or other budget item
const otherLinks = this.db.get<{ count: number }>(`
SELECT COUNT(*) as count FROM file_links
WHERE file_id = ? AND (
reservation_id IS NOT NULL OR
place_id IS NOT NULL OR
assignment_id IS NOT NULL OR
(budget_item_id IS NOT NULL AND budget_item_id != ?)
)
`, fileId, budgetItemId);
if (otherLinks && otherLinks.count > 0) return;
// It is an orphan receipt solely belonging to this budget item: move to trash
this.db.run('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?', fileId);
for (const row of rows) {
if (keep.has(row.file_id)) continue;
if (row.reservation_id || row.assignment_id || row.place_id) {
this.db.run('UPDATE file_links SET budget_item_id = NULL WHERE id = ?', row.id);
} else {
this.db.run('DELETE FROM file_links WHERE id = ?', row.id);
}
}
}
// -------------------------------------------------------------------------
@@ -628,30 +630,24 @@ export class BudgetService {
}
if (data.receipt_file_ids !== undefined) {
// Find existing receipt file IDs for this budget item
const oldReceiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Sync receipts: delete previous links and add new ones
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
// The ids that survive are left linked rather than dropped and re-added,
// so a receipt the request keeps never loses its row for an instant.
const keep = new Set(data.receipt_file_ids.map(Number));
this.unlinkReceipts(id, keep);
if (data.receipt_file_ids.length > 0) {
const insertLink = this.db.prepare('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)');
const adopt = this.db.prepare('UPDATE file_links SET budget_item_id = ? WHERE id = ?');
for (const fid of data.receipt_file_ids) {
const belongs = this.db.get('SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL', fid, tripId);
if (belongs) {
insertLink.run(fid, id);
}
}
}
// Any previously attached receipt that was unlinked now moves to trash if orphan
const newSet = new Set(data.receipt_file_ids);
for (const oldFid of oldReceiptFiles) {
if (!newSet.has(oldFid)) {
this.trashOrphanReceipt(oldFid, id);
if (!belongs) continue;
// A file already tied to a place or a booking gets the receipt link
// written onto that row, because the unique index is per file and
// item and a second row for the same pair would be refused anyway.
const spare = this.db.get<{ id: number }>(
'SELECT id FROM file_links WHERE file_id = ? AND budget_item_id IS NULL LIMIT 1', fid,
);
if (spare) adopt.run(id, spare.id);
else insertLink.run(fid, id);
}
}
}
@@ -687,18 +683,12 @@ export class BudgetService {
if (!item) return false;
return this.db.transaction(() => {
// Find all receipts attached to this item before deleting it
const receiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Move exclusively attached receipts to trash
for (const fid of receiptFiles) {
this.trashOrphanReceipt(fid, id);
}
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
// The receipts stay; only their tie to this expense goes. Rows that
// carry nothing else are removed, rows that also point at a place or a
// booking keep those. A link on a file already in the trash is left for
// the SET NULL on the foreign key, so restoring the file does not bring
// back a pointer to an expense that no longer exists.
this.unlinkReceipts(id);
this.db.run('DELETE FROM budget_items WHERE id = ?', id);
// The booking keeps a copy of this expense's total in its metadata, and
@@ -974,7 +974,7 @@ describe('an expense whose split leaves a remainder', () => {
expect(found?.receipts?.[0].id).toBe(fileId);
});
it('updates receipts on updateBudgetItem, trashing orphans and keeping shared receipts', () => {
it('updates receipts on updateBudgetItem without deleting any file', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
@@ -994,18 +994,18 @@ describe('an expense whose split leaves a remainder', () => {
const updated = budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [f2, f3] });
expect(updated?.receipts?.map(r => r.id).sort()).toEqual([f2, f3].sort());
// Check database state
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
expect(f1Row.deleted_at).not.toBeNull();
const f4Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f4) as { deleted_at: string | null };
expect(f4Row.deleted_at).toBeNull();
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
expect(f2Row.deleted_at).toBeNull();
// Removing a receipt removes the link and nothing else. The file stays on
// the trip: deleting it needs file_delete, which this path never checks.
for (const fid of [f1, f2, f3, f4]) {
const row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fid) as { deleted_at: string | null };
expect(row.deleted_at).toBeNull();
}
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE budget_item_id = ?').get(item.id)).toEqual({ c: 2 });
// f4 keeps the link it has to the other expense.
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(f4)).toEqual({ c: 1 });
});
it('trashes orphan receipts on deleteBudgetItem, but preserves shared files', () => {
it('unlinks receipts on deleteBudgetItem and leaves every file in place', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
@@ -1019,39 +1019,66 @@ describe('an expense whose split leaves a remainder', () => {
const deleted = budget.deleteBudgetItem(item.id, trip.id);
expect(deleted).toBe(true);
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
expect(f1Row.deleted_at).not.toBeNull();
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
expect(f2Row.deleted_at).toBeNull();
// Neither file is touched; only the links to the deleted expense go.
for (const fid of [f1, f2]) {
const row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fid) as { deleted_at: string | null };
expect(row.deleted_at).toBeNull();
}
const linkRows = testDb.prepare('SELECT * FROM file_links WHERE budget_item_id = ?').all(item.id);
expect(linkRows).toHaveLength(0);
});
it('handles all branches of trashOrphanReceipt safely', () => {
it('unlinks receipts without ever trashing the file itself', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// Branch 1: non-existent file or already deleted
const item = budget.createBudgetItem(trip.id, { name: 'BranchTest' });
expect(() => budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] })).not.toThrow();
// budget_edit and file_delete are separate permissions and a receipt id is
// any file on the trip, so the budget domain must never delete one.
const plain = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'r.pdf', 'r.pdf').lastInsertRowid);
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [plain] });
budget.deleteBudgetItem(item.id, trip.id);
expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(plain) as { deleted_at: string | null }).deleted_at).toBeNull();
// The link is gone, because it was all the row carried.
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(plain)).toEqual({ c: 0 });
});
// Branch 2: file linked to reservation
const resId = Number(testDb.prepare('INSERT INTO reservations (trip_id, title) VALUES (?, ?)').run(trip.id, 'Flight').lastInsertRowid);
const fRes = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, reservation_id) VALUES (?, ?, ?, ?)').run(trip.id, 'res.pdf', 'res.pdf', resId).lastInsertRowid);
const item2 = budget.createBudgetItem(trip.id, { name: 'ResItem', receipt_file_ids: [fRes] });
budget.deleteBudgetItem(item2.id, trip.id);
const fResRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fRes) as { deleted_at: string | null };
expect(fResRow.deleted_at).toBeNull();
it('keeps a place link on a row that also carried the receipt link', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const place = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'menu.pdf', 'menu.pdf').lastInsertRowid);
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file, place);
// Branch 3: file linked via file_links to a place or assignment
const place2 = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
const fLink = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'link.pdf', 'link.pdf').lastInsertRowid);
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(fLink, place2);
const item3 = budget.createBudgetItem(trip.id, { name: 'LinkItem', receipt_file_ids: [fLink] });
budget.deleteBudgetItem(item3.id, trip.id);
const fLinkRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fLink) as { deleted_at: string | null };
expect(fLinkRow.deleted_at).toBeNull();
const item = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] });
budget.deleteBudgetItem(item.id, trip.id);
const row = testDb.prepare('SELECT place_id, budget_item_id FROM file_links WHERE file_id = ?').get(file) as { place_id: number | null; budget_item_id: number | null };
expect(row.place_id).toBe(place);
expect(row.budget_item_id).toBeNull();
expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file) as { deleted_at: string | null }).deleted_at).toBeNull();
});
it('leaves a receipt already in the trash linked, so restoring it comes back attached', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'old.pdf', 'old.pdf').lastInsertRowid);
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', receipt_file_ids: [file] });
testDb.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(file);
// A save that no longer mentions the trashed receipt must not drop its link.
budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id)).toEqual({ c: 1 });
});
it('an edit that keeps a receipt does not churn its link row', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'keep.pdf', 'keep.pdf').lastInsertRowid);
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', receipt_file_ids: [file] });
const before = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number };
budget.updateBudgetItem(item.id, trip.id, { name: 'Hotel 2', receipt_file_ids: [file] });
const after = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number };
expect(after.id).toBe(before.id);
});
});
@@ -251,12 +251,64 @@ describe('listFiles', () => {
expect(bareRow.linked_reservation_ids).toEqual([]);
expect(bareRow.linked_place_ids).toEqual([]);
const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid);
svc.createFileLink(linked.id, { budget_item_id: item });
const withReceipt = (svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === linked.id);
expect(withReceipt.linked_budget_item_ids).toEqual([item]);
expect((svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === bare.id).linked_budget_item_ids).toEqual([]);
// The empty-trip guard skips the IN () batch entirely.
const empty = createTrip(testDb, user.id);
expect(svc.listFiles(empty.id, false)).toEqual([]);
});
});
// ── receipts on an expense ────────────────────────────────────────────────────
describe('budget receipts', () => {
function seedItem(tripId: number) {
return Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(tripId, 'Dinner').lastInsertRowid);
}
it('FILE-SVC-040: an upload naming an expense gets its link row straight away', () => {
const { user, trip } = seedTrip();
const item = seedItem(trip.id);
const file = makeFile(trip.id, user.id, {}, { budget_item_id: item });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
});
it('FILE-SVC-041: an upload without one writes no link at all', () => {
const { user, trip } = seedTrip();
const file = makeFile(trip.id, user.id);
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 0 });
});
it('FILE-SVC-042: updateFile attaches to an expense and detaches on a falsy id', () => {
const { user, trip } = seedTrip();
const item = seedItem(trip.id);
const file = makeFile(trip.id, user.id);
svc.updateFile(file.id, file, { budget_item_id: item });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
// Sending it twice must not double the row.
svc.updateFile(file.id, file, { budget_item_id: item });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 1 });
svc.updateFile(file.id, file, { budget_item_id: null });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id IS NOT NULL').get(file.id)).toEqual({ c: 0 });
});
it('FILE-SVC-043: leaving budget_item_id out touches no link', () => {
const { user, trip } = seedTrip();
const item = seedItem(trip.id);
const file = makeFile(trip.id, user.id, {}, { budget_item_id: item });
svc.updateFile(file.id, file, { description: 'renamed' });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
});
});
// ── createFile / updateFile / toggleStarred ───────────────────────────────────
describe('createFile', () => {
@@ -424,6 +476,12 @@ describe('findForeignLinkTarget', () => {
expect(svc.findForeignLinkTarget(mine.id, { place_id: foreignPlace.id })).toBe('place_id');
expect(svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id');
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull();
// A receipt may only point at an expense on the same trip.
const foreignItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(foreign.id, 'Foreign').lastInsertRowid);
const myItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(mine.id, 'Mine').lastInsertRowid);
expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: foreignItem })).toBe('budget_item_id');
expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: myItem })).toBeNull();
});
it('FILE-SVC-028: falsy ids are skipped (they clear the link) and reservation is checked first', () => {
+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;