feat(api): add invoice, line, and document stubs

This commit is contained in:
Adam Moussa 2026-09-22 12:48:42 -04:00
parent ebee62acd0
commit f08b538a19
No known key found for this signature in database
18 changed files with 1562 additions and 8 deletions

74
package-lock.json generated
View file

@ -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",

View file

@ -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.

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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."));

View file

@ -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<string, string>;
};
export type DocumentsStore = {
presignPut(input: { objectKey: string; contentType: string }): Promise<PresignPutResult>;
objectExists(objectKey: string): Promise<boolean>;
presignGet(objectKey: string): Promise<string>;
};
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<string> } {
const uploaded = new Set<string>();
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`;
},
};
}

View file

@ -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",

View file

@ -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<Record<string, unknown>> {
return c.req.json<Record<string, unknown>>();
}
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<T>(handle: Db, table: unknown): Promise<T[]> {
return (await handle.db.select().from(table as never)) as T[];
}
export async function firstById<T extends { id: string }>(
handle: Db,
table: unknown,
id: string,
): Promise<T | undefined> {
const rows = await rowsOf<T>(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 };

View file

@ -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");
});
});

View file

@ -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<string, string>; 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<string, unknown>): 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<LineRow[]> {
const rows = await rowsOf<LineRow>(handle, invoiceLines);
return rows.filter((row) => row.invoiceId === invoiceId);
}
async function activeDuplicate(
handle: Db,
vendorId: string,
invoiceNumber: string,
exceptId?: string,
): Promise<boolean> {
const rows = await rowsOf<InvoiceRow>(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<AppBindings>();
routes.get("/invoices", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await rowsOf<InvoiceRow>(handle, invoices);
const allLines = await rowsOf<LineRow>(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<InvoiceRow>(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<VendorRow>(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<string, unknown>);
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<InvoiceRow>(handle, invoices, id);
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof invoices.$inferInsert> = { 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<InvoiceRow>(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<InvoiceRow>(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<string, unknown>);
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<InvoiceRow>(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<DocumentRow>(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<DocumentRow>(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;
}

View file

@ -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<typeof SEED.vendor>;
glAccounts: Array<typeof SEED.gl>;
departments: Array<typeof SEED.department>;
invoices: Array<Record<string, unknown>>;
invoiceLines: Array<Record<string, unknown>>;
documents: Array<Record<string, unknown>>;
invoices: Array<typeof SEED.invoice>;
invoiceLines: Array<typeof SEED.line>;
documents: Array<typeof SEED.document>;
approvalPolicies: Array<Record<string, unknown>>;
approvalSteps: Array<Record<string, unknown>>;
invoiceComments: Array<Record<string, unknown>>;
@ -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);
}),
})),
};

View file

@ -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<string, string>;
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 };
};
};