feat(invoices): standalone invoices view in budget section#203
Conversation
- Add vendorName field to Invoice type in shared/src/types/invoice.ts - Export InvoiceStatusSummary, InvoiceSummary, InvoiceListPaginatedResponse, and InvoiceDetailResponse from @cornerstone/shared - Update toInvoice() in invoiceService.ts to resolve and include vendorName - Add listAllInvoices() with pagination, filtering (status, vendorId, q), sorting (date, amount, status, vendor_name, due_date), and global status summary - Add getInvoiceById() for cross-vendor single invoice lookup - Create server/src/routes/standaloneInvoices.ts with GET /api/invoices and GET /api/invoices/:invoiceId endpoints - Register standaloneInvoiceRoutes at /api/invoices prefix in app.ts Co-Authored-By: Claude backend-developer (Sonnet 4.5) <noreply@anthropic.com>
- Add fetchAllInvoices and fetchInvoiceById to invoicesApi client - Add Invoices tab to BudgetSubNav between Vendors and Sources - Implement InvoicesPage with summary cards (Pending/Paid/Claimed), filterable/sortable paginated table, mobile card view, and create modal - Implement InvoiceDetailPage with edit and delete modals, budget line linking via work item selector, breadcrumb navigation - Register /budget/invoices and /budget/invoices/:id routes in App.tsx Fixes #200 Co-Authored-By: Claude frontend-developer (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude orchestrator (Sonnet 4.6) <noreply@anthropic.com>
The vendorName field added to the Invoice type requires updating all existing mock objects in test files. Co-Authored-By: Claude orchestrator (Sonnet 4.6) <noreply@anthropic.com>
Replace inline anonymous type `{ invoice: Invoice }` with the explicit
`InvoiceDetailResponse` shared type in fetchInvoiceById, consistent with
how other shared response types are used across the API client.
Co-Authored-By: Claude <frontend-developer> (Sonnet 4.6) <noreply@anthropic.com>
…rvice functions - `assertVendorExists()` now returns the vendor name (was void) - `toInvoice()` accepts an optional `knownVendorName` 3rd parameter; when provided it skips the per-call vendor DB lookup - `listInvoices`, `createInvoice`, and `updateInvoice` capture the vendor name returned by `assertVendorExists` and forward it to `toInvoice`, avoiding one extra SELECT per invoice row - `deleteInvoice` ignores the returned name (no `toInvoice` call needed) - `getInvoiceById` and `listAllInvoices` are unchanged — they either rely on the fallback lookup or already use a JOIN Co-Authored-By: Claude <backend-developer> (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude product-architect (Opus 4.6) <noreply@anthropic.com>
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer]
Security Review — PR #203: Standalone Invoices View
Reviewed: server/src/routes/standaloneInvoices.ts, server/src/services/invoiceService.ts, client/src/pages/InvoicesPage/InvoicesPage.tsx, client/src/pages/InvoiceDetailPage/InvoiceDetailPage.tsx, client/src/lib/invoicesApi.ts, shared/src/types/invoice.ts
Checklist
- No SQL/command/XSS injection vectors in new code
- Authentication/authorization enforced on all new endpoints
- No sensitive data (secrets, tokens, PII) exposed in logs, errors, or client responses
- User input validated and sanitized at API boundaries
- New dependencies — none added
- No hardcoded credentials or secrets
- CORS configuration remains restrictive
- Error responses do not leak internal details
Findings
Authentication and Authorization — PASS
Both new endpoints (GET /api/invoices and GET /api/invoices/:invoiceId) correctly enforce authentication via two layers:
- The global
preValidationhook inauth.tsblocks all/api/*routes not inPUBLIC_ROUTES. The new routes are not public, so unauthenticated requests are rejected with 401 before reaching the handler. - Each handler also contains an explicit
if (!request.user) throw new UnauthorizedError()guard as a belt-and-suspenders check.
This is consistent with the pattern used across all other protected routes in the codebase.
Input Validation — PASS
GET /api/invoices querystring schema is well-formed:
page/pageSize: integer with min/max boundsstatus/sortBy/sortOrder: enum-constrained — only allowlisted values are acceptedq: string withmaxLength: 200additionalProperties: falseprevents unexpected query parameters from reaching handler code
SQL Injection — PASS
The sortBy parameter is enum-validated by AJV before reaching service code, and then mapped to a Drizzle ORM column reference (not a raw string). The LIKE search for q correctly escapes % and _ metacharacters before constructing the pattern, and uses a parameterized Drizzle sql tagged template. No raw SQL string interpolation exists anywhere in the new code.
XSS — PASS
All user-supplied data is rendered through React JSX expressions, which auto-escape output. No dangerouslySetInnerHTML, innerHTML, or eval() usage was found in either new page. The ApiClientError.error.message values reflected in error banners originate from fixed strings in AppError subclasses on the server — they never echo user input back, so there is no reflected XSS path.
Information Disclosure — PASS (by design)
GET /api/invoices returns invoices across all vendors. For this single-tenant self-hosted application, all authenticated users have access to the same project data — this is by design and consistent with how other listing endpoints work (GET /api/work-items, GET /api/vendors, etc.).
Regression on Previous Findings — RESOLVED
The prior finding "pageSize 200 Exceeds Server Maximum of 100" (PR #193) is resolved in this PR. Both InvoicesPage.tsx and InvoiceDetailPage.tsx correctly use pageSize: 100 for work item fetching. The .catch() handlers for fetchWorkItemBudgets are also properly implemented in both pages, addressing the related "Swallowed Promise Rejection" pattern identified in PR #193.
Informational Finding
getInvoiceByIdSchema is missing additionalProperties: false on the params schema
(server/src/routes/standaloneInvoices.ts, lines ~3317-3325)
This is a style inconsistency — the listAllInvoicesSchema querystring in the same file correctly uses additionalProperties: false, but the params schema for the :invoiceId route does not. This has no exploitable impact (path parameters are positionally extracted and cannot carry extra fields), but it is inconsistent with the defensive validation style used elsewhere in the codebase. Recommended fix:
const getInvoiceByIdSchema = {
params: {
type: 'object',
required: ['invoiceId'],
properties: {
invoiceId: { type: 'string' },
},
additionalProperties: false, // add this
},
};This can be addressed in a future PR — it does not block merge.
Verdict
No blocking security issues found. The implementation correctly enforces authentication, validates all querystring inputs against strict enums and type/range constraints, uses parameterized queries throughout (no SQL injection), and renders all output via React's auto-escaping (no XSS). One informational finding (params schema completeness) noted for a future cleanup pass.
Security Audit wiki page updated: https://github.com/steilerDev/cornerstone/wiki/Security-Audit
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect] Architecture review of PR #203 — standalone invoices view.
Overall Assessment
The PR adds two new backend endpoints (GET /api/invoices, GET /api/invoices/:invoiceId), corresponding API client functions, and two new frontend pages. The implementation is well-structured and follows most established patterns. However, there are several issues that need to be addressed before merge — one breaking change to the shared type, a wiki deviation, missing test coverage, and an N+1 query regression on existing endpoints.
Verdict: Request Changes (4 items must be addressed)
Issues Requiring Changes
1. CRITICAL — Wiki API Contract deviation (new endpoints + modified Invoice shape not documented)
The wiki API Contract (wiki/API-Contract.md) does not document:
GET /api/invoices(paginated cross-vendor listing)GET /api/invoices/:invoiceId(cross-vendor single invoice)- The
vendorNamefield added to theInvoiceresponse shape
Per the Implementation-Wiki Deviation Workflow in CLAUDE.md: "Fix and wiki update should land together — do not merge code that knowingly contradicts the wiki without also updating the wiki in the same PR."
The Invoice response shape in the wiki (line ~2504) currently shows:
interface InvoiceResponse {
id: string;
vendorId: string;
// ... no vendorName
}Action required: Update wiki/API-Contract.md to:
- Add
vendorName: stringto the Invoice Response Shape section - Document
GET /api/invoiceswith query parameters, response shape (includingInvoiceListPaginatedResponse), and error codes - Document
GET /api/invoices/:invoiceIdwith path parameters, response shape (InvoiceDetailResponse), and error codes - Add a Deviation Log entry noting the
vendorNamefield was added to the Invoice shape - Commit the wiki submodule update alongside the code changes
2. N+1 vendor query regression on existing endpoints
In server/src/services/invoiceService.ts, the toInvoice() helper (line 51-72) now executes a vendor lookup query per row:
function toInvoice(db: DbType, row: typeof invoices.$inferSelect): Invoice {
const vendor = db.select().from(vendors).where(eq(vendors.id, row.vendorId)).get();
// ...
}This function is called by:
listInvoices()(line 98) — the vendor-scoped list atGET /api/vendors/:vendorId/invoices. This already knows the vendor from the URL path, so the per-row vendor query is unnecessary. For N invoices, this adds N redundant queries.createInvoice()(line 306) — acceptable (single row)updateInvoice()(line 398) — acceptable (single row)getInvoiceById()(line 236) — acceptable (single row)
Meanwhile, listAllInvoices() correctly uses a JOIN to resolve vendor names efficiently, then bypasses toInvoice() with its own mapping logic (lines 199-217). This is good but results in duplicated mapping code.
Action required: Refactor toInvoice() to accept an optional vendorName parameter so callers that already know the vendor name can pass it in, avoiding the redundant query. For example:
function toInvoice(db: DbType, row: typeof invoices.$inferSelect, vendorName?: string): Invoice {
const resolvedVendorName = vendorName
?? db.select().from(vendors).where(eq(vendors.id, row.vendorId)).get()?.name
?? 'Unknown';
// ...
}Then listInvoices() can pass the known vendor name, and listAllInvoices() can reuse toInvoice() instead of duplicating the mapping.
3. Missing test coverage for new backend service functions and API client functions
No unit tests exist for the new service functions listAllInvoices() and getInvoiceById() in invoiceService.ts. No tests exist for the new client API functions fetchAllInvoices() and fetchInvoiceById() in invoicesApi.ts.
The existing invoicesApi.test.ts only covers fetchInvoices, createInvoice, updateInvoice, and deleteInvoice. The vendorName field was added to test fixtures (good), but no new test cases were added for the two new functions.
Action required: The QA agent needs to add:
- Unit tests for
invoiceService.listAllInvoices()covering: pagination, search filter, status filter, vendor filter, sorting, summary computation, empty results - Unit tests for
invoiceService.getInvoiceById()covering: found, not found (404) - Client API tests for
fetchAllInvoices()covering: URL construction with various query param combinations, response parsing - Client API tests for
fetchInvoiceById()covering: URL construction, envelope unwrapping (.then(r => r.invoice))
4. InvoiceDetailResponse type is defined but not used in the route handler
In shared/src/types/invoice.ts, InvoiceDetailResponse is defined and exported:
export interface InvoiceDetailResponse {
invoice: Invoice;
}But in server/src/routes/standaloneInvoices.ts line 63, the response is constructed inline:
return reply.status(200).send({ invoice });This is not a blocking issue — it works correctly — but the type should be referenced in the API client for type safety. Currently fetchInvoiceById() uses get<{ invoice: Invoice }> instead of get<InvoiceDetailResponse>. Consider using the shared type for consistency.
Minor Observations (Non-blocking)
5. Duplicate formatCurrency and formatDate utility functions
Both InvoicesPage.tsx and InvoiceDetailPage.tsx define identical formatCurrency() and formatDate() helper functions. The VendorDetailPage likely has similar helpers. Consider extracting these into a shared client/src/lib/format.ts utility in a future PR to reduce duplication. Not blocking for this PR.
6. CSS duplication across page modules
The two new CSS modules (1,642 lines total) contain substantial duplication of button styles, modal styles, form styles, etc. that are repeated across VendorDetailPage, InvoicesPage, and InvoiceDetailPage. This is consistent with the existing pattern (each page is self-contained with CSS Modules), but the growing duplication suggests a future PR should extract shared component CSS. Not blocking for this PR.
7. fetchAllInvoices unwrapping vs fetchInvoiceById unwrapping
fetchAllInvoices() returns the full InvoiceListPaginatedResponse (caller accesses .invoices, .pagination, .summary), while fetchInvoiceById() unwraps the envelope in the API client:
return get<{ invoice: Invoice }>(`/invoices/${invoiceId}`).then((r) => r.invoice);The existing pattern in invoicesApi.ts is mixed — fetchInvoices() unwraps, createInvoice() unwraps, but fetchAllInvoices() does not. This is fine for this PR since the list response has multiple top-level keys. Just noting the inconsistency for awareness.
What Looks Good
- Routing: Lazy loading of both new pages in
App.tsxfollows the established pattern correctly - Route nesting:
/budget/invoicesand/budget/invoices/:idare correctly nested under the budget route group - BudgetSubNav: Tab placement between Vendors and Sources is logical
- Shared types:
InvoiceStatusSummary,InvoiceSummary,InvoiceListPaginatedResponse,InvoiceDetailResponseare well-defined and properly exported - Backend route structure:
standaloneInvoices.tsfollows the same pattern as other route files (Fastify schema validation, auth check, service delegation) - Fastify schema validation: Correct use of JSON Schema for query parameter validation with
additionalProperties: false - Service layer:
listAllInvoices()correctly uses JOINs for the paginated listing, properly handles WHERE clause composition, and computes an unfiltered summary - CSS Modules: Proper use of design tokens throughout, responsive breakpoints for mobile/tablet, touch-friendly minimum heights
- Frontend patterns: URL-based filter state via
useSearchParams, debounced search, proper loading/error states, accessible modals withrole="dialog"andaria-modal - Test fixture updates: Existing test fixtures in
invoicesApi.test.tsandVendorDetailPage.test.tsxcorrectly updated withvendorNamefield
Summary of Required Changes
- Update wiki API Contract with new endpoints and
vendorNamefield — must land with this PR - Fix N+1 vendor query in
toInvoice()for the existinglistInvoices()path - Add missing unit tests for new service functions and API client functions (QA agent)
- Consider using
InvoiceDetailResponseinfetchInvoiceByIdfor type consistency (minor)
…etchAllInvoices, fetchInvoiceById
- Add 14 tests for listAllInvoices(): empty results, multi-vendor vendorName via JOIN,
pagination (page/pageSize/metadata), status filter, vendorId filter, q search
(case-insensitive), sort by amount (asc/desc), sort by vendor_name, global summary
(unaffected by current page filter), zero values for unused statuses, combined filters
- Add 4 tests for getInvoiceById(): all fields with vendorName, NotFoundError,
cross-vendor lookup, null createdBy
- Add 10 tests for fetchAllInvoices(): GET /api/invoices with no params, query strings
(page/pageSize/q/status/vendorId/sortBy/sortOrder), full InvoiceListPaginatedResponse
unwrapping, undefined param omission, clean path without query string, 401/500 errors
- Add 6 tests for fetchInvoiceById(): GET /api/invoices/:id URL, correct invoiceId,
Invoice unwrapped from { invoice } envelope, vendorName populated, 404/401 errors
Co-Authored-By: Claude <qa-integration-tester> (Sonnet 4.6) <noreply@anthropic.com>
…id collision The beta branch introduced a new InvoiceSummary type in workItemBudget.ts representing a per-invoice line summary. Our feature branch added a different InvoiceSummary type in invoice.ts representing a status breakdown (pending/ paid/claimed counts + totals). Rename ours to InvoiceStatusBreakdown to eliminate the duplicate identifier. Co-Authored-By: Claude <orchestrator> (Sonnet 4.6) <noreply@anthropic.com>
…e branch Add missing `workItemBudget` field to the manual Invoice object construction in `listAllInvoices()`. The `Invoice` type now requires `workItemBudget: WorkItemBudgetSummary | null` (added by PR #202), and `listAllInvoices()` was building Invoice objects inline rather than using `toInvoice()`, so the field was omitted. Co-Authored-By: Claude <backend-developer> (Sonnet 4.6) <noreply@anthropic.com>
|
🎉 This PR is included in version 1.9.0-beta.46 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
vendorNamefield to theInvoiceshared type and all invoice API responses (existing vendor-scoped endpoints automatically include it via updatedtoInvoice()helper)GET /api/invoicesendpoint — paginated, filterable by status/vendor/search, sortable, with global status summary (pending/paid/claimed counts + totals)GET /api/invoices/:idendpoint — cross-vendor single invoice lookup/budget/invoicespage — summary cards, filter bar, sortable table (desktop) + card list (mobile), pagination, create invoice modal with vendor dropdown/budget/invoices/:iddetail page — full invoice details, edit modal, delete with navigation back to listWhat's Changed
Shared (
shared/src/)types/invoice.ts:vendorName: stringadded toInvoice; newInvoiceStatusSummary,InvoiceSummary,InvoiceListPaginatedResponse,InvoiceDetailResponsetypesindex.ts: exports for new typesServer (
server/src/)services/invoiceService.ts:toInvoice()now resolves vendor name;listAllInvoices()+getInvoiceById()addedroutes/standaloneInvoices.ts(new):GET /andGET /:invoiceIdapp.ts: registers standalone invoice routes at/api/invoicesClient (
client/src/)lib/invoicesApi.ts:fetchAllInvoices()+fetchInvoiceById()addedcomponents/BudgetSubNav/BudgetSubNav.tsx: Invoices tab addedpages/InvoicesPage/(new): list page with summary cards + filters + pagination + create modalpages/InvoiceDetailPage/(new): detail page with edit/delete modalsApp.tsx: two new routes registeredTest plan
/budget/invoices— "Invoices" tab is active, summary cards show correct counts/totals, table renders with sortable columns, filters (search/status/vendor) narrow results/budget/invoices/:id/budget/invoices/:id— invoice details render correctly, vendor name is a link to the vendor page, Edit modal saves changes, Delete navigates back to list🤖 Generated with Claude Code