From f08b538a191d5702bd0b3a0f0c8c03aade849b78 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:48:42 -0400 Subject: [PATCH] feat(api): add invoice, line, and document stubs --- package-lock.json | 74 +++ packages/api/openapi/components/schemas.yaml | 155 ++++++ packages/api/openapi/openapi.yaml | 22 + .../paths/documents-id-confirmations.yaml | 52 ++ packages/api/openapi/paths/documents-id.yaml | 39 ++ .../openapi/paths/invoices-id-documents.yaml | 53 +++ .../api/openapi/paths/invoices-id-lines.yaml | 123 +++++ packages/api/openapi/paths/invoices-id.yaml | 106 +++++ packages/api/openapi/paths/invoices.yaml | 106 +++++ packages/api/package.json | 2 + packages/api/src/app.ts | 4 + packages/api/src/documents.ts | 77 +++ packages/api/src/env.ts | 9 + packages/api/src/routes/helpers.ts | 41 ++ packages/api/src/routes/invoices.test.ts | 163 +++++++ packages/api/src/routes/invoices.ts | 446 ++++++++++++++++++ packages/api/src/test/fake-db.ts | 52 +- src/api/types.ts | 46 ++ 18 files changed, 1562 insertions(+), 8 deletions(-) create mode 100644 packages/api/openapi/paths/documents-id-confirmations.yaml create mode 100644 packages/api/openapi/paths/documents-id.yaml create mode 100644 packages/api/openapi/paths/invoices-id-documents.yaml create mode 100644 packages/api/openapi/paths/invoices-id-lines.yaml create mode 100644 packages/api/openapi/paths/invoices-id.yaml create mode 100644 packages/api/openapi/paths/invoices.yaml create mode 100644 packages/api/src/documents.ts create mode 100644 packages/api/src/routes/invoices.test.ts create mode 100644 packages/api/src/routes/invoices.ts diff --git a/package-lock.json b/package-lock.json index d100c79..1bc709e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,6 +113,22 @@ "node": "20 || >=22" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.0.tgz", + "integrity": "sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-rds-data": { "version": "3.1136.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-rds-data/-/client-rds-data-3.1136.0.tgz", @@ -132,6 +148,28 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1137.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1137.0.tgz", + "integrity": "sha512-ppiYnDyy2qCDT3PIV83XJwAi0BhVUZ/jOHxUgC5tlMCaFeKRL8PVVub8A0pUMkF1yvZtzNOp3ClbmTCcIa9w3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1001.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-node": "^3.972.83", + "@aws-sdk/middleware-sdk-s3": "^3.972.76", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { "version": "3.978.0", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", @@ -299,6 +337,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.76.tgz", + "integrity": "sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/nested-clients": { "version": "3.997.45", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", @@ -318,6 +373,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1137.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1137.0.tgz", + "integrity": "sha512-OJQwS0qt5fQMoZSccncZQBLLvSZ3Jw7Lo+E3MYBNNPRnUkvJxn5LeY246K+1QvoL9o8zHpYVPa7YgCL+zf+xtg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.46", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", @@ -7816,6 +7888,8 @@ "version": "0.1.0", "dependencies": { "@aws-sdk/client-rds-data": "^3.1135.0", + "@aws-sdk/client-s3": "^3.1137.0", + "@aws-sdk/s3-request-presigner": "^3.1137.0", "@hono/node-server": "^2.1.1", "@seahaven-ap/shared": "*", "drizzle-orm": "^0.45.2", diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index 4d1ea11..bd6be25 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -197,3 +197,158 @@ User: type: string format: date-time description: Row update time. +Invoice: + type: object + required: + - id + - vendorId + - invoiceNumber + - amount + - amountDue + - dueDate + - status + - paymentMethod + - memo + - createdAt + - updatedAt + - lines + properties: + id: + type: string + format: uuid + description: Invoice primary key. + example: 88888888-8888-4888-8888-888888888888 + vendorId: + type: string + format: uuid + description: Vendor foreign key. + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + description: Vendor-issued invoice number. + example: INV-1001 + amount: + type: string + description: Invoice total as numeric(14,2) text. + example: "1250.00" + amountDue: + type: string + description: Remaining amount due as numeric(14,2) text. + example: "1250.00" + dueDate: + type: string + description: Due date as YYYY-MM-DD. + example: "2026-09-01" + payDate: + type: [string, "null"] + description: Optional pay date as YYYY-MM-DD. + example: "2026-09-15" + sendPaymentOn: + type: [string, "null"] + description: Optional send date as YYYY-MM-DD. + example: "2026-09-10" + status: + type: string + enum: [pending_approval, approved, scheduled, paid, rejected, void] + description: Invoice workflow status. + example: pending_approval + paymentMethod: + type: string + enum: [check, ach] + description: Payment method for this invoice. + example: check + memo: + type: string + description: Free-form memo. + example: Seed invoice for local smoke. + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. + lines: + type: array + description: Coding lines attached to the invoice. + items: + $ref: "#/InvoiceLine" +InvoiceLine: + type: object + required: [id, invoiceId, description, amount, createdAt] + properties: + id: + type: string + format: uuid + description: Line primary key. + example: 99999999-9999-4999-8999-999999999999 + invoiceId: + type: string + format: uuid + description: Parent invoice id. + example: 88888888-8888-4888-8888-888888888888 + description: + type: string + description: Line description. + example: Monthly maintenance + amount: + type: string + description: Line amount as numeric(14,2) text. + example: "1250.00" + glAccountId: + type: [string, "null"] + format: uuid + description: Optional GL account id. + departmentId: + type: [string, "null"] + format: uuid + description: Optional department id. + createdAt: + type: string + format: date-time + description: Row creation time. +Document: + type: object + required: [id, objectKey, contentType, fileName, createdAt] + properties: + id: + type: string + format: uuid + description: Document primary key. + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd + invoiceId: + type: [string, "null"] + format: uuid + description: Parent invoice id when attached. + objectKey: + type: string + description: Object key in the documents bucket. + example: seed/inv-1001.pdf + contentType: + type: string + description: Uploaded object MIME type. + example: application/pdf + fileName: + type: string + description: Original file name. + example: inv-1001.pdf + uploadedByUserId: + type: [string, "null"] + format: uuid + description: User who started the upload. + createdAt: + type: string + format: date-time + description: Row creation time. + uploadUrl: + type: string + description: Presigned PUT URL returned on create. + uploadHeaders: + type: object + additionalProperties: + type: string + description: Headers the browser must send with the presigned PUT. + downloadUrl: + type: string + description: Presigned GET URL returned on confirm or get. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index 2ecdda1..f0881a3 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -15,6 +15,10 @@ tags: description: Cookie session via Cognito hosted UI, plus caller identity after upsert. - name: Master data description: Vendors, GL accounts, departments, and user role updates. + - name: Invoices + description: Invoice headers, coding lines, and uniqueness rules. + - name: Documents + description: Presigned document upload, confirm, and download against MinIO or S3. paths: /api/health: $ref: ./paths/health.yaml @@ -46,6 +50,18 @@ paths: $ref: ./paths/users.yaml /api/users/{id}: $ref: ./paths/users-id.yaml + /api/invoices: + $ref: ./paths/invoices.yaml + /api/invoices/{id}: + $ref: ./paths/invoices-id.yaml + /api/invoices/{id}/lines: + $ref: ./paths/invoices-id-lines.yaml + /api/invoices/{id}/documents: + $ref: ./paths/invoices-id-documents.yaml + /api/documents/{id}: + $ref: ./paths/documents-id.yaml + /api/documents/{id}/confirmations: + $ref: ./paths/documents-id-confirmations.yaml components: securitySchemes: cookieAuth: @@ -67,5 +83,11 @@ components: $ref: ./components/schemas.yaml#/Department User: $ref: ./components/schemas.yaml#/User + Invoice: + $ref: ./components/schemas.yaml#/Invoice + InvoiceLine: + $ref: ./components/schemas.yaml#/InvoiceLine + Document: + $ref: ./components/schemas.yaml#/Document security: - cookieAuth: [] diff --git a/packages/api/openapi/paths/documents-id-confirmations.yaml b/packages/api/openapi/paths/documents-id-confirmations.yaml new file mode 100644 index 0000000..046f263 --- /dev/null +++ b/packages/api/openapi/paths/documents-id-confirmations.yaml @@ -0,0 +1,52 @@ +parameters: + - name: id + in: path + required: true + description: Document primary key. + schema: + type: string + format: uuid + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd +post: + tags: [Documents] + summary: Confirm a document upload + description: Succeeds when the object exists in MinIO or S3. Missing objects are a conflict. + operationId: post-api-documents-id-confirmations + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + responses: + "200": + description: Confirmed document with download URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Document not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Object has not been uploaded. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/documents-id.yaml b/packages/api/openapi/paths/documents-id.yaml new file mode 100644 index 0000000..62e0bdd --- /dev/null +++ b/packages/api/openapi/paths/documents-id.yaml @@ -0,0 +1,39 @@ +parameters: + - name: id + in: path + required: true + description: Document primary key. + schema: + type: string + format: uuid + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd +get: + tags: [Documents] + summary: Get a document + description: Returns document metadata and a presigned GET URL. + operationId: get-api-documents-id + responses: + "200": + description: Document with download URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Document not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id-documents.yaml b/packages/api/openapi/paths/invoices-id-documents.yaml new file mode 100644 index 0000000..039a40b --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-documents.yaml @@ -0,0 +1,53 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +post: + tags: [Documents] + summary: Presign a document upload + description: Creates a document row and returns a MinIO or S3 presigned PUT URL. + operationId: post-api-invoices-id-documents + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [fileName] + properties: + fileName: + type: string + example: inv-1001.pdf + contentType: + type: string + example: application/pdf + responses: + "201": + description: Document row with upload URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id-lines.yaml b/packages/api/openapi/paths/invoices-id-lines.yaml new file mode 100644 index 0000000..af59da5 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-lines.yaml @@ -0,0 +1,123 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +post: + tags: [Invoices] + summary: Add an invoice line + description: Appends one coding line. Sum is checked on replace, not on this add. + operationId: post-api-invoices-id-lines + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [description, amount] + properties: + description: + type: string + example: Extra coding + amount: + type: string + example: "25.00" + glAccountId: + type: string + format: uuid + example: 66666666-6666-4666-8666-666666666666 + departmentId: + type: string + format: uuid + example: 77777777-7777-4777-8777-777777777777 + responses: + "201": + description: Created line. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/InvoiceLine + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +put: + tags: [Invoices] + summary: Replace invoice lines + description: Replaces every coding line. The amounts must sum to the invoice amount. + operationId: put-api-invoices-id-lines + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + type: object + required: [description, amount] + properties: + description: + type: string + example: Labor + amount: + type: string + example: "1250.00" + glAccountId: + type: string + format: uuid + departmentId: + type: string + format: uuid + responses: + "200": + description: Replaced lines. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/InvoiceLine + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id.yaml b/packages/api/openapi/paths/invoices-id.yaml new file mode 100644 index 0000000..a79fe43 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id.yaml @@ -0,0 +1,106 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +get: + tags: [Invoices] + summary: Get an invoice + description: Returns one invoice and its coding lines. + operationId: get-api-invoices-id + responses: + "200": + description: Invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Invoices] + summary: Update an invoice + description: Patch header fields. Duplicate active vendor plus invoice number is a conflict. + operationId: patch-api-invoices-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + vendorId: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + example: INV-1001 + amount: + type: string + example: "1250.00" + dueDate: + type: string + example: "2026-09-01" + status: + type: string + enum: [pending_approval, approved, scheduled, paid, rejected, void] + example: approved + paymentMethod: + type: string + enum: [check, ach] + example: ach + memo: + type: string + example: Updated memo + responses: + "200": + description: Updated invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Active vendor and invoice number already exist. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices.yaml b/packages/api/openapi/paths/invoices.yaml new file mode 100644 index 0000000..1e45e53 --- /dev/null +++ b/packages/api/openapi/paths/invoices.yaml @@ -0,0 +1,106 @@ +get: + tags: [Invoices] + summary: List invoices + description: Returns invoices with their coding lines. + operationId: get-api-invoices + responses: + "200": + description: Invoice list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/Invoice + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Invoices] + summary: Create an invoice + description: Inserts an invoice. Line amounts must sum to amount when lines are sent. Duplicate active vendor plus invoice number is a conflict. + operationId: post-api-invoices + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [vendorId, invoiceNumber, amount, dueDate] + properties: + vendorId: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + example: INV-2002 + amount: + type: string + example: "100.00" + dueDate: + type: string + example: "2026-10-01" + paymentMethod: + type: string + enum: [check, ach] + example: check + memo: + type: string + example: Harbor repair + lines: + type: array + items: + type: object + required: [description, amount] + properties: + description: + type: string + example: Labor + amount: + type: string + example: "100.00" + glAccountId: + type: string + format: uuid + departmentId: + type: string + format: uuid + responses: + "201": + description: Created invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Active vendor and invoice number already exist. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/package.json b/packages/api/package.json index 0ef41fd..1a19141 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -31,6 +31,8 @@ }, "dependencies": { "@aws-sdk/client-rds-data": "^3.1135.0", + "@aws-sdk/client-s3": "^3.1137.0", + "@aws-sdk/s3-request-presigner": "^3.1137.0", "@hono/node-server": "^2.1.1", "@seahaven-ap/shared": "*", "drizzle-orm": "^0.45.2", diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index a75016e..455b8f5 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -9,6 +9,8 @@ import { createVendorRoutes } from "./routes/vendors.js"; import { createGlAccountRoutes } from "./routes/gl-accounts.js"; import { createDepartmentRoutes } from "./routes/departments.js"; import { createUserRoutes } from "./routes/users.js"; +import { createInvoiceRoutes } from "./routes/invoices.js"; +import { createDocumentsStore, type DocumentsStore } from "./documents.js"; import { errorJson } from "./http.js"; import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; import { csrfAllowed, isMutating } from "./auth/oauth.js"; @@ -16,6 +18,7 @@ import type { CognitoTokenClient } from "./auth/cognito.js"; export type AppDeps = AuthDeps & { tokens?: CognitoTokenClient; + documents?: DocumentsStore; }; export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { @@ -48,6 +51,7 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { api.route("/", createGlAccountRoutes(handle)); api.route("/", createDepartmentRoutes(handle)); api.route("/", createUserRoutes(handle)); + api.route("/", createInvoiceRoutes(handle, deps.documents ?? createDocumentsStore(env))); app.route("/api", api); app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); diff --git a/packages/api/src/documents.ts b/packages/api/src/documents.ts new file mode 100644 index 0000000..0ad0b99 --- /dev/null +++ b/packages/api/src/documents.ts @@ -0,0 +1,77 @@ +import { + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import type { ApiEnv } from "./env.js"; + +export type PresignPutResult = { + url: string; + headers: Record; +}; + +export type DocumentsStore = { + presignPut(input: { objectKey: string; contentType: string }): Promise; + objectExists(objectKey: string): Promise; + presignGet(objectKey: string): Promise; +}; + +const SIGN_EXPIRES_SECONDS = 900; + +export function createDocumentsStore(env: ApiEnv): DocumentsStore { + const client = new S3Client({ + region: env.awsRegion, + endpoint: env.documentsEndpoint || undefined, + forcePathStyle: Boolean(env.documentsEndpoint), + credentials: + env.documentsAccessKey && env.documentsSecretKey + ? { accessKeyId: env.documentsAccessKey, secretAccessKey: env.documentsSecretKey } + : undefined, + }); + const bucket = env.documentsBucket; + + return { + async presignPut({ objectKey, contentType }) { + const url = await getSignedUrl( + client, + new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: contentType }), + { expiresIn: SIGN_EXPIRES_SECONDS }, + ); + return { url, headers: { "Content-Type": contentType } }; + }, + async objectExists(objectKey) { + try { + await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey })); + return true; + } catch { + return false; + } + }, + async presignGet(objectKey) { + return getSignedUrl(client, new GetObjectCommand({ Bucket: bucket, Key: objectKey }), { + expiresIn: SIGN_EXPIRES_SECONDS, + }); + }, + }; +} + +export function createMemoryDocumentsStore(): DocumentsStore & { uploaded: Set } { + const uploaded = new Set(); + return { + uploaded, + async presignPut({ objectKey, contentType }) { + return { + url: `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=put`, + headers: { "Content-Type": contentType }, + }; + }, + async objectExists(objectKey) { + return uploaded.has(objectKey); + }, + async presignGet(objectKey) { + return `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=get`; + }, + }; +} diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 790b799..c9475f4 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -26,6 +26,10 @@ export type ApiEnv = { cognitoDomain: string; appOrigin: string; originVerifySecret: string; + documentsEndpoint: string; + documentsAccessKey: string; + documentsSecretKey: string; + documentsBucket: string; devAuthBypass: boolean; devAuthSub: string; devAuthEmail: string; @@ -79,6 +83,11 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { cognitoDomain: env.COGNITO_DOMAIN?.trim() ?? "", appOrigin: env.APP_ORIGIN?.trim() || (local ? "http://127.0.0.1:3000" : ""), originVerifySecret: env.ORIGIN_VERIFY_SECRET?.trim() ?? "", + documentsEndpoint: env.DOCUMENTS_ENDPOINT?.trim() || env.MINIO_ENDPOINT?.trim() || "", + documentsAccessKey: env.DOCUMENTS_ACCESS_KEY?.trim() || env.MINIO_ACCESS_KEY?.trim() || "", + documentsSecretKey: env.DOCUMENTS_SECRET_KEY?.trim() || env.MINIO_SECRET_KEY?.trim() || "", + documentsBucket: + env.DOCUMENTS_BUCKET?.trim() || env.MINIO_BUCKET?.trim() || "seahaven-ap-documents", devAuthBypass, devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin", devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com", diff --git a/packages/api/src/routes/helpers.ts b/packages/api/src/routes/helpers.ts index c44d41d..d12dfd1 100644 --- a/packages/api/src/routes/helpers.ts +++ b/packages/api/src/routes/helpers.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Context } from "hono"; +import type { Db } from "../db/client.js"; import type { UserRole } from "../env.js"; import { can, type RbacAction } from "../auth/rbac.js"; import { errorJson } from "../http.js"; @@ -46,4 +47,44 @@ export function parseJsonBody(c: Context): Promise> { return c.req.json>(); } +export function asMoney(value: unknown): string | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value.toFixed(2); + } + if (typeof value === "string" && /^\d+(\.\d{1,2})?$/.test(value.trim())) { + return Number(value).toFixed(2); + } + return null; +} + +export function moneyCents(value: string): number { + return Math.round(Number(value) * 100); +} + +export function isDateOnly(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +export async function rowsOf(handle: Db, table: unknown): Promise { + return (await handle.db.select().from(table as never)) as T[]; +} + +export async function firstById( + handle: Db, + table: unknown, + id: string, +): Promise { + const rows = await rowsOf(handle, table); + return rows.find((row) => row.id === id); +} + +export function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code: unknown }).code === "23505" + ); +} + export type { UserRole }; diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts new file mode 100644 index 0000000..23489a4 --- /dev/null +++ b/packages/api/src/routes/invoices.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../app.js"; +import { createMemoryDocumentsStore } from "../documents.js"; +import { loadEnv } from "../env.js"; +import type { ErrorEnvelope } from "../http.js"; +import { createFakeDb, SEED } from "../test/fake-db.js"; + +function expectEnvelope(body: unknown, code: string) { + const envelope = body as ErrorEnvelope; + expect(envelope.error.code).toBe(code); +} + +function envFor(role: "admin" | "viewer") { + return loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + DEV_AUTH_SUB: SEED.user.cognitoSub, + DEV_AUTH_EMAIL: SEED.user.email, + DEV_AUTH_NAME: SEED.user.name, + DEV_AUTH_ROLE: role, + }); +} + +const jsonHeaders = { + "content-type": "application/json", + origin: "http://127.0.0.1:3000", +}; + +describe("invoice stubs", () => { + it("lists the seeded invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices"); + expect(response.status).toBe(200); + const body = (await response.json()) as { items: Array<{ invoiceNumber: string }> }; + expect(body.items[0]?.invoiceNumber).toBe("INV-1001"); + }); + + it("creates an invoice when line amounts sum to the header amount", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-2002", + amount: "100.00", + dueDate: "2026-10-01", + lines: [ + { description: "Half", amount: "40.00" }, + { description: "Rest", amount: "60.00" }, + ], + }), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + invoiceNumber: "INV-2002", + amount: "100.00", + }); + }); + + it("rejects a duplicate active vendor and invoice number", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-1001", + amount: "10.00", + dueDate: "2026-10-01", + }), + }); + expect(response.status).toBe(409); + expectEnvelope(await response.json(), "CONFLICT"); + }); + + it("rejects a line replace whose amounts do not sum to the invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [{ description: "Too small", amount: "1.00" }], + }), + }); + expect(response.status).toBe(400); + expectEnvelope(await response.json(), "VALIDATION_ERROR"); + }); + + it("replaces lines when the amounts sum to the invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [ + { description: "Labor", amount: "1000.00", glAccountId: SEED.gl.id }, + { description: "Parts", amount: "250.00", departmentId: SEED.department.id }, + ], + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { items: Array<{ amount: string }> }; + expect(body.items).toHaveLength(2); + }); + + it("presigns a document and confirms after upload", async () => { + const documents = createMemoryDocumentsStore(); + const app = createApp(envFor("admin"), createFakeDb(), { documents }); + const created = await app.request(`/api/invoices/${SEED.invoice.id}/documents`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ fileName: "inv.pdf", contentType: "application/pdf" }), + }); + expect(created.status).toBe(201); + const doc = (await created.json()) as { id: string; objectKey: string; uploadUrl: string }; + expect(doc.uploadUrl).toContain("presign=put"); + const missing = await app.request(`/api/documents/${doc.id}/confirmations`, { + method: "POST", + headers: jsonHeaders, + body: "{}", + }); + expect(missing.status).toBe(409); + documents.uploaded.add(doc.objectKey); + const confirmed = await app.request(`/api/documents/${doc.id}/confirmations`, { + method: "POST", + headers: jsonHeaders, + body: "{}", + }); + expect(confirmed.status).toBe(200); + const got = await app.request(`/api/documents/${doc.id}`); + expect(got.status).toBe(200); + await expect(got.json()).resolves.toMatchObject({ id: doc.id, fileName: "inv.pdf" }); + }); + + it("returns 403 when a viewer writes an invoice", async () => { + const app = createApp(envFor("viewer"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-9", + amount: "1.00", + dueDate: "2026-10-01", + }), + }); + expect(response.status).toBe(403); + expectEnvelope(await response.json(), "FORBIDDEN"); + }); +}); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts new file mode 100644 index 0000000..87bf05b --- /dev/null +++ b/packages/api/src/routes/invoices.ts @@ -0,0 +1,446 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import type { DocumentsStore } from "../documents.js"; +import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { + asMoney, + asString, + caller, + firstById, + isDateOnly, + isUniqueViolation, + iso, + isUuid, + moneyCents, + newId, + optionalString, + parseJsonBody, + requireCan, + rowsOf, +} from "./helpers.js"; + +const STATUSES = ["pending_approval", "approved", "scheduled", "paid", "rejected", "void"] as const; +const PAYMENT_METHODS = ["check", "ach"] as const; + +type InvoiceRow = typeof invoices.$inferSelect; +type LineRow = typeof invoiceLines.$inferSelect; +type DocumentRow = typeof documents.$inferSelect; +type VendorRow = typeof vendors.$inferSelect; +type LineInput = { + description: string; + amount: string; + glAccountId: string | null; + departmentId: string | null; +}; + +function isStatus(value: string): value is (typeof STATUSES)[number] { + return (STATUSES as readonly string[]).includes(value); +} + +function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] { + return (PAYMENT_METHODS as readonly string[]).includes(value); +} + +function toInvoice(row: InvoiceRow, lines: LineRow[]) { + return { + id: row.id, + vendorId: row.vendorId, + invoiceNumber: row.invoiceNumber, + amount: row.amount, + amountDue: row.amountDue, + dueDate: row.dueDate, + payDate: row.payDate, + sendPaymentOn: row.sendPaymentOn, + status: row.status, + paymentMethod: row.paymentMethod, + memo: row.memo, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + lines: lines.map(toLine), + }; +} + +function toLine(row: LineRow) { + return { + id: row.id, + invoiceId: row.invoiceId, + description: row.description, + amount: row.amount, + glAccountId: row.glAccountId, + departmentId: row.departmentId, + createdAt: iso(row.createdAt), + }; +} + +function toDocument( + row: DocumentRow, + urls: { uploadUrl?: string; uploadHeaders?: Record; downloadUrl?: string } = {}, +) { + return { + id: row.id, + invoiceId: row.invoiceId, + objectKey: row.objectKey, + contentType: row.contentType, + fileName: row.fileName, + uploadedByUserId: row.uploadedByUserId, + createdAt: iso(row.createdAt), + ...urls, + }; +} + +function parseLine(body: Record): LineInput | string { + const description = asString(body.description).trim(); + const amount = asMoney(body.amount); + if (!description || !amount) return "Line description and amount are required."; + const glAccountId = optionalString(body.glAccountId) ?? null; + const departmentId = optionalString(body.departmentId) ?? null; + if (glAccountId && !isUuid(glAccountId)) return "Invalid glAccountId."; + if (departmentId && !isUuid(departmentId)) return "Invalid departmentId."; + return { description, amount, glAccountId, departmentId }; +} + +function linesSumToAmount(lines: Array<{ amount: string }>, amount: string): boolean { + const sum = lines.reduce((total, line) => total + moneyCents(line.amount), 0); + return sum === moneyCents(amount); +} + +async function linesFor(handle: Db, invoiceId: string): Promise { + const rows = await rowsOf(handle, invoiceLines); + return rows.filter((row) => row.invoiceId === invoiceId); +} + +async function activeDuplicate( + handle: Db, + vendorId: string, + invoiceNumber: string, + exceptId?: string, +): Promise { + const rows = await rowsOf(handle, invoices); + return rows.some( + (row) => + row.vendorId === vendorId && + row.invoiceNumber === invoiceNumber && + row.status !== "void" && + row.id !== exceptId, + ); +} + +function safeFileName(value: string): string { + return ( + value + .replace(/[/\\]+/g, "_") + .replace(/^\.+/, "_") + .slice(0, 180) || "document" + ); +} + +export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { + const routes = new Hono(); + + routes.get("/invoices", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await rowsOf(handle, invoices); + const allLines = await rowsOf(handle, invoiceLines); + return c.json({ + items: rows.map((row) => + toInvoice( + row, + allLines.filter((line) => line.invoiceId === row.id), + ), + ), + }); + }); + + routes.get("/invoices/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const row = await firstById(handle, invoices, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + return c.json(toInvoice(row, await linesFor(handle, id))); + }); + + routes.post("/invoices", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const body = await parseJsonBody(c); + const vendorId = asString(body.vendorId); + const invoiceNumber = asString(body.invoiceNumber).trim(); + const amount = asMoney(body.amount); + const dueDate = asString(body.dueDate); + if (!isUuid(vendorId) || !invoiceNumber || !amount || !isDateOnly(dueDate)) { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "vendorId, invoiceNumber, amount, and dueDate are required.", + ); + } + const vendor = await firstById(handle, vendors, vendorId); + if (!vendor) return errorJson(c, 400, "VALIDATION_ERROR", "Vendor not found."); + const method = asString(body.paymentMethod, vendor.defaultPaymentMethod); + if (!isPaymentMethod(method)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); + } + const parsedLines: LineInput[] = []; + if (Array.isArray(body.lines)) { + for (const raw of body.lines) { + if (!raw || typeof raw !== "object") { + return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); + } + const parsed = parseLine(raw as Record); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + parsedLines.push(parsed); + } + if (!linesSumToAmount(parsedLines, amount)) { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "Line amounts must sum to the invoice amount.", + ); + } + } + if (await activeDuplicate(handle, vendorId, invoiceNumber)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + let row: InvoiceRow; + try { + [row] = await handle.db + .insert(invoices) + .values({ + vendorId, + invoiceNumber, + amount, + amountDue: amount, + dueDate, + paymentMethod: method, + memo: asString(body.memo), + status: "pending_approval", + }) + .returning(); + } catch (error) { + if (isUniqueViolation(error)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + throw error; + } + for (const line of parsedLines) { + await handle.db + .insert(invoiceLines) + .values({ invoiceId: row.id, ...line }) + .returning(); + } + return c.json(toInvoice(row, await linesFor(handle, row.id)), 201); + }); + + routes.patch("/invoices/:id", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const existing = await firstById(handle, invoices, id); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = await parseJsonBody(c); + const patch: Partial = { updatedAt: new Date() }; + if (body.vendorId !== undefined) { + const vendorId = asString(body.vendorId); + if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId."); + patch.vendorId = vendorId; + } + if (body.invoiceNumber !== undefined) { + const invoiceNumber = asString(body.invoiceNumber).trim(); + if (!invoiceNumber) + return errorJson(c, 400, "VALIDATION_ERROR", "invoiceNumber is required."); + patch.invoiceNumber = invoiceNumber; + } + if (body.amount !== undefined) { + const amount = asMoney(body.amount); + if (!amount) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid amount."); + patch.amount = amount; + patch.amountDue = amount; + } + if (body.dueDate !== undefined) { + const dueDate = asString(body.dueDate); + if (!isDateOnly(dueDate)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid dueDate."); + patch.dueDate = dueDate; + } + if (body.payDate !== undefined) { + const payDate = optionalString(body.payDate) ?? null; + if (payDate && !isDateOnly(payDate)) + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid payDate."); + patch.payDate = payDate; + } + if (body.sendPaymentOn !== undefined) { + const sendPaymentOn = optionalString(body.sendPaymentOn) ?? null; + if (sendPaymentOn && !isDateOnly(sendPaymentOn)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid sendPaymentOn."); + } + patch.sendPaymentOn = sendPaymentOn; + } + if (body.status !== undefined) { + const status = asString(body.status); + if (!isStatus(status)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid status."); + patch.status = status; + } + if (body.paymentMethod !== undefined) { + const method = asString(body.paymentMethod); + if (!isPaymentMethod(method)) + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); + patch.paymentMethod = method; + } + if (body.memo !== undefined) patch.memo = asString(body.memo); + const nextVendor = patch.vendorId ?? existing.vendorId; + const nextNumber = patch.invoiceNumber ?? existing.invoiceNumber; + const nextStatus = patch.status ?? existing.status; + if (nextStatus !== "void" && (await activeDuplicate(handle, nextVendor, nextNumber, id))) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + const nextAmount = patch.amount ?? existing.amount; + const currentLines = await linesFor(handle, id); + if (currentLines.length > 0 && !linesSumToAmount(currentLines, nextAmount)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); + } + let row: InvoiceRow; + try { + [row] = await handle.db.update(invoices).set(patch).where(eq(invoices.id, id)).returning(); + } catch (error) { + if (isUniqueViolation(error)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + throw error; + } + return c.json(toInvoice(row, currentLines)); + }); + + routes.post("/invoices/:id/lines", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const parsed = parseLine(await parseJsonBody(c)); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + const [row] = await handle.db + .insert(invoiceLines) + .values({ invoiceId: id, ...parsed }) + .returning(); + return c.json(toLine(row), 201); + }); + + routes.put("/invoices/:id/lines", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = await parseJsonBody(c); + if (!Array.isArray(body.items)) { + return errorJson(c, 400, "VALIDATION_ERROR", "items must be an array of lines."); + } + const parsedLines: LineInput[] = []; + for (const raw of body.items) { + if (!raw || typeof raw !== "object") { + return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); + } + const parsed = parseLine(raw as Record); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + parsedLines.push(parsed); + } + if (!linesSumToAmount(parsedLines, invoice.amount)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); + } + await handle.db.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id)); + const created: LineRow[] = []; + for (const line of parsedLines) { + const [row] = await handle.db + .insert(invoiceLines) + .values({ invoiceId: id, ...line }) + .returning(); + created.push(row); + } + return c.json({ items: created.map(toLine) }); + }); + + routes.post("/invoices/:id/documents", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = await parseJsonBody(c); + const fileName = safeFileName(asString(body.fileName).trim()); + const contentType = asString(body.contentType, "application/pdf").trim() || "application/pdf"; + if (!asString(body.fileName).trim()) { + return errorJson(c, 400, "VALIDATION_ERROR", "fileName is required."); + } + const documentId = newId(); + const objectKey = `invoices/${id}/${documentId}/${fileName}`; + const [row] = await handle.db + .insert(documents) + .values({ + id: documentId, + invoiceId: id, + objectKey, + contentType, + fileName, + uploadedByUserId: caller(c).id, + }) + .returning(); + const put = await store.presignPut({ objectKey, contentType }); + return c.json(toDocument(row, { uploadUrl: put.url, uploadHeaders: put.headers }), 201); + }); + + routes.post("/documents/:id/confirmations", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id."); + const row = await firstById(handle, documents, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); + if (!(await store.objectExists(row.objectKey))) { + return errorJson(c, 409, "CONFLICT", "Object has not been uploaded."); + } + return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); + }); + + routes.get("/documents/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id."); + const row = await firstById(handle, documents, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); + return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); + }); + + return routes; +} diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts index eaae40e..b9d10f4 100644 --- a/packages/api/src/test/fake-db.ts +++ b/packages/api/src/test/fake-db.ts @@ -35,6 +35,39 @@ export const SEED = { createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), }, + invoice: { + id: "88888888-8888-4888-8888-888888888888", + vendorId: "55555555-5555-4555-8555-555555555555", + invoiceNumber: "INV-1001", + amount: "1250.00", + amountDue: "1250.00", + dueDate: "2026-09-01", + payDate: null as string | null, + sendPaymentOn: null as string | null, + status: "pending_approval" as const, + paymentMethod: "check" as const, + memo: "Seed invoice for local smoke.", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + line: { + id: "99999999-9999-4999-8999-999999999999", + invoiceId: "88888888-8888-4888-8888-888888888888", + description: "Monthly maintenance", + amount: "1250.00", + glAccountId: "66666666-6666-4666-8666-666666666666", + departmentId: "77777777-7777-4777-8777-777777777777", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + document: { + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + invoiceId: "88888888-8888-4888-8888-888888888888", + objectKey: "seed/inv-1001.pdf", + contentType: "application/pdf", + fileName: "inv-1001.pdf", + uploadedByUserId: "11111111-1111-4111-8111-111111111111", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, }; export type Store = { @@ -42,9 +75,9 @@ export type Store = { vendors: Array; glAccounts: Array; departments: Array; - invoices: Array>; - invoiceLines: Array>; - documents: Array>; + invoices: Array; + invoiceLines: Array; + documents: Array; approvalPolicies: Array>; approvalSteps: Array>; invoiceComments: Array>; @@ -57,9 +90,9 @@ export function emptyStore(): Store { vendors: [{ ...SEED.vendor }], glAccounts: [{ ...SEED.gl }], departments: [{ ...SEED.department }], - invoices: [], - invoiceLines: [], - documents: [], + invoices: [{ ...SEED.invoice }], + invoiceLines: [{ ...SEED.line }], + documents: [{ ...SEED.document }], approvalPolicies: [], approvalSteps: [], invoiceComments: [], @@ -157,8 +190,11 @@ export function createFakeDb(store: Store = emptyStore()): Db { })), })), })), - delete: vi.fn(() => ({ - where: vi.fn(async () => undefined), + delete: vi.fn((table: unknown) => ({ + where: vi.fn(async () => { + const rows = rowsFor(store, table); + rows.splice(0, rows.length); + }), })), }; diff --git a/src/api/types.ts b/src/api/types.ts index 510f9c5..b4e3f46 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -52,6 +52,46 @@ export type User = { updatedAt: string; }; +export type InvoiceLine = { + id: string; + invoiceId: string; + description: string; + amount: string; + glAccountId: string | null; + departmentId: string | null; + createdAt: string; +}; + +export type Invoice = { + id: string; + vendorId: string; + invoiceNumber: string; + amount: string; + amountDue: string; + dueDate: string; + payDate: string | null; + sendPaymentOn: string | null; + status: "pending_approval" | "approved" | "scheduled" | "paid" | "rejected" | "void"; + paymentMethod: "check" | "ach"; + memo: string; + createdAt: string; + updatedAt: string; + lines: InvoiceLine[]; +}; + +export type Document = { + id: string; + invoiceId: string | null; + objectKey: string; + contentType: string; + fileName: string; + uploadedByUserId: string | null; + createdAt: string; + uploadUrl?: string; + uploadHeaders?: Record; + downloadUrl?: string; +}; + export type ApiPaths = { "/api/health": { get: { response: HealthResponse }; @@ -71,4 +111,10 @@ export type ApiPaths = { "/api/users": { get: { response: { items: User[] } }; }; + "/api/invoices": { + get: { response: { items: Invoice[] } }; + }; + "/api/documents/{id}": { + get: { response: Document }; + }; };