Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions backend/src/db/bookkeeping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>[],
) {
for (const candidate of deriveInventoryItems(transactions)) {
const entry: Record<string, unknown> = {
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(
Expand Down Expand Up @@ -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 = {
Expand Down
97 changes: 95 additions & 2 deletions backend/src/finance/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown>).id) === allocationId,
) as Record<string, unknown> | 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,
};
}
116 changes: 100 additions & 16 deletions backend/src/routes/bookkeepingInventory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import type { LambdaEvent, LambdaResponse } from "../types";
import {
Expand All @@ -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";

Expand All @@ -40,23 +45,70 @@ function validateInventoryEdit(input: Record<string, unknown>): 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, unknown>): string[] {
const errors: string[] = [];
const disposal = input.disposal as Record<string, unknown> | 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<string, unknown>[],
): Promise<Record<string, unknown>[]> {
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,
event: LambdaEvent,
client: DynamoDBDocumentClient,
): Promise<LambdaResponse | null> {
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) });
}

Expand Down Expand Up @@ -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()
Expand All @@ -126,23 +182,51 @@ export async function handleBookkeepingInventoryRoutes(
const value: Record<string, unknown> = {};
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<string, unknown>;
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." });
Expand Down
Loading