mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -7,7 +7,8 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
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 { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
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 } : {}),
|
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Upload any pending receipt files
|
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||||
const uploadedFileIds: number[] = []
|
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||||
try {
|
editing
|
||||||
for (const f of pendingReceiptFiles) {
|
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||||
const fd = new FormData()
|
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||||
fd.append('file', f)
|
))
|
||||||
if (editing) {
|
// Only cleared once the save went through, so a retry after a failure
|
||||||
fd.append('budget_item_id', String(editing.id))
|
// does not upload a second copy of every file.
|
||||||
}
|
setPendingReceiptFiles([])
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error(t('common.unknownError'))
|
// 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 {
|
} finally {
|
||||||
|
setUploadingReceipt(false)
|
||||||
setSaving(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(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
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 === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
|
||||||
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
|
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKey)
|
// Capture phase, so this runs before the listeners the parents put on
|
||||||
return () => window.removeEventListener('keydown', handleKey)
|
// `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])
|
}, [index, receipts.length, onClose])
|
||||||
|
|
||||||
if (!current) return null
|
if (!current) return null
|
||||||
@@ -371,7 +382,7 @@ export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: Rec
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="bg-primary text-white"
|
className="bg-accent text-accent-text"
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 18px',
|
padding: '9px 18px',
|
||||||
borderRadius: 10,
|
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 { useExchangeRates } from '../../../../hooks/useExchangeRates'
|
||||||
import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters'
|
import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters'
|
||||||
import { openFile } from '../../../../utils/fileDownload'
|
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 { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
|
||||||
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
|
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
|
||||||
import { localToday } from '../../../../components/Planner/today'
|
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 } : {}),
|
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Upload any pending receipt files
|
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||||
const uploadedFileIds: number[] = []
|
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||||
try {
|
editing
|
||||||
for (const f of pendingReceiptFiles) {
|
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||||
const fd = new FormData()
|
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||||
fd.append('file', f)
|
))
|
||||||
if (editing) {
|
setPendingReceiptFiles([])
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error(t('common.unknownError'))
|
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||||
|
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
} finally {
|
||||||
|
setUploadingReceipt(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ describe('MCostSheet', () => {
|
|||||||
total_price: 85.5,
|
total_price: 85.5,
|
||||||
note: null,
|
note: null,
|
||||||
ticket_json: 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))
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4425,12 +4425,19 @@ function runMigrations(db: Database.Database): void {
|
|||||||
* SQLite treats NULLs as distinct, so the rows that exist today, which all
|
* SQLite treats NULLs as distinct, so the rows that exist today, which all
|
||||||
* carry a NULL here, do not collide with each other.
|
* 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.
|
* 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 }>;
|
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')) {
|
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 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)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)');
|
||||||
|
|||||||
@@ -204,34 +204,36 @@ export class BudgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move a receipt file to trash if it is only linked to this budget item and
|
* Drop this item's receipt links, leaving the files themselves alone.
|
||||||
* not referenced by any other reservation, place, or day assignment.
|
*
|
||||||
|
* 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) {
|
private unlinkReceipts(budgetItemId: number | string, keep: ReadonlySet<number> = new Set()) {
|
||||||
const file = this.db.get<{ id: number; place_id: number | null; reservation_id: number | null }>(
|
const rows = this.db.all<{ id: number; file_id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null }>(
|
||||||
'SELECT id, place_id, reservation_id FROM trip_files WHERE id = ? AND deleted_at IS NULL',
|
`SELECT fl.id, fl.file_id, fl.reservation_id, fl.assignment_id, fl.place_id
|
||||||
fileId,
|
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;
|
for (const row of rows) {
|
||||||
|
if (keep.has(row.file_id)) continue;
|
||||||
// Check if file is tied directly to another entity via legacy columns
|
if (row.reservation_id || row.assignment_id || row.place_id) {
|
||||||
if (file.place_id || file.reservation_id) return;
|
this.db.run('UPDATE file_links SET budget_item_id = NULL WHERE id = ?', row.id);
|
||||||
|
} else {
|
||||||
// Check if file is linked via file_links to another reservation, place, assignment, or other budget item
|
this.db.run('DELETE FROM file_links WHERE id = ?', row.id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -628,30 +630,24 @@ export class BudgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.receipt_file_ids !== undefined) {
|
if (data.receipt_file_ids !== undefined) {
|
||||||
// Find existing receipt file IDs for this budget item
|
// The ids that survive are left linked rather than dropped and re-added,
|
||||||
const oldReceiptFiles = this.db.all<{ id: number }>(`
|
// so a receipt the request keeps never loses its row for an instant.
|
||||||
SELECT f.id FROM trip_files f
|
const keep = new Set(data.receipt_file_ids.map(Number));
|
||||||
JOIN file_links fl ON fl.file_id = f.id
|
this.unlinkReceipts(id, keep);
|
||||||
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);
|
|
||||||
if (data.receipt_file_ids.length > 0) {
|
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 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) {
|
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);
|
const belongs = this.db.get('SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL', fid, tripId);
|
||||||
if (belongs) {
|
if (!belongs) continue;
|
||||||
insertLink.run(fid, id);
|
// 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,
|
||||||
// Any previously attached receipt that was unlinked now moves to trash if orphan
|
);
|
||||||
const newSet = new Set(data.receipt_file_ids);
|
if (spare) adopt.run(id, spare.id);
|
||||||
for (const oldFid of oldReceiptFiles) {
|
else insertLink.run(fid, id);
|
||||||
if (!newSet.has(oldFid)) {
|
|
||||||
this.trashOrphanReceipt(oldFid, id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,18 +683,12 @@ export class BudgetService {
|
|||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
return this.db.transaction(() => {
|
return this.db.transaction(() => {
|
||||||
// Find all receipts attached to this item before deleting it
|
// Find all receipts attached to this item before deleting it
|
||||||
const receiptFiles = this.db.all<{ id: number }>(`
|
// The receipts stay; only their tie to this expense goes. Rows that
|
||||||
SELECT f.id FROM trip_files f
|
// carry nothing else are removed, rows that also point at a place or a
|
||||||
JOIN file_links fl ON fl.file_id = f.id
|
// booking keep those. A link on a file already in the trash is left for
|
||||||
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
|
// the SET NULL on the foreign key, so restoring the file does not bring
|
||||||
`, id).map(r => r.id);
|
// back a pointer to an expense that no longer exists.
|
||||||
|
this.unlinkReceipts(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);
|
|
||||||
this.db.run('DELETE FROM budget_items WHERE id = ?', 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
|
// 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);
|
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 { user: alice } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, alice.id);
|
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] });
|
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());
|
expect(updated?.receipts?.map(r => r.id).sort()).toEqual([f2, f3].sort());
|
||||||
|
|
||||||
// Check database state
|
// Removing a receipt removes the link and nothing else. The file stays on
|
||||||
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
|
// the trip: deleting it needs file_delete, which this path never checks.
|
||||||
expect(f1Row.deleted_at).not.toBeNull();
|
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 };
|
||||||
const f4Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f4) as { deleted_at: string | null };
|
expect(row.deleted_at).toBeNull();
|
||||||
expect(f4Row.deleted_at).toBeNull();
|
}
|
||||||
|
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE budget_item_id = ?').get(item.id)).toEqual({ c: 2 });
|
||||||
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
|
// f4 keeps the link it has to the other expense.
|
||||||
expect(f2Row.deleted_at).toBeNull();
|
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 { user: alice } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, alice.id);
|
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);
|
const deleted = budget.deleteBudgetItem(item.id, trip.id);
|
||||||
expect(deleted).toBe(true);
|
expect(deleted).toBe(true);
|
||||||
|
|
||||||
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
|
// Neither file is touched; only the links to the deleted expense go.
|
||||||
expect(f1Row.deleted_at).not.toBeNull();
|
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 };
|
||||||
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
|
expect(row.deleted_at).toBeNull();
|
||||||
expect(f2Row.deleted_at).toBeNull();
|
}
|
||||||
|
|
||||||
const linkRows = testDb.prepare('SELECT * FROM file_links WHERE budget_item_id = ?').all(item.id);
|
const linkRows = testDb.prepare('SELECT * FROM file_links WHERE budget_item_id = ?').all(item.id);
|
||||||
expect(linkRows).toHaveLength(0);
|
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 { user: alice } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, alice.id);
|
const trip = createTrip(testDb, alice.id);
|
||||||
|
|
||||||
// Branch 1: non-existent file or already deleted
|
// budget_edit and file_delete are separate permissions and a receipt id is
|
||||||
const item = budget.createBudgetItem(trip.id, { name: 'BranchTest' });
|
// any file on the trip, so the budget domain must never delete one.
|
||||||
expect(() => budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] })).not.toThrow();
|
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
|
it('keeps a place link on a row that also carried the receipt link', () => {
|
||||||
const resId = Number(testDb.prepare('INSERT INTO reservations (trip_id, title) VALUES (?, ?)').run(trip.id, 'Flight').lastInsertRowid);
|
const { user: alice } = createUser(testDb);
|
||||||
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 trip = createTrip(testDb, alice.id);
|
||||||
const item2 = budget.createBudgetItem(trip.id, { name: 'ResItem', receipt_file_ids: [fRes] });
|
const place = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
|
||||||
budget.deleteBudgetItem(item2.id, trip.id);
|
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'menu.pdf', 'menu.pdf').lastInsertRowid);
|
||||||
const fResRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fRes) as { deleted_at: string | null };
|
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file, place);
|
||||||
expect(fResRow.deleted_at).toBeNull();
|
|
||||||
|
|
||||||
// Branch 3: file linked via file_links to a place or assignment
|
const item = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] });
|
||||||
const place2 = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
|
budget.deleteBudgetItem(item.id, trip.id);
|
||||||
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 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 };
|
||||||
const item3 = budget.createBudgetItem(trip.id, { name: 'LinkItem', receipt_file_ids: [fLink] });
|
expect(row.place_id).toBe(place);
|
||||||
budget.deleteBudgetItem(item3.id, trip.id);
|
expect(row.budget_item_id).toBeNull();
|
||||||
const fLinkRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fLink) as { deleted_at: string | null };
|
expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file) as { deleted_at: string | null }).deleted_at).toBeNull();
|
||||||
expect(fLinkRow.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_reservation_ids).toEqual([]);
|
||||||
expect(bareRow.linked_place_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.
|
// The empty-trip guard skips the IN () batch entirely.
|
||||||
const empty = createTrip(testDb, user.id);
|
const empty = createTrip(testDb, user.id);
|
||||||
expect(svc.listFiles(empty.id, false)).toEqual([]);
|
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 ───────────────────────────────────
|
// ── createFile / updateFile / toggleStarred ───────────────────────────────────
|
||||||
|
|
||||||
describe('createFile', () => {
|
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, { place_id: foreignPlace.id })).toBe('place_id');
|
||||||
expect(svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id');
|
expect(svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id');
|
||||||
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull();
|
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', () => {
|
it('FILE-SVC-028: falsy ids are skipped (they clear the link) and reservation is checked first', () => {
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'لا توجد إيصالات مرفقة',
|
'costs.noReceipts': 'لا توجد إيصالات مرفقة',
|
||||||
'costs.deleteReceipt': 'حذف الإيصال',
|
'costs.deleteReceipt': 'حذف الإيصال',
|
||||||
'costs.viewReceipt': 'عرض الإيصال',
|
'costs.viewReceipt': 'عرض الإيصال',
|
||||||
|
'costs.receiptLeftBehind': 'فشل الحفظ، وما زال هناك {count} إيصالات مرفوعة. احذفها من تبويب الملفات.',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Nenhum comprovante anexado',
|
'costs.noReceipts': 'Nenhum comprovante anexado',
|
||||||
'costs.deleteReceipt': 'Remover comprovante',
|
'costs.deleteReceipt': 'Remover comprovante',
|
||||||
'costs.viewReceipt': 'Ver 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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Cap rebut adjunt',
|
'costs.noReceipts': 'Cap rebut adjunt',
|
||||||
'costs.deleteReceipt': 'Eliminar rebut',
|
'costs.deleteReceipt': 'Eliminar rebut',
|
||||||
'costs.viewReceipt': 'Veure 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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Žádné připojené účtenky',
|
'costs.noReceipts': 'Žádné připojené účtenky',
|
||||||
'costs.deleteReceipt': 'Odebrat účtenku',
|
'costs.deleteReceipt': 'Odebrat účtenku',
|
||||||
'costs.viewReceipt': 'Zobrazit úč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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Keine Belege angehängt',
|
'costs.noReceipts': 'Keine Belege angehängt',
|
||||||
'costs.deleteReceipt': 'Beleg entfernen',
|
'costs.deleteReceipt': 'Beleg entfernen',
|
||||||
'costs.viewReceipt': 'Beleg ansehen',
|
'costs.viewReceipt': 'Beleg ansehen',
|
||||||
|
'costs.receiptLeftBehind': 'Speichern fehlgeschlagen, {count} hochgeladene Belege liegen noch da. Im Dateien-Tab entfernen.',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'No receipts attached',
|
'costs.noReceipts': 'No receipts attached',
|
||||||
'costs.deleteReceipt': 'Remove receipt',
|
'costs.deleteReceipt': 'Remove receipt',
|
||||||
'costs.viewReceipt': 'View 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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'No hay recibos adjuntos',
|
'costs.noReceipts': 'No hay recibos adjuntos',
|
||||||
'costs.deleteReceipt': 'Eliminar recibo',
|
'costs.deleteReceipt': 'Eliminar recibo',
|
||||||
'costs.viewReceipt': 'Ver 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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Aucun reçu joint',
|
'costs.noReceipts': 'Aucun reçu joint',
|
||||||
'costs.deleteReceipt': 'Supprimer le reçu',
|
'costs.deleteReceipt': 'Supprimer le reçu',
|
||||||
'costs.viewReceipt': 'Voir 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;
|
export default budget;
|
||||||
|
|||||||
@@ -154,5 +154,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Δεν υπάρχουν συνημμένες αποδείξεις',
|
'costs.noReceipts': 'Δεν υπάρχουν συνημμένες αποδείξεις',
|
||||||
'costs.deleteReceipt': 'Αφαίρεση απόδειξης',
|
'costs.deleteReceipt': 'Αφαίρεση απόδειξης',
|
||||||
'costs.viewReceipt': 'Προβολή απόδειξης',
|
'costs.viewReceipt': 'Προβολή απόδειξης',
|
||||||
|
'costs.receiptLeftBehind': 'Η αποθήκευση απέτυχε και {count} ανεβασμένες αποδείξεις παραμένουν. Διαγράψτε τες στην καρτέλα Αρχεία.',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Nincsenek csatolt nyugták',
|
'costs.noReceipts': 'Nincsenek csatolt nyugták',
|
||||||
'costs.deleteReceipt': 'Nyugta eltávolítása',
|
'costs.deleteReceipt': 'Nyugta eltávolítása',
|
||||||
'costs.viewReceipt': 'Nyugta megtekintése',
|
'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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Tidak ada kuitansi terlampir',
|
'costs.noReceipts': 'Tidak ada kuitansi terlampir',
|
||||||
'costs.deleteReceipt': 'Hapus kuitansi',
|
'costs.deleteReceipt': 'Hapus kuitansi',
|
||||||
'costs.viewReceipt': 'Lihat kuitansi',
|
'costs.viewReceipt': 'Lihat kuitansi',
|
||||||
|
'costs.receiptLeftBehind': 'Gagal menyimpan, dan {count} struk yang diunggah masih ada. Hapus di tab Berkas.',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Nessuna ricevuta allegata',
|
'costs.noReceipts': 'Nessuna ricevuta allegata',
|
||||||
'costs.deleteReceipt': 'Rimuovi ricevuta',
|
'costs.deleteReceipt': 'Rimuovi ricevuta',
|
||||||
'costs.viewReceipt': 'Visualizza ricevuta',
|
'costs.viewReceipt': 'Visualizza ricevuta',
|
||||||
|
'costs.receiptLeftBehind': 'Salvataggio non riuscito e {count} ricevute caricate sono ancora lì. Rimuovile nella scheda File.',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': '添付されたレシートはありません',
|
'costs.noReceipts': '添付されたレシートはありません',
|
||||||
'costs.deleteReceipt': 'レシートを削除',
|
'costs.deleteReceipt': 'レシートを削除',
|
||||||
'costs.viewReceipt': 'レシートを表示',
|
'costs.viewReceipt': 'レシートを表示',
|
||||||
|
'costs.receiptLeftBehind': '保存に失敗し、アップロード済みの領収書が {count} 件残っています。ファイルタブで削除してください。',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': '첨부된 영수증 없음',
|
'costs.noReceipts': '첨부된 영수증 없음',
|
||||||
'costs.deleteReceipt': '영수증 삭제',
|
'costs.deleteReceipt': '영수증 삭제',
|
||||||
'costs.viewReceipt': '영수증 보기',
|
'costs.viewReceipt': '영수증 보기',
|
||||||
|
'costs.receiptLeftBehind': '저장에 실패했고 업로드된 영수증 {count}개가 남아 있습니다. 파일 탭에서 삭제하세요.',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Geen bonnetjes bijgevoegd',
|
'costs.noReceipts': 'Geen bonnetjes bijgevoegd',
|
||||||
'costs.deleteReceipt': 'Bonnetje verwijderen',
|
'costs.deleteReceipt': 'Bonnetje verwijderen',
|
||||||
'costs.viewReceipt': 'Bonnetje bekijken',
|
'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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Brak załączonych paragonów',
|
'costs.noReceipts': 'Brak załączonych paragonów',
|
||||||
'costs.deleteReceipt': 'Usuń paragon',
|
'costs.deleteReceipt': 'Usuń paragon',
|
||||||
'costs.viewReceipt': 'Zobacz 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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Нет прикрепленных чеков',
|
'costs.noReceipts': 'Нет прикрепленных чеков',
|
||||||
'costs.deleteReceipt': 'Удалить чек',
|
'costs.deleteReceipt': 'Удалить чек',
|
||||||
'costs.viewReceipt': 'Просмотреть чек',
|
'costs.viewReceipt': 'Просмотреть чек',
|
||||||
|
'costs.receiptLeftBehind': 'Не удалось сохранить, {count} загруженных чеков остались. Удалите их на вкладке «Файлы».',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Inga bifogade kvitton',
|
'costs.noReceipts': 'Inga bifogade kvitton',
|
||||||
'costs.deleteReceipt': 'Ta bort kvitto',
|
'costs.deleteReceipt': 'Ta bort kvitto',
|
||||||
'costs.viewReceipt': 'Visa kvitto',
|
'costs.viewReceipt': 'Visa kvitto',
|
||||||
|
'costs.receiptLeftBehind': 'Sparandet misslyckades och {count} uppladdade kvitton finns kvar. Ta bort dem under Filer.',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Ekli fiş yok',
|
'costs.noReceipts': 'Ekli fiş yok',
|
||||||
'costs.deleteReceipt': 'Fişi kaldır',
|
'costs.deleteReceipt': 'Fişi kaldır',
|
||||||
'costs.viewReceipt': 'Fişi görüntüle',
|
'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;
|
export default budget;
|
||||||
|
|||||||
@@ -153,5 +153,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Немає доданих чеків',
|
'costs.noReceipts': 'Немає доданих чеків',
|
||||||
'costs.deleteReceipt': 'Видалити чек',
|
'costs.deleteReceipt': 'Видалити чек',
|
||||||
'costs.viewReceipt': 'Переглянути чек',
|
'costs.viewReceipt': 'Переглянути чек',
|
||||||
|
'costs.receiptLeftBehind': 'Не вдалося зберегти, {count} завантажених чеків залишилися. Видаліть їх на вкладці «Файли».',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': 'Chưa đính kèm hóa đơn nào',
|
'costs.noReceipts': 'Chưa đính kèm hóa đơn nào',
|
||||||
'costs.deleteReceipt': 'Xóa hóa đơn',
|
'costs.deleteReceipt': 'Xóa hóa đơn',
|
||||||
'costs.viewReceipt': 'Xem 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;
|
export default budget;
|
||||||
|
|||||||
@@ -152,5 +152,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': '未附加收據',
|
'costs.noReceipts': '未附加收據',
|
||||||
'costs.deleteReceipt': '刪除收據',
|
'costs.deleteReceipt': '刪除收據',
|
||||||
'costs.viewReceipt': '查看收據',
|
'costs.viewReceipt': '查看收據',
|
||||||
|
'costs.receiptLeftBehind': '儲存失敗,還有 {count} 張已上傳的收據。請到「檔案」分頁刪除。',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -152,5 +152,6 @@ const budget: TranslationStrings = {
|
|||||||
'costs.noReceipts': '未附加收据',
|
'costs.noReceipts': '未附加收据',
|
||||||
'costs.deleteReceipt': '删除收据',
|
'costs.deleteReceipt': '删除收据',
|
||||||
'costs.viewReceipt': '查看收据',
|
'costs.viewReceipt': '查看收据',
|
||||||
|
'costs.receiptLeftBehind': '保存失败,还有 {count} 个已上传的收据。请在“文件”标签页中删除。',
|
||||||
};
|
};
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
Reference in New Issue
Block a user