diff --git a/backend/src/db/bookkeeping.ts b/backend/src/db/bookkeeping.ts index 773efff0..5de3d36d 100644 --- a/backend/src/db/bookkeeping.ts +++ b/backend/src/db/bookkeeping.ts @@ -14,6 +14,7 @@ import { cleanItem } from "./cleanItem"; import { TABLE_BOOKKEEPING } from "./tableNames"; import { classifyMandatoryPayment } from "../finance/mandatoryPayments"; import { isOwnerWithdrawalReportExempt } from "../finance/ownerPay"; +import { deriveInventoryItems, inventoryItemId, inventoryItemSeed } from "../finance/inventory"; import { BOOKKEEPING_AUDIT_PARTITION_PREFIX, BOOKKEEPING_AUDIT_STORAGE_VERSION, @@ -344,7 +345,44 @@ export async function putBookkeepingTransactionWithItems( } throw error; } - return { item: clean(item)!, duplicate: false }; + const written = { item: clean(item)!, duplicate: false }; + await syncInventoryForTransactions(client, [written.item]); + return written; +} + +/** + * Derive inventory entries from freshly written ledger transactions so a + * physical purchase shows up in the inventory without a manual scan. Runs + * after the ledger write and is intentionally not atomic with it: entries are + * keyed by transaction/allocation seed and existing entries are left + * untouched, so manual inventory edits survive and a failed sync is repaired + * by the next "Scan ledger". + */ +export async function syncInventoryForTransactions( + client: DynamoDBDocumentClient, + transactions: readonly Record[], +) { + for (const candidate of deriveInventoryItems(transactions)) { + const entry: Record = { + id: inventoryItemId(candidate.transactionId, candidate.allocationId), + transactionId: candidate.transactionId, + allocationId: candidate.allocationId, + name: candidate.name, + type: candidate.type, + vendor: candidate.vendor, + acquisitionDate: candidate.acquisitionDate, + amount: candidate.amount, + currency: candidate.currency, + amountEur: candidate.amountEur, + status: "in-use", + }; + await putBookkeepingItem( + client, + "inventory", + Object.fromEntries(Object.entries(entry).filter(([, child]) => child !== undefined)), + inventoryItemSeed(candidate.transactionId, candidate.allocationId), + ); + } } async function getBookkeepingItemRaw( @@ -814,7 +852,9 @@ export async function updateBookkeepingTransaction( throw new BookkeepingItemLockedError(); throw error; } - return getBookkeepingItem(client, "bookkeeping", id); + const updated = await getBookkeepingItem(client, "bookkeeping", id); + if (updated) await syncInventoryForTransactions(client, [updated]); + return updated; } export type PrepareDocumentInput = { diff --git a/backend/src/finance/inventory.ts b/backend/src/finance/inventory.ts index b12c62d8..277668e2 100644 --- a/backend/src/finance/inventory.ts +++ b/backend/src/finance/inventory.ts @@ -17,6 +17,21 @@ export type InventoryType = (typeof INVENTORY_TYPES)[number]; export const INVENTORY_STATUSES = ["in-use", "disposed"] as const; export type InventoryStatus = (typeof INVENTORY_STATUSES)[number]; +// A disposal is the exit record for an inventory item: sold off or +// decommissioned (scrapped, recycled, given away without payment). +export const INVENTORY_DISPOSAL_KINDS = ["sold", "decommissioned"] as const; +export type InventoryDisposalKind = (typeof INVENTORY_DISPOSAL_KINDS)[number]; + +export type InventoryDisposal = { + id: string; + kind: InventoryDisposalKind; + date: string; + /** Money received, required when the item was sold. */ + proceeds?: string; + currency?: string; + notes?: string; +}; + export const DEFAULT_INVENTORY_FROM_YEAR = 2022; // Ordered: the first matching bucket wins, so "Surface Pro 12" Keyboard" @@ -85,8 +100,12 @@ type TransactionLike = { }; const text = (value: unknown) => (typeof value === "string" ? value.trim() : ""); -const money = (value: unknown) => - typeof value === "string" && /^(0|[1-9]\d{0,11})(\.\d{1,4})?$/.test(value) ? value : undefined; + +/** Non-negative decimal amount as stored on ledger items, e.g. "13.99". */ +export function isValidMoney(value: unknown): value is string { + return typeof value === "string" && /^(0|[1-9]\d{0,11})(\.\d{1,4})?$/.test(value); +} +const money = (value: unknown) => (isValidMoney(value) ? value : undefined); /** * Physical purchases among expense transactions from `fromYear` on. A payment @@ -151,3 +170,77 @@ export function deriveInventoryItems( } return candidates.filter((candidate) => candidate.transactionId && candidate.name && candidate.acquisitionDate); } + +export type InventorySourceRecord = { + transactionId: string; + allocationId?: string; + description: string; + counterparty: string; + date: string; + amount?: string; + currency?: string; + amountEur?: string; +}; + +/** + * The ledger record an inventory item came from, read back from the current + * transaction so the tab shows when it was bought and for what price even + * after the ledger row was corrected. Allocation-backed items describe + * themselves from their allocation row. Returns null when the transaction + * (or the referenced allocation row) no longer exists. + */ +export function inventorySourceRecord( + item: { transactionId?: unknown; allocationId?: unknown }, + transaction: Record | null | undefined, +): InventorySourceRecord | null { + if (!transaction) return null; + const transactionId = text(transaction.id); + if (!transactionId || transactionId !== text(item.transactionId)) return null; + const date = text(transaction.paidDate) || text(transaction.transactionDate); + const base = { + transactionId, + date, + counterparty: text(transaction.counterparty), + amount: money(transaction.amount), + currency: text(transaction.currency), + amountEur: text(transaction.currency) === "EUR" ? money(transaction.amount) : money(transaction.amountEur), + }; + const allocationId = text(item.allocationId); + if (!allocationId) return { ...base, description: text(transaction.description) }; + const rows = Array.isArray(transaction.allocations) ? transaction.allocations : []; + const row = rows.find( + (candidate) => candidate && typeof candidate === "object" && text((candidate as Record).id) === allocationId, + ) as Record | undefined; + if (!row) return null; + return { + transactionId, + allocationId, + description: text(row.description) || text(transaction.description), + counterparty: text(row.counterparty) || base.counterparty, + date: text(row.paidDate) || text(row.transactionDate) || base.date, + amount: money(row.amount) ?? money(row.grossAmountEur) ?? base.amount, + currency: text(row.currency) || base.currency, + amountEur: money(row.amountEur) ?? money(row.grossAmountEur) + ?? (text(row.currency) === "EUR" ? money(row.amount) : undefined) ?? base.amountEur, + }; +} + +/** + * Disposal records drive the item status: any record means disposed, and the + * disposed date is the latest record's date. Returns the writable fields for + * updateBookkeepingItem; `disposedDate: undefined` removes the stale stamp + * when the last record is gone. + */ +export function deriveInventoryDisposalState( + disposals: readonly InventoryDisposal[], +): { disposals: InventoryDisposal[]; status: InventoryStatus; disposedDate?: string } { + const disposed = disposals.length > 0; + const records = [...disposals]; + return { + disposals: records, + status: disposed ? "disposed" : "in-use", + disposedDate: disposed + ? records.reduce((latest, record) => (record.date > latest ? record.date : latest), records[0].date) + : undefined, + }; +} diff --git a/backend/src/routes/bookkeepingInventory.ts b/backend/src/routes/bookkeepingInventory.ts index 39efec9e..bfb60013 100644 --- a/backend/src/routes/bookkeepingInventory.ts +++ b/backend/src/routes/bookkeepingInventory.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import type { LambdaEvent, LambdaResponse } from "../types"; import { @@ -13,12 +14,16 @@ import { json, parse } from "./bookkeepingShared"; import { realDate } from "../sponsorCrm/model"; import { DEFAULT_INVENTORY_FROM_YEAR, - INVENTORY_STATUSES, + INVENTORY_DISPOSAL_KINDS, INVENTORY_TYPES, + deriveInventoryDisposalState, deriveInventoryItems, inventoryItemId, inventoryItemSeed, - type InventoryStatus, + inventorySourceRecord, + isValidMoney, + type InventoryDisposal, + type InventoryDisposalKind, type InventoryType, } from "../finance/inventory"; @@ -40,15 +45,62 @@ function validateInventoryEdit(input: Record): string[] { errors.push("name"); if (input.type !== undefined && !INVENTORY_TYPES.includes(String(input.type) as InventoryType)) errors.push("type"); - if (input.status !== undefined && !INVENTORY_STATUSES.includes(String(input.status) as InventoryStatus)) - errors.push("status"); - if (input.disposedDate !== undefined && input.disposedDate !== null && !realDate(input.disposedDate)) - errors.push("disposedDate"); + // Status and disposedDate follow the disposal records; editing them + // directly is rejected so clients cannot fight the derivation. + if (input.status !== undefined) errors.push("status"); + if (input.disposedDate !== undefined) errors.push("disposedDate"); if (input.notes !== undefined && input.notes !== null && (typeof input.notes !== "string" || input.notes.length > 2000)) errors.push("notes"); return errors; } +/** + * Validate a disposal record (sell / decommission) or the removal of one. + * Status and disposedDate are derived from the records themselves, so they + * are not writable fields. + */ +function validateDisposalChange(input: Record): string[] { + const errors: string[] = []; + const disposal = input.disposal as Record | undefined; + if (disposal !== undefined) { + if (!disposal || typeof disposal !== "object" || Array.isArray(disposal)) + errors.push("disposal"); + else { + if (!INVENTORY_DISPOSAL_KINDS.includes(String(disposal.kind) as InventoryDisposalKind)) + errors.push("disposal.kind"); + if (!realDate(disposal.date)) errors.push("disposal.date"); + const sold = disposal.kind === "sold"; + if (disposal.proceeds === undefined) { + if (sold) errors.push("disposal.proceeds"); + } else if (!isValidMoney(disposal.proceeds)) errors.push("disposal.proceeds"); + if (disposal.currency !== undefined && disposal.currency !== null + && (typeof disposal.currency !== "string" || !/^[A-Za-z]{3}$/.test(disposal.currency))) + errors.push("disposal.currency"); + if (disposal.notes !== undefined && disposal.notes !== null + && (typeof disposal.notes !== "string" || String(disposal.notes).length > 500)) + errors.push("disposal.notes"); + } + } + if (input.removeDisposalId !== undefined + && (typeof input.removeDisposalId !== "string" || !input.removeDisposalId.trim())) + errors.push("removeDisposalId"); + return errors; +} + +/** Attach the current ledger record each item was derived from. */ +async function enrichWithSource( + client: DynamoDBDocumentClient, + items: Record[], +): Promise[]> { + if (!items.length) return items; + const transactions = await listTransactions(client); + const byId = new Map(transactions.map((transaction) => [String(transaction.id), transaction])); + return items.map((item) => { + const source = inventorySourceRecord(item, byId.get(String(item.transactionId || ""))); + return source ? { ...item, source } : item; + }); +} + export async function handleBookkeepingInventoryRoutes( path: string, method: string, @@ -56,7 +108,7 @@ export async function handleBookkeepingInventoryRoutes( client: DynamoDBDocumentClient, ): Promise { if (path === "/api/bookkeeping/inventory" && method === "GET") { - const items = await listBookkeepingItems(client, "inventory"); + const items = await enrichWithSource(client, await listBookkeepingItems(client, "inventory")); return json(200, { items: items.map(publicInventoryItem) }); } @@ -111,11 +163,15 @@ export async function handleBookkeepingInventoryRoutes( const body = parse(event.body || null); if (method === "GET") { const existing = await getBookkeepingItem(client, "inventory", id); - return existing ? json(200, publicInventoryItem(existing)) : json(404, { error: "Not found" }); + if (!existing) return json(404, { error: "Not found" }); + const [enriched] = await enrichWithSource(client, [publicInventoryItem(existing)]); + return json(200, enriched); } if (method === "PUT") { if (!body) return json(400, { error: "Invalid JSON" }); - const errors = validateInventoryEdit(body); + if (body.disposal !== undefined && body.removeDisposalId !== undefined) + return json(400, { error: "Add or remove one disposal per request", fields: ["disposal", "removeDisposalId"] }); + const errors = [...validateInventoryEdit(body), ...validateDisposalChange(body)]; if (errors.length) return json(400, { error: "Invalid inventory item", fields: errors }); const expectedUpdatedAt = typeof body.expectedUpdatedAt === "string" && body.expectedUpdatedAt.trim() @@ -126,23 +182,51 @@ export async function handleBookkeepingInventoryRoutes( const value: Record = {}; if (body.name !== undefined) value.name = String(body.name).trim(); if (body.type !== undefined) value.type = String(body.type); - if (body.status !== undefined) value.status = String(body.status); if (body.notes !== undefined) value.notes = body.notes === null || !String(body.notes).trim() ? undefined : String(body.notes); - if (body.disposedDate !== undefined) - value.disposedDate = body.disposedDate === null ? undefined : String(body.disposedDate); - if (body.status !== undefined && body.status !== "disposed" && body.disposedDate === undefined) - value.disposedDate = undefined; + if (body.disposal !== undefined || body.removeDisposalId !== undefined) { + const existing = await getBookkeepingItem(client, "inventory", id); + if (!existing) return json(404, { error: "Not found" }); + const records = (Array.isArray(existing.disposals) ? existing.disposals : []) + .filter((record): record is InventoryDisposal => Boolean(record) && typeof record === "object"); + let next: InventoryDisposal[]; + if (body.disposal !== undefined) { + const disposal = body.disposal as Record; + const record: InventoryDisposal = { + id: randomUUID(), + kind: String(disposal.kind) as InventoryDisposalKind, + date: String(disposal.date), + }; + if (isValidMoney(disposal.proceeds)) { + record.proceeds = String(disposal.proceeds); + record.currency = disposal.currency ? String(disposal.currency).toUpperCase() : "EUR"; + } + if (disposal.notes !== undefined && disposal.notes !== null && String(disposal.notes).trim()) + record.notes = String(disposal.notes); + next = [...records, record]; + } else { + const removeId = String(body.removeDisposalId); + if (!records.some((record) => record.id === removeId)) + return json(404, { error: "Unknown disposal record", fields: ["removeDisposalId"] }); + next = records.filter((record) => record.id !== removeId); + } + // Status follows the records: disposing stamps the item, removing the + // last record puts it back in use. + Object.assign(value, deriveInventoryDisposalState(next)); + } // Ledger-seeded fields are not editable; a request naming none of the // editable ones is a read-back, not a write. if (!Object.keys(value).length) { const existing = await getBookkeepingItem(client, "inventory", id); - return existing ? json(200, publicInventoryItem(existing)) : json(404, { error: "Not found" }); + if (!existing) return json(404, { error: "Not found" }); + const [enriched] = await enrichWithSource(client, [publicInventoryItem(existing)]); + return json(200, enriched); } try { const updated = await updateBookkeepingItem(client, "inventory", id, expectedUpdatedAt, value); if (!updated) return json(404, { error: "Not found" }); - return json(200, publicInventoryItem(updated)); + const [enriched] = await enrichWithSource(client, [publicInventoryItem(updated)]); + return json(200, enriched); } catch (error) { if (error instanceof BookkeepingItemLockedError) return json(409, { error: "Inventory item changed; refresh and retry." }); diff --git a/backend/tests/bookkeeping-inventory.test.ts b/backend/tests/bookkeeping-inventory.test.ts index 53ea7902..e316d06b 100644 --- a/backend/tests/bookkeeping-inventory.test.ts +++ b/backend/tests/bookkeeping-inventory.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert'; import { handler } from '../src/handler'; import { getClient } from '../src/db/client'; import { startLocal, stopLocal, createTables } from '../scripts/local-dynamodb'; -import { putBookkeepingItem } from '../src/db/bookkeeping'; +import { putBookkeepingItem, putBookkeepingTransactionWithItems, updateBookkeepingTransaction, getBookkeepingItem } from '../src/db/bookkeeping'; import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { classifyPhysicalItem, @@ -133,27 +133,92 @@ describe('inventory API', () => { assert.ok(stored.items.every((item: any) => item.transactionId && item.acquisitionDate)); }); - it('rejects invalid edits and updates status, notes and the disposed date', async () => { + it('exposes the ledger record each item was derived from', async () => { + const listed = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body); + const laptop = listed.items.find((item: any) => item.name === 'Laptop'); + assert.deepEqual(laptop.source, { + transactionId: 'inv-tx-laptop', + description: 'Laptop', + counterparty: 'Synthetic Vendor', + date: '2026-03-19', + amount: '1813.58', + currency: 'EUR', + amountEur: '1813.58', + }); + const allocationItem = listed.items.find((item: any) => item.allocationId === 'alloc-1'); + assert.equal(allocationItem.source.allocationId, 'alloc-1'); + assert.equal(allocationItem.source.description, 'Lenovo ThinkCentre M920q'); + assert.equal(allocationItem.source.amount, '399.00'); + }); + + it('rejects invalid edits and keeps ledger-seeded fields read-only', async () => { const items = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body).items; const item = items.find((candidate: any) => candidate.name === 'Laptop'); const rejected = await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { type: 'vehicle' }); assert.equal(rejected.statusCode, 400); assert.deepEqual(JSON.parse(rejected.body).fields, ['type']); + // Status follows the disposal records; direct edits are rejected. + const statusPut = await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { status: 'disposed' }); + assert.equal(statusPut.statusCode, 400); + assert.deepEqual(JSON.parse(statusPut.body).fields, ['status']); // Ledger-seeded fields are not editable; naming none is a read-back. const noop = await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { amount: '1.00' }); assert.equal(noop.statusCode, 200); assert.equal(JSON.parse(noop.body).updatedAt, item.updatedAt); + }); + + it('records a sale as a disposal with proceeds and removes it again', async () => { + const items = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body).items; + const item = items.find((candidate: any) => candidate.name === 'Laptop'); + const noProceeds = await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { disposal: { kind: 'sold', date: '2027-01-15' } }); + assert.equal(noProceeds.statusCode, 400); + assert.deepEqual(JSON.parse(noProceeds.body).fields, ['disposal.proceeds']); + const unknownKind = await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { disposal: { kind: 'scrapped', date: '2027-01-15' } }); + assert.equal(unknownKind.statusCode, 400); + assert.deepEqual(JSON.parse(unknownKind.body).fields, ['disposal.kind']); - const disposed = JSON.parse((await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { status: 'disposed', disposedDate: '2027-01-15', notes: 'Sold' })).body); - assert.equal(disposed.status, 'disposed'); - assert.equal(disposed.disposedDate, '2027-01-15'); - assert.equal(disposed.notes, 'Sold'); + const sold = JSON.parse((await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { + disposal: { kind: 'sold', date: '2027-01-15', proceeds: '250', notes: 'eBay' }, + })).body); + assert.equal(sold.status, 'disposed'); + assert.equal(sold.disposedDate, '2027-01-15'); + assert.equal(sold.disposals.length, 1); + assert.equal(sold.disposals[0].kind, 'sold'); + assert.equal(sold.disposals[0].proceeds, '250'); + assert.equal(sold.disposals[0].currency, 'EUR'); + assert.equal(sold.disposals[0].notes, 'eBay'); + assert.ok(sold.disposals[0].id); + // The response still carries the ledger record. + assert.equal(sold.source.transactionId, 'inv-tx-laptop'); - const restored = JSON.parse((await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { status: 'in-use', notes: null })).body); + const missing = await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { removeDisposalId: 'no-such-record' }); + assert.equal(missing.statusCode, 404); + const restored = JSON.parse((await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { + removeDisposalId: sold.disposals[0].id, + })).body); assert.equal(restored.status, 'in-use'); assert.equal(restored.disposedDate, undefined); - assert.equal(restored.notes, undefined); - assert.ok(restored.updatedAt > disposed.updatedAt); + assert.deepEqual(restored.disposals, []); + assert.ok(restored.updatedAt > sold.updatedAt); + }); + + it('records a decommission without proceeds and keeps the latest disposed date', async () => { + const items = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body).items; + const item = items.find((candidate: any) => candidate.name === 'UGREEN video capture card'); + const scrapped = JSON.parse((await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { + disposal: { kind: 'decommissioned', date: '2027-02-01' }, + })).body); + assert.equal(scrapped.status, 'disposed'); + assert.equal(scrapped.disposedDate, '2027-02-01'); + assert.equal(scrapped.disposals.length, 1); + assert.equal(scrapped.disposals[0].kind, 'decommissioned'); + assert.equal(scrapped.disposals[0].proceeds, undefined); + + const later = JSON.parse((await invoke('PUT', `/api/bookkeeping/inventory/${item.id}`, { + disposal: { kind: 'decommissioned', date: '2027-04-10' }, + })).body); + assert.equal(later.disposedDate, '2027-04-10'); + assert.equal(later.disposals.length, 2); }); it('rejects a stale optimistic-concurrency version with 409', async () => { @@ -175,4 +240,33 @@ describe('inventory API', () => { const stored = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body); assert.equal(stored.items.some((candidate: any) => candidate.id === item.id), false); }); + + it('adds inventory entries automatically when the ledger gains a physical purchase', async () => { + const before = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body).items.length; + + await putBookkeepingTransactionWithItems(client, { ...transaction({ description: 'Standing desk', amount: '259.00' }), id: 'inv-tx-desk' }, []); + let listed = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body); + assert.equal(listed.items.length, before + 1); + const desk = listed.items.find((candidate: any) => candidate.transactionId === 'inv-tx-desk'); + assert.equal(desk.type, 'furniture'); + assert.equal(desk.acquisitionDate, '2026-03-19'); + assert.equal(desk.status, 'in-use'); + + // A subscription never reaches the inventory. + await putBookkeepingTransactionWithItems(client, { ...transaction({ description: 'Mailchimp Standard plan' }), id: 'inv-tx-manual-subscription' }, []); + listed = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body); + assert.equal(listed.items.length, before + 1); + + // Editing a transaction to carry physical allocation rows derives them too. + const stored = await getBookkeepingItem(client, 'bookkeeping', 'inv-tx-desk'); + await updateBookkeepingTransaction(client, 'inv-tx-desk', String(stored.updatedAt), { + allocations: [ + { id: 'auto-alloc-1', description: 'Lenovo ThinkCentre M920q', amount: '399.00', currency: 'EUR', amountEur: '399.00' }, + ], + }); + listed = JSON.parse((await invoke('GET', '/api/bookkeeping/inventory')).body); + const derived = listed.items.find((candidate: any) => candidate.allocationId === 'auto-alloc-1'); + assert.equal(derived.type, 'computer'); + assert.equal(derived.transactionId, 'inv-tx-desk'); + }); }); diff --git a/frontend/src/styles/bookkeeping.css b/frontend/src/styles/bookkeeping.css index 846d21bc..55209468 100644 --- a/frontend/src/styles/bookkeeping.css +++ b/frontend/src/styles/bookkeeping.css @@ -268,8 +268,35 @@ .bk-inv-actions { width: 2.5em; text-align: right; } .bk-inv-remove { color: var(--dk-text-secondary); } .bk-inv-remove:hover { color: var(--dk-danger-text); } +.bk-inv-source { + align-self: flex-start; + padding: 0; + border: none; + background: none; + color: var(--dk-accent-default); + font-size: var(--dk-text-xs); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 22em; + text-align: left; +} +.bk-inv-source:hover { color: var(--dk-accent-hover); text-decoration: underline; } +.bk-inv-status { display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-1); } +.bk-inv-badge { font-size: var(--dk-text-xs); color: var(--dk-text-secondary); } +.bk-inv-badge--disposed { color: var(--dk-danger-text); } +.bk-inv-extras td { padding-top: 0; } +.bk-inv-disposals { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); font-size: var(--dk-text-xs); color: var(--dk-text-secondary); } +.bk-inv-disposal { display: inline-flex; align-items: center; gap: 0.35em; } +.bk-inv-disposal strong { color: var(--dk-text-primary); font-weight: 600; } +.bk-inv-disposal-remove { color: var(--dk-text-secondary); } +.bk-inv-disposal-remove:hover { color: var(--dk-danger-text); } +.bk-inv-dispose { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-3); font-size: var(--dk-text-sm); } +.bk-inv-dispose label { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-text-secondary); } @media (max-width: 820px) { .bk-inv-what { min-width: 10em; } .bk-inv-note { min-width: 8em; } + .bk-inv-dispose { flex-direction: column; align-items: stretch; } } diff --git a/frontend/src/surfaces/finance/bookkeeping-inventory.js b/frontend/src/surfaces/finance/bookkeeping-inventory.js index 9233c4c4..fd52caa0 100644 --- a/frontend/src/surfaces/finance/bookkeeping-inventory.js +++ b/frontend/src/surfaces/finance/bookkeeping-inventory.js @@ -1,17 +1,24 @@ // Inventory: physical purchases derived from the ledger, so the balance shows // what hardware, phones, audio and furniture the company owns. The view is a // table over /api/bookkeeping/inventory; "Scan ledger" runs the import that -// turns 2022+ expenses (and per-invoice allocation rows) into items. +// turns 2022+ expenses (and per-invoice allocation rows) into items. Every +// item links back to the ledger record it came from, and selling or +// decommissioning writes a disposal record so exits are documented too. import { html } from "./shared.js"; import { formatAmount } from "./bookkeeping-format.js"; const TYPES = ["computer", "phone", "audio", "furniture", "accessory", "other"]; +const DISPOSAL_KINDS = ["sold", "decommissioned"]; +const DISPOSAL_LABELS = { sold: "sold", decommissioned: "decommissioned" }; -export function createBookkeepingInventory({ surface, api, escapeHtml, safeAction, setStatus, todayIsoDate }) { +export function createBookkeepingInventory({ surface, api, escapeHtml, safeAction, setStatus, todayIsoDate, openTransaction }) { const host = surface.querySelector("[data-inventory]"); const scanButton = surface.querySelector("[data-inventory-scan]"); let items = []; + // The in-row disposal form: one item records its exit at a time. + let disposeForm = null; + const today = () => todayIsoDate?.() || new Date().toISOString().slice(0, 10); const eurValue = (item) => { if (item.amountEur) return Number(item.amountEur); if (item.amount && String(item.currency || "").toUpperCase() === "EUR") return Number(item.amount); @@ -22,6 +29,56 @@ export function createBookkeepingInventory({ surface, api, escapeHtml, safeActio : formatAmount(eurValue(item), "EUR"); const sorted = () => [...items].sort((a, b) => String(b.acquisitionDate || "").localeCompare(String(a.acquisitionDate || ""))); + const disposalsOf = (item) => (Array.isArray(item.disposals) ? item.disposals : []); + + function sourceMarkup(item) { + const source = item.source; + if (!source) return ""; + return html``; + } + + function statusMarkup(item) { + if (item.status === "disposed") { + return html`disposed${item.disposedDate ? html` · ${escapeHtml(item.disposedDate)}` : ""}`; + } + return html`in use + `; + } + + function disposalsMarkup(item) { + const records = disposalsOf(item); + if (!records.length) return ""; + return html` + ${records.map((record) => html` + ${DISPOSAL_LABELS[record.kind] || escapeHtml(String(record.kind))} ${escapeHtml(String(record.date))} + ${record.proceeds ? html` · proceeds ${formatAmount(String(record.proceeds), String(record.currency || "EUR"))}` : ""} + ${record.notes ? html` · ${escapeHtml(String(record.notes))}` : ""} + + `).join("")} + `; + } + + function disposeFormMarkup(item) { + if (disposeForm?.itemId !== item.id) return ""; + const sold = disposeForm.kind === "sold"; + return html` + + + ${sold ? html`` : ""} + + + + `; + } function rowMarkup(item) { const disposed = item.status === "disposed"; @@ -30,6 +87,7 @@ export function createBookkeepingInventory({ surface, api, escapeHtml, safeActio ${escapeHtml(item.name)} ${item.vendor ? html`${escapeHtml(item.vendor)}` : ""} + ${sourceMarkup(item)} ${amountText(item)} - - - ${disposed && item.disposedDate ? html`${escapeHtml(item.disposedDate)}` : ""} - + ${statusMarkup(item)} @@ -83,7 +135,7 @@ export function createBookkeepingInventory({ surface, api, escapeHtml, safeActio AcquiredItemTypePaidStatusNote - ${visible.map(rowMarkup).join("")} + ${visible.map((item) => rowMarkup(item) + disposeFormMarkup(item) + disposalsMarkup(item)).join("")} `; } @@ -97,11 +149,40 @@ export function createBookkeepingInventory({ surface, api, escapeHtml, safeActio return saved; } + function submitDisposal() { + if (!disposeForm) return; + const form = disposeForm; + if (!form.date) { + setStatus("Pick a disposal date."); + return; + } + disposeForm = null; + safeAction(async () => { + await save(form.itemId, { disposal: { + kind: form.kind, + date: form.date, + proceeds: form.kind === "sold" ? String(form.proceeds || "").trim() : undefined, + notes: String(form.notes || "").trim() || undefined, + } }); + setStatus(form.kind === "sold" ? "Recorded the sale." : "Recorded the decommissioning."); + }, "Could not record the disposal"); + } + host.addEventListener("change", (event) => { if (event.target.closest("[data-inv-year]")) { render(); return; } + const disposalField = event.target.closest("[data-inv-disposal]"); + if (disposalField) { + // The kind decides whether proceeds apply; other form fields only + // update the draft, they never re-render mid-typing. + if (disposeForm && disposalField.dataset.invDisposal === "kind") { + disposeForm.kind = String(disposalField.value || "sold"); + render(); + } + return; + } const field = event.target.closest("[data-inv-field]"); if (!field) return; const row = field.closest("[data-inv]"); @@ -111,16 +192,44 @@ export function createBookkeepingInventory({ surface, api, escapeHtml, safeActio field.disabled = true; safeAction(async () => { if (field.dataset.invField === "type") await save(item.id, { type: value }); - else if (field.dataset.invField === "status") - await save(item.id, value === "disposed" - ? { status: "disposed", disposedDate: todayIsoDate?.() || new Date().toISOString().slice(0, 10) } - : { status: "in-use" }); else if (field.dataset.invField === "notes") await save(item.id, { notes: value || null }); field.disabled = false; setStatus("Inventory updated."); }, "Could not save the inventory change"); }); + host.addEventListener("click", (event) => { + const source = event.target.closest("[data-inv-source]"); + if (source) { + openTransaction?.(source.dataset.invSource); + return; + } + const disposeButton = event.target.closest("[data-inv-dispose]"); + if (disposeButton) { + disposeForm = { itemId: disposeButton.dataset.invDispose, kind: "sold", date: today(), proceeds: "", notes: "" }; + render(); + return; + } + if (event.target.closest("[data-inv-disposal-cancel]")) { + disposeForm = null; + render(); + return; + } + if (event.target.closest("[data-inv-disposal-save]")) { + submitDisposal(); + return; + } + const removeDisposal = event.target.closest("[data-inv-disposal-remove]"); + if (removeDisposal) { + const row = removeDisposal.closest("[data-inv]"); + const item = items.find((candidate) => candidate.id === row?.dataset.inv); + if (!item) return; + safeAction(async () => { + await save(item.id, { removeDisposalId: removeDisposal.dataset.invDisposalRemove }); + setStatus("Disposal record removed."); + }, "Could not remove the disposal record"); + return; + } const button = event.target.closest("[data-inv-remove]"); if (!button) return; const item = items.find((candidate) => candidate.id === button.dataset.invRemove); @@ -132,9 +241,14 @@ export function createBookkeepingInventory({ surface, api, escapeHtml, safeActio setStatus(`Removed ${item.name} from the inventory.`); }, "Could not remove the inventory item"); }); + host.addEventListener("input", (event) => { if (event.target.closest("[data-inv-year]")) render(); + const disposalField = event.target.closest("[data-inv-disposal]"); + if (disposalField && disposeForm && disposalField.dataset.invDisposal !== "kind") + disposeForm[disposalField.dataset.invDisposal] = disposalField.value; }); + scanButton.addEventListener("click", () => safeAction(async () => { scanButton.disabled = true; diff --git a/frontend/src/surfaces/finance/bookkeeping.js b/frontend/src/surfaces/finance/bookkeeping.js index f7afd877..35658a30 100644 --- a/frontend/src/surfaces/finance/bookkeeping.js +++ b/frontend/src/surfaces/finance/bookkeeping.js @@ -582,6 +582,8 @@ export function createBookkeepingSurface(context) { status.textContent = text; }, todayIsoDate, + // Inventory rows link back to the ledger record they came from. + openTransaction: (id) => openTransaction(id), }); const navigation = mountBookkeepingNavigation({ diff --git a/frontend/test/bookkeeping-inventory.test.mjs b/frontend/test/bookkeeping-inventory.test.mjs index 3dfc8ce5..9888c1d4 100644 --- a/frontend/test/bookkeeping-inventory.test.mjs +++ b/frontend/test/bookkeeping-inventory.test.mjs @@ -12,18 +12,25 @@ function fakeHost() { }; } +const node = (dataset, row) => ({ dataset, closest: (probe) => (row && probe === "[data-inv]" ? row : null) }); + const laptop = { id: "inv-laptop", transactionId: "tx-1", name: "Laptop", type: "computer", vendor: "Amazon", acquisitionDate: "2026-03-19", amount: "1813.58", currency: "EUR", amountEur: "1813.58", status: "in-use", + source: { + transactionId: "tx-1", description: "MacBook Pro 14", counterparty: "Amazon", + date: "2026-03-19", amount: "1813.58", currency: "EUR", amountEur: "1813.58", + }, }; const headset = { id: "inv-headset", transactionId: "tx-2", name: "Stereo Headset", type: "audio", vendor: "Amazon", acquisitionDate: "2025-06-18", amount: "35.40", currency: "EUR", amountEur: "35.40", status: "disposed", disposedDate: "2026-09-01", + disposals: [{ id: "d-1", kind: "sold", date: "2026-09-01", proceeds: "20.00", currency: "EUR", notes: "eBay" }], }; -function mount({ inventoryPages, putResponses = [], deletes = [] }) { +function mount({ inventoryPages, putResponses = [], deletes = [] } = {}) { const host = fakeHost(); const scanButton = { disabled: false, addEventListener(name, handler) { host.listeners.set(`scan:${name}`, handler); } }; const surface = { @@ -34,6 +41,7 @@ function mount({ inventoryPages, putResponses = [], deletes = [] }) { }; const status = []; const calls = []; + const opened = []; let page = 0; const api = async (path, options = {}) => { calls.push([path, options.method || "GET", options.body ? JSON.parse(options.body) : undefined]); @@ -51,10 +59,24 @@ function mount({ inventoryPages, putResponses = [], deletes = [] }) { surface, api, escapeHtml, safeAction, setStatus: (text) => status.push(text), todayIsoDate: () => "2037-03-15", + openTransaction: (id) => opened.push(id), }); - return { view, host, calls, status, errors, scanClick: () => host.listeners.get("scan:click")() }; + // A click on a node whose closest() answers every probed selector. + const click = (probes) => + host.listeners.get("click")({ target: { closest: (probe) => probes[probe] ?? null } }); + const change = (dataset, value) => + host.listeners.get("change")({ target: { + closest: (probe) => (probe === "[data-inv-disposal]" ? { dataset, value } : null), + } }); + const type = (dataset, value) => + host.listeners.get("input")({ target: { + closest: (probe) => (probe === "[data-inv-disposal]" ? { dataset, value } : null), + } }); + return { view, host, calls, status, errors, opened, click, change, type, scanClick: () => host.listeners.get("scan:click")() }; } +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + test("loads the inventory and renders items with the in-use total", async () => { const { view, host } = mount({ inventoryPages: [[laptop, headset]] }); await view.renderInventory(); @@ -76,36 +98,102 @@ test("scan applies the import, reports the outcome and reloads", async () => { const { view, host, calls, status, scanClick } = mount({ inventoryPages: [[], [laptop]] }); await view.renderInventory(); scanClick(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flush(); assert.deepEqual(calls[1], ["/inventory/import", "POST", { apply: true }]); assert.match(status.at(-1), /Added 1 item from the ledger\./); assert.match(host.innerHTML, /Laptop/); }); -test("marking disposed stamps today as the disposed date; un-disposing clears it", async () => { - const { view, host, calls } = mount({ +test("each item links back to the ledger record it came from", async () => { + const { view, host, opened, click } = mount({ inventoryPages: [[laptop]] }); + await view.renderInventory(); + assert.match(host.innerHTML, /↳ ledger: MacBook Pro 14/); + click({ "[data-inv-source]": node({ invSource: "tx-1" }) }); + assert.deepEqual(opened, ["tx-1"]); +}); + +test("recording a sale sends a disposal with proceeds and derives the disposed status", async () => { + const { view, host, calls, status, click, type } = mount({ inventoryPages: [[laptop]], - putResponses: [{ ...laptop, status: "disposed", disposedDate: "2037-03-15" }], + putResponses: [{ ...laptop, status: "disposed", disposedDate: "2037-03-15", + disposals: [{ id: "d-9", kind: "sold", date: "2037-03-15", proceeds: "250", currency: "EUR" }] }], }); await view.renderInventory(); - const field = { dataset: { invField: "status" }, value: "disposed", disabled: false, closest: (selector) => selector === "[data-inv]" ? { dataset: { inv: laptop.id } } : null }; - host.listeners.get("change")({ target: { closest: (selector) => selector === "[data-inv-year]" ? null : field } }); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepEqual(calls.at(-1), [`/inventory/${laptop.id}`, "PUT", { status: "disposed", disposedDate: "2037-03-15" }]); - assert.match(host.innerHTML, /is-disposed/); - assert.match(host.innerHTML, /2037-03-15/); + click({ "[data-inv-dispose]": node({ invDispose: laptop.id }) }); + assert.match(host.innerHTML, /bk-inv-dispose/); + type({ invDisposal: "proceeds" }, "250"); + click({ "[data-inv-disposal-save]": node({}) }); + await flush(); + assert.deepEqual(calls.at(-1), [`/inventory/${laptop.id}`, "PUT", { + disposal: { kind: "sold", date: "2037-03-15", proceeds: "250" }, + }]); + assert.match(status.at(-1), /Recorded the sale\./); + // The saved item renders as disposed with its record listed. + assert.match(host.innerHTML, /disposed · 2037-03-15/); + assert.match(host.innerHTML, /sold<\/strong> 2037-03-15/); + assert.match(host.innerHTML, /proceeds/); +}); + +test("a decommission form has no proceeds field and sends the decommissioned kind", async () => { + const { view, host, calls, click, change } = mount({ + inventoryPages: [[laptop]], + putResponses: [{ ...laptop, status: "disposed", disposedDate: "2037-03-15", + disposals: [{ id: "d-8", kind: "decommissioned", date: "2037-03-15" }] }], + }); + await view.renderInventory(); + click({ "[data-inv-dispose]": node({ invDispose: laptop.id }) }); + assert.match(host.innerHTML, /Proceeds/); + change({ invDisposal: "kind" }, "decommissioned"); + assert.doesNotMatch(host.innerHTML, /Proceeds/); + click({ "[data-inv-disposal-save]": node({}) }); + await flush(); + assert.deepEqual(calls.at(-1), [`/inventory/${laptop.id}`, "PUT", { + disposal: { kind: "decommissioned", date: "2037-03-15" }, + }]); +}); + +test("cancel closes the dispose form without writing", async () => { + const { view, calls, click } = mount({ inventoryPages: [[laptop]] }); + await view.renderInventory(); + click({ "[data-inv-dispose]": node({ invDispose: laptop.id }) }); + click({ "[data-inv-disposal-cancel]": node({}) }); + assert.deepEqual(calls.filter(([, method]) => method === "PUT"), []); +}); + +test("a sale without a date is rejected before any request", async () => { + const { view, status, calls, click, type } = mount({ inventoryPages: [[laptop]] }); + await view.renderInventory(); + click({ "[data-inv-dispose]": node({ invDispose: laptop.id }) }); + type({ invDisposal: "date" }, ""); + click({ "[data-inv-disposal-save]": node({}) }); + await flush(); + assert.match(status.at(-1), /Pick a disposal date\./); + assert.deepEqual(calls.filter(([, method]) => method === "PUT"), []); +}); + +test("removing the last disposal record puts the item back in use", async () => { + const { view, host, calls, click } = mount({ + inventoryPages: [[headset]], + putResponses: [{ ...headset, status: "in-use", disposedDate: undefined, disposals: [] }], + }); + await view.renderInventory(); + assert.match(host.innerHTML, /sold<\/strong> 2026-09-01/); + assert.match(host.innerHTML, /proceeds/); + click({ + "[data-inv-disposal-remove]": node({ invDisposalRemove: "d-1" }, node({ inv: headset.id })), + }); + await flush(); + assert.deepEqual(calls.at(-1), [`/inventory/${headset.id}`, "PUT", { removeDisposalId: "d-1" }]); }); test("removal confirms first and drops the row", async () => { - const { view, host, calls } = mount({ inventoryPages: [[laptop]] }); + const { view, host, calls, click } = mount({ inventoryPages: [[laptop]] }); const originalConfirm = globalThis.confirm; globalThis.confirm = () => true; try { await view.renderInventory(); - const button = { dataset: { invRemove: laptop.id }, closest: (selector) => selector === "[data-inv-remove]" ? button : null }; - host.listeners.get("click")({ target: { closest: (selector) => selector === "[data-inv-year]" ? null : button } }); - await new Promise((resolve) => setTimeout(resolve, 0)); + click({ "[data-inv-remove]": node({ invRemove: laptop.id }) }); + await flush(); assert.deepEqual(calls.at(-1), [`/inventory/${laptop.id}`, "DELETE", undefined]); assert.doesNotMatch(host.innerHTML, /Laptop/); } finally { @@ -114,15 +202,14 @@ test("removal confirms first and drops the row", async () => { }); test("declined confirmation leaves the row in place", async () => { - const { view, host, calls } = mount({ inventoryPages: [[laptop]] }); + const { view, host, calls, click } = mount({ inventoryPages: [[laptop]] }); const originalConfirm = globalThis.confirm; globalThis.confirm = () => false; try { await view.renderInventory(); - const button = { dataset: { invRemove: laptop.id }, closest: (selector) => selector === "[data-inv-remove]" ? button : null }; - host.listeners.get("click")({ target: { closest: (selector) => selector === "[data-inv-year]" ? null : button } }); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepEqual(calls.filter(([path, method]) => method === "DELETE"), []); + click({ "[data-inv-remove]": node({ invRemove: laptop.id }) }); + await flush(); + assert.deepEqual(calls.filter(([, method]) => method === "DELETE"), []); assert.match(host.innerHTML, /Laptop/); } finally { globalThis.confirm = originalConfirm;