mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-02 12:03:23 +00:00
feat(api): add invoice, line, and document stubs
This commit is contained in:
parent
ebee62acd0
commit
f08b538a19
18 changed files with 1562 additions and 8 deletions
74
package-lock.json
generated
74
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
52
packages/api/openapi/paths/documents-id-confirmations.yaml
Normal file
52
packages/api/openapi/paths/documents-id-confirmations.yaml
Normal 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
|
||||
39
packages/api/openapi/paths/documents-id.yaml
Normal file
39
packages/api/openapi/paths/documents-id.yaml
Normal 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
|
||||
53
packages/api/openapi/paths/invoices-id-documents.yaml
Normal file
53
packages/api/openapi/paths/invoices-id-documents.yaml
Normal 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
|
||||
123
packages/api/openapi/paths/invoices-id-lines.yaml
Normal file
123
packages/api/openapi/paths/invoices-id-lines.yaml
Normal 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
|
||||
106
packages/api/openapi/paths/invoices-id.yaml
Normal file
106
packages/api/openapi/paths/invoices-id.yaml
Normal 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
|
||||
106
packages/api/openapi/paths/invoices.yaml
Normal file
106
packages/api/openapi/paths/invoices.yaml
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."));
|
||||
|
|
|
|||
77
packages/api/src/documents.ts
Normal file
77
packages/api/src/documents.ts
Normal 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`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
163
packages/api/src/routes/invoices.test.ts
Normal file
163
packages/api/src/routes/invoices.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
446
packages/api/src/routes/invoices.ts
Normal file
446
packages/api/src/routes/invoices.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue