mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- research.md: 9 architectural decisions (two-tier OCR, Azure+tesseract fallback) - data-model.md: K1ImportSession, CellMapping models, K1ImportStatus enum - contracts/k1-import-api.md: 10 REST endpoints - quickstart.md: file structure, setup guide, workflow - plan.md: summary, technical context, constitution check, project structure - Updated copilot agent context with new tech stack entriespull/6701/head
6 changed files with 1021 additions and 1 deletions
@ -0,0 +1,381 @@ |
|||
# API Contracts: K-1 Import |
|||
|
|||
**Phase 1 Output** | **Date**: 2026-03-18 |
|||
|
|||
## Base Path |
|||
|
|||
All endpoints under `/api/v1/k1-import/` |
|||
|
|||
## Authentication |
|||
|
|||
All endpoints require JWT authentication (`AuthGuard('jwt')`) and appropriate permissions via `HasPermissionGuard`. |
|||
|
|||
--- |
|||
|
|||
## Endpoints |
|||
|
|||
### POST /api/v1/k1-import/upload |
|||
|
|||
Upload a K-1 PDF and initiate extraction. |
|||
|
|||
**Permission**: `createKDocument` |
|||
|
|||
**Request**: `multipart/form-data` |
|||
|
|||
| Field | Type | Required | Description | |
|||
| --------------- | -------- | -------- | ------------------------------------ | |
|||
| `file` | File | Yes | PDF file (max 25 MB, MIME: application/pdf) | |
|||
| `partnershipId` | `string` | Yes | Target partnership UUID | |
|||
| `taxYear` | `number` | Yes | Tax year for this K-1 | |
|||
|
|||
**Response**: `201 Created` |
|||
|
|||
```json |
|||
{ |
|||
"id": "uuid", |
|||
"partnershipId": "uuid", |
|||
"status": "PROCESSING", |
|||
"taxYear": 2025, |
|||
"fileName": "K1-Smith-Capital-2025.pdf", |
|||
"fileSize": 245760, |
|||
"extractionMethod": "pdf-parse", |
|||
"createdAt": "2026-03-18T00:00:00.000Z" |
|||
} |
|||
``` |
|||
|
|||
**Errors**: |
|||
|
|||
| Status | Condition | |
|||
| ------ | -------------------------------------- | |
|||
| 400 | File is not a valid PDF | |
|||
| 400 | File exceeds 25 MB size limit | |
|||
| 400 | Partnership not found or not owned by user | |
|||
| 400 | Partnership has no active members | |
|||
| 400 | Tax year < partnership inception year | |
|||
|
|||
--- |
|||
|
|||
### GET /api/v1/k1-import/:id |
|||
|
|||
Get the current state of an import session, including extraction results. |
|||
|
|||
**Permission**: `readKDocument` |
|||
|
|||
**Response**: `200 OK` |
|||
|
|||
```json |
|||
{ |
|||
"id": "uuid", |
|||
"partnershipId": "uuid", |
|||
"status": "EXTRACTED", |
|||
"taxYear": 2025, |
|||
"fileName": "K1-Smith-Capital-2025.pdf", |
|||
"fileSize": 245760, |
|||
"extractionMethod": "pdf-parse", |
|||
"rawExtraction": { |
|||
"metadata": { |
|||
"partnershipName": "Smith Capital Partners LP", |
|||
"partnershipEin": "12-3456789", |
|||
"partnerName": "Smith Family Trust", |
|||
"partnerEin": "98-7654321", |
|||
"taxYear": 2025, |
|||
"isAmended": false, |
|||
"isFinal": true |
|||
}, |
|||
"fields": [ |
|||
{ |
|||
"boxNumber": "1", |
|||
"label": "Ordinary business income (loss)", |
|||
"customLabel": null, |
|||
"rawValue": "$52,340", |
|||
"numericValue": 52340, |
|||
"confidence": 0.95, |
|||
"confidenceLevel": "HIGH", |
|||
"isUserEdited": false |
|||
} |
|||
], |
|||
"overallConfidence": 0.92, |
|||
"method": "pdf-parse", |
|||
"pagesProcessed": 2 |
|||
}, |
|||
"verifiedData": null, |
|||
"documentId": "uuid", |
|||
"kDocumentId": null, |
|||
"errorMessage": null, |
|||
"createdAt": "2026-03-18T00:00:00.000Z", |
|||
"updatedAt": "2026-03-18T00:00:05.000Z" |
|||
} |
|||
``` |
|||
|
|||
**Errors**: |
|||
|
|||
| Status | Condition | |
|||
| ------ | ----------------------------------- | |
|||
| 404 | Import session not found | |
|||
| 403 | Import session belongs to different user | |
|||
|
|||
--- |
|||
|
|||
### PUT /api/v1/k1-import/:id/verify |
|||
|
|||
Submit user-verified/edited extraction data. Transitions status from EXTRACTED to VERIFIED. |
|||
|
|||
**Permission**: `updateKDocument` |
|||
|
|||
**Request**: `application/json` |
|||
|
|||
```json |
|||
{ |
|||
"taxYear": 2025, |
|||
"fields": [ |
|||
{ |
|||
"boxNumber": "1", |
|||
"label": "Ordinary business income (loss)", |
|||
"customLabel": null, |
|||
"rawValue": "$52,340", |
|||
"numericValue": 52340, |
|||
"confidence": 0.95, |
|||
"confidenceLevel": "HIGH", |
|||
"isUserEdited": false |
|||
}, |
|||
{ |
|||
"boxNumber": "11", |
|||
"label": "Other income (loss)", |
|||
"customLabel": "Section 1256 contracts", |
|||
"rawValue": "$8,200", |
|||
"numericValue": 8200, |
|||
"confidence": 0.72, |
|||
"confidenceLevel": "MEDIUM", |
|||
"isUserEdited": true |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**Response**: `200 OK` — Updated import session with status `VERIFIED` |
|||
|
|||
**Errors**: |
|||
|
|||
| Status | Condition | |
|||
| ------ | ----------------------------------------------- | |
|||
| 400 | Import session is not in EXTRACTED status | |
|||
| 400 | Fields array is empty | |
|||
| 404 | Import session not found | |
|||
|
|||
--- |
|||
|
|||
### POST /api/v1/k1-import/:id/confirm |
|||
|
|||
Confirm verified data and trigger automatic model object creation (KDocument, Distributions, Document linkage). |
|||
|
|||
**Permission**: `createKDocument` |
|||
|
|||
**Request**: `application/json` |
|||
|
|||
```json |
|||
{ |
|||
"filingStatus": "DRAFT", |
|||
"existingKDocumentAction": null |
|||
} |
|||
``` |
|||
|
|||
| Field | Type | Required | Description | |
|||
| ------------------------- | ----------------------------- | -------- | ---------------------------------------- | |
|||
| `filingStatus` | `"DRAFT" \| "ESTIMATED" \| "FINAL"` | Yes | Status for the created/updated KDocument | |
|||
| `existingKDocumentAction` | `"UPDATE" \| "CREATE_NEW" \| null` | No | Action if KDocument already exists | |
|||
|
|||
**Response**: `201 Created` |
|||
|
|||
```json |
|||
{ |
|||
"importSession": { |
|||
"id": "uuid", |
|||
"status": "CONFIRMED" |
|||
}, |
|||
"kDocument": { |
|||
"id": "uuid", |
|||
"partnershipId": "uuid", |
|||
"type": "K1", |
|||
"taxYear": 2025, |
|||
"filingStatus": "DRAFT", |
|||
"data": { "ordinaryIncome": 52340, "..." : "..." } |
|||
}, |
|||
"distributions": [ |
|||
{ |
|||
"id": "uuid", |
|||
"entityId": "uuid", |
|||
"partnershipId": "uuid", |
|||
"type": "RETURN_OF_CAPITAL", |
|||
"amount": 60000, |
|||
"date": "2025-12-31T00:00:00.000Z" |
|||
} |
|||
], |
|||
"allocations": [ |
|||
{ |
|||
"entityId": "uuid", |
|||
"entityName": "Smith Family Trust", |
|||
"ownershipPercent": 60, |
|||
"allocatedValues": { "ordinaryIncome": 31404, "..." : "..." } |
|||
} |
|||
], |
|||
"document": { |
|||
"id": "uuid", |
|||
"type": "K1", |
|||
"name": "K1-Smith-Capital-2025.pdf" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**Errors**: |
|||
|
|||
| Status | Condition | |
|||
| ------ | ------------------------------------------------------------- | |
|||
| 400 | Import session is not in VERIFIED status | |
|||
| 400 | Partnership has no active members | |
|||
| 409 | KDocument already exists for this partnership/year and no action specified | |
|||
|
|||
--- |
|||
|
|||
### POST /api/v1/k1-import/:id/cancel |
|||
|
|||
Cancel an import session. No model objects are created. |
|||
|
|||
**Permission**: `updateKDocument` |
|||
|
|||
**Response**: `200 OK` — Updated import session with status `CANCELLED` |
|||
|
|||
**Errors**: |
|||
|
|||
| Status | Condition | |
|||
| ------ | --------------------------------------------- | |
|||
| 400 | Import session is already CONFIRMED or CANCELLED | |
|||
| 404 | Import session not found | |
|||
|
|||
--- |
|||
|
|||
### GET /api/v1/k1-import/history |
|||
|
|||
List import sessions for a partnership, ordered by creation date descending. |
|||
|
|||
**Permission**: `readKDocument` |
|||
|
|||
**Query Parameters**: |
|||
|
|||
| Param | Type | Required | Description | |
|||
| --------------- | -------- | -------- | ------------------------------ | |
|||
| `partnershipId` | `string` | Yes | Partnership UUID | |
|||
| `taxYear` | `number` | No | Filter by tax year | |
|||
|
|||
**Response**: `200 OK` — Array of import session summaries |
|||
|
|||
```json |
|||
[ |
|||
{ |
|||
"id": "uuid", |
|||
"partnershipId": "uuid", |
|||
"status": "CONFIRMED", |
|||
"taxYear": 2025, |
|||
"fileName": "K1-Smith-Capital-2025.pdf", |
|||
"extractionMethod": "pdf-parse", |
|||
"kDocumentId": "uuid", |
|||
"createdAt": "2026-03-18T00:00:00.000Z" |
|||
} |
|||
] |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### POST /api/v1/k1-import/:id/reprocess |
|||
|
|||
Re-run extraction on a previously uploaded PDF using the current cell mapping configuration. |
|||
|
|||
**Permission**: `updateKDocument` |
|||
|
|||
**Response**: `200 OK` — New import session with status `PROCESSING` (original session unchanged) |
|||
|
|||
**Errors**: |
|||
|
|||
| Status | Condition | |
|||
| ------ | ------------------------------------------- | |
|||
| 400 | Original import session has no stored document | |
|||
| 404 | Import session not found | |
|||
|
|||
--- |
|||
|
|||
## Cell Mapping Endpoints |
|||
|
|||
### GET /api/v1/cell-mapping |
|||
|
|||
Get cell mappings for a partnership (with global defaults for unmapped boxes). |
|||
|
|||
**Permission**: `readKDocument` |
|||
|
|||
**Query Parameters**: |
|||
|
|||
| Param | Type | Required | Description | |
|||
| --------------- | -------- | -------- | ---------------------------------------- | |
|||
| `partnershipId` | `string` | No | Partnership UUID (omit for global defaults) | |
|||
|
|||
**Response**: `200 OK` |
|||
|
|||
```json |
|||
[ |
|||
{ |
|||
"id": "uuid", |
|||
"partnershipId": null, |
|||
"boxNumber": "1", |
|||
"label": "Ordinary business income (loss)", |
|||
"description": "IRS Schedule K-1 Box 1", |
|||
"isCustom": false, |
|||
"sortOrder": 1 |
|||
} |
|||
] |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### PUT /api/v1/cell-mapping |
|||
|
|||
Update or create cell mappings for a partnership. |
|||
|
|||
**Permission**: `updateKDocument` |
|||
|
|||
**Request**: `application/json` |
|||
|
|||
```json |
|||
{ |
|||
"partnershipId": "uuid", |
|||
"mappings": [ |
|||
{ |
|||
"boxNumber": "11", |
|||
"label": "Section 1256 contracts", |
|||
"description": "Custom label for Box 11", |
|||
"isCustom": false |
|||
}, |
|||
{ |
|||
"boxNumber": "20-Z", |
|||
"label": "Qualified Business Income (Section 199A)", |
|||
"description": "Custom additional box", |
|||
"isCustom": true |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**Response**: `200 OK` — Updated mappings array |
|||
|
|||
--- |
|||
|
|||
### DELETE /api/v1/cell-mapping/reset |
|||
|
|||
Reset a partnership's cell mappings to IRS defaults (deletes all custom mappings for the partnership). |
|||
|
|||
**Permission**: `updateKDocument` |
|||
|
|||
**Query Parameters**: |
|||
|
|||
| Param | Type | Required | Description | |
|||
| --------------- | -------- | -------- | ------------------ | |
|||
| `partnershipId` | `string` | Yes | Partnership UUID | |
|||
|
|||
**Response**: `200 OK` |
|||
@ -0,0 +1,241 @@ |
|||
# Data Model: K-1 PDF Scan Import |
|||
|
|||
**Phase 1 Output** | **Date**: 2026-03-18 |
|||
|
|||
## Overview |
|||
|
|||
This feature adds 2 new Prisma models and 1 new enum to support K-1 PDF scanning, import session tracking, and cell mapping configuration. It extends the existing models from spec 001-family-office-transform (KDocument, Distribution, Document, PartnershipMembership) with automatic creation from scanned data. |
|||
|
|||
### Entity Relationship Diagram (Conceptual) |
|||
|
|||
``` |
|||
User (existing) |
|||
└── Partnership (existing from 001) |
|||
├── K1ImportSession[] ──┬── Document (uploaded PDF, existing from 001) |
|||
│ (new model) ├── KDocument (auto-created, existing from 001) |
|||
│ └── CellMapping (per-partnership config) |
|||
├── PartnershipMembership[] (existing from 001) |
|||
│ └── [K-1 allocations computed at confirm time] |
|||
├── KDocument[] (existing from 001) |
|||
│ └── Distribution[] (auto-created from Box 19, existing from 001) |
|||
└── CellMapping[] (new model, per-partnership overrides) |
|||
|
|||
Global CellMapping (partnershipId = null) ── IRS default box definitions |
|||
``` |
|||
|
|||
## New Enum |
|||
|
|||
### K1ImportStatus |
|||
|
|||
Tracks the lifecycle of a K-1 import session. |
|||
|
|||
| Value | Description | |
|||
| ------------ | -------------------------------------------------------------- | |
|||
| `PROCESSING` | PDF uploaded, extraction in progress | |
|||
| `EXTRACTED` | Extraction complete, awaiting user review | |
|||
| `VERIFIED` | User has reviewed/edited values, ready for confirmation | |
|||
| `CONFIRMED` | User confirmed, model objects created (KDocument, Distributions) | |
|||
| `CANCELLED` | User cancelled, no model objects created | |
|||
| `FAILED` | Extraction failed (invalid PDF, OCR error, etc.) | |
|||
|
|||
## New Models |
|||
|
|||
### K1ImportSession |
|||
|
|||
A record of a single K-1 PDF import attempt, tracking the full lifecycle from upload through confirmation. |
|||
|
|||
| Field | Type | Constraints | Description | |
|||
| ------------------ | ---------------- | ---------------------------- | --------------------------------------------------------------- | |
|||
| `id` | `String` | PK, UUID, auto-generated | Unique identifier | |
|||
| `partnershipId` | `String` | FK → Partnership.id, indexed | Target partnership for this K-1 import | |
|||
| `userId` | `String` | FK → User.id, indexed | User who initiated the import | |
|||
| `status` | `K1ImportStatus` | Required, Default: PROCESSING | Current lifecycle status | |
|||
| `taxYear` | `Int` | Required | Tax year extracted or specified by user | |
|||
| `fileName` | `String` | Required | Original filename of uploaded PDF | |
|||
| `fileSize` | `Int` | Required | File size in bytes | |
|||
| `extractionMethod` | `String` | Required | Method used: "pdf-parse", "azure", "tesseract" | |
|||
| `rawExtraction` | `Json?` | Optional | Raw extraction results before user edits | |
|||
| `verifiedData` | `Json?` | Optional | User-verified/edited extraction results (K1ExtractionResult) | |
|||
| `documentId` | `String?` | FK → Document.id, optional | Linked uploaded PDF Document record | |
|||
| `kDocumentId` | `String?` | FK → KDocument.id, optional | Resulting KDocument (set on CONFIRMED status) | |
|||
| `errorMessage` | `String?` | Optional | Error details if status is FAILED | |
|||
| `createdAt` | `DateTime` | Default: now() | Upload timestamp | |
|||
| `updatedAt` | `DateTime` | Auto-updated | Last modification timestamp | |
|||
|
|||
**Relations**: |
|||
|
|||
- `partnership` → `Partnership` (many-to-one, cascade delete) |
|||
- `user` → `User` (many-to-one, cascade delete) |
|||
- `document` → `Document?` (many-to-one, optional) |
|||
- `kDocument` → `KDocument?` (many-to-one, optional) |
|||
|
|||
**Indexes**: `@@index([partnershipId, taxYear])` for import history queries per partnership/year. |
|||
|
|||
### CellMapping |
|||
|
|||
A configuration defining how K-1 box numbers map to labels. Supports a global IRS-default set (partnershipId = null) and per-partnership customizations. |
|||
|
|||
| Field | Type | Constraints | Description | |
|||
| --------------- | ---------- | -------------------------------------- | ---------------------------------------------------- | |
|||
| `id` | `String` | PK, UUID, auto-generated | Unique identifier | |
|||
| `partnershipId` | `String?` | FK → Partnership.id, optional, indexed | Partnership this mapping applies to (null = global) | |
|||
| `boxNumber` | `String` | Required | K-1 box identifier (e.g., "1", "6a", "19a", "20-A") | |
|||
| `label` | `String` | Required | Display label (e.g., "Ordinary business income") | |
|||
| `description` | `String?` | Optional | Extended description or IRS instructions | |
|||
| `isCustom` | `Boolean` | Default: false | Whether this is a user-added custom cell | |
|||
| `sortOrder` | `Int` | Required | Display order in the verification screen | |
|||
| `createdAt` | `DateTime` | Default: now() | Creation timestamp | |
|||
| `updatedAt` | `DateTime` | Auto-updated | Last modification timestamp | |
|||
|
|||
**Relations**: |
|||
|
|||
- `partnership` → `Partnership?` (many-to-one, optional, cascade delete) |
|||
|
|||
**Unique constraint**: `@@unique([partnershipId, boxNumber])` — one mapping per box per partnership (or per box globally when partnershipId is null). |
|||
|
|||
## Modifications to Existing Models |
|||
|
|||
### Partnership (from spec 001) |
|||
|
|||
Add back-references — no column changes: |
|||
|
|||
| New Field | Type | Description | |
|||
| ----------------- | -------------------- | ------------------------------------ | |
|||
| `importSessions` | `K1ImportSession[]` | Import attempts for this partnership | |
|||
| `cellMappings` | `CellMapping[]` | Custom cell mapping configurations | |
|||
|
|||
### KDocument (from spec 001) |
|||
|
|||
Add back-reference — no column changes: |
|||
|
|||
| New Field | Type | Description | |
|||
| ---------------- | ------------------- | ---------------------------------------- | |
|||
| `importSession` | `K1ImportSession?` | Import session that created this record | |
|||
|
|||
## Application-Layer Types |
|||
|
|||
### K1ExtractionResult (TypeScript interface) |
|||
|
|||
The structure returned by the extraction service and stored in `K1ImportSession.rawExtraction` and `K1ImportSession.verifiedData`. |
|||
|
|||
```typescript |
|||
interface K1ExtractionResult { |
|||
/** Extracted metadata from the K-1 header */ |
|||
metadata: { |
|||
partnershipName: string | null; |
|||
partnershipEin: string | null; |
|||
partnerName: string | null; |
|||
partnerEin: string | null; |
|||
taxYear: number | null; |
|||
isAmended: boolean; |
|||
isFinal: boolean; |
|||
}; |
|||
|
|||
/** Extracted box values */ |
|||
fields: K1ExtractedField[]; |
|||
|
|||
/** Overall extraction confidence (0.0–1.0) */ |
|||
overallConfidence: number; |
|||
|
|||
/** Extraction method used */ |
|||
method: 'pdf-parse' | 'azure' | 'tesseract'; |
|||
|
|||
/** Number of pages processed */ |
|||
pagesProcessed: number; |
|||
} |
|||
|
|||
interface K1ExtractedField { |
|||
/** Box identifier (e.g., "1", "6a", "19a") */ |
|||
boxNumber: string; |
|||
|
|||
/** Display label from cell mapping */ |
|||
label: string; |
|||
|
|||
/** Custom label override by user (null if not overridden) */ |
|||
customLabel: string | null; |
|||
|
|||
/** Extracted raw text value */ |
|||
rawValue: string; |
|||
|
|||
/** Parsed numeric value (null if unparseable) */ |
|||
numericValue: number | null; |
|||
|
|||
/** Confidence score (0.0–1.0) */ |
|||
confidence: number; |
|||
|
|||
/** Confidence level for display */ |
|||
confidenceLevel: 'HIGH' | 'MEDIUM' | 'LOW'; |
|||
|
|||
/** Whether user has manually edited this value */ |
|||
isUserEdited: boolean; |
|||
} |
|||
``` |
|||
|
|||
### K1ConfirmationRequest (TypeScript interface) |
|||
|
|||
The request body when the user confirms verified K-1 data. |
|||
|
|||
```typescript |
|||
interface K1ConfirmationRequest { |
|||
/** Import session ID */ |
|||
importSessionId: string; |
|||
|
|||
/** Tax year (may have been overridden by user) */ |
|||
taxYear: number; |
|||
|
|||
/** Filing status for the new KDocument */ |
|||
filingStatus: 'DRAFT' | 'ESTIMATED' | 'FINAL'; |
|||
|
|||
/** Verified fields with any user edits applied */ |
|||
fields: K1ExtractedField[]; |
|||
|
|||
/** Whether to update an existing KDocument (null = create new) */ |
|||
existingKDocumentAction: 'UPDATE' | 'CREATE_NEW' | null; |
|||
} |
|||
``` |
|||
|
|||
### Default IRS K-1 Cell Mapping |
|||
|
|||
The standard box definitions seeded as global CellMapping records (partnershipId = null): |
|||
|
|||
| boxNumber | label | sortOrder | |
|||
| --------- | ----------------------------------------- | --------- | |
|||
| 1 | Ordinary business income (loss) | 1 | |
|||
| 2 | Net rental real estate income (loss) | 2 | |
|||
| 3 | Other net rental income (loss) | 3 | |
|||
| 4 | Guaranteed payments for services | 4 | |
|||
| 4a | Guaranteed payments for capital | 5 | |
|||
| 4b | Total guaranteed payments | 6 | |
|||
| 5 | Interest income | 7 | |
|||
| 6a | Ordinary dividends | 8 | |
|||
| 6b | Qualified dividends | 9 | |
|||
| 6c | Dividend equivalents | 10 | |
|||
| 7 | Royalties | 11 | |
|||
| 8 | Net short-term capital gain (loss) | 12 | |
|||
| 9a | Net long-term capital gain (loss) | 13 | |
|||
| 9b | Collectibles (28%) gain (loss) | 14 | |
|||
| 9c | Unrecaptured section 1250 gain | 15 | |
|||
| 10 | Net section 1231 gain (loss) | 16 | |
|||
| 11 | Other income (loss) | 17 | |
|||
| 12 | Section 179 deduction | 18 | |
|||
| 13 | Other deductions | 19 | |
|||
| 14 | Self-employment earnings (loss) | 20 | |
|||
| 15 | Credits | 21 | |
|||
| 16 | Foreign transactions | 22 | |
|||
| 17 | Alternative minimum tax (AMT) items | 23 | |
|||
| 18 | Tax-exempt income and nondeductible expenses | 24 | |
|||
| 19a | Distributions — Cash and marketable securities | 25 | |
|||
| 19b | Distributions — Other property | 26 | |
|||
| 20 | Other information | 27 | |
|||
| 21 | Foreign taxes paid or accrued | 28 | |
|||
|
|||
## Validation Rules |
|||
|
|||
1. **Import session partnership**: Must reference an existing partnership owned by the current user. |
|||
2. **Import session tax year**: Must be ≥ year of the partnership's inception date. |
|||
3. **File upload**: Must be a valid PDF, ≤ 25 MB. System rejects non-PDF MIME types. |
|||
4. **Extraction status transitions**: Only valid transitions: PROCESSING → EXTRACTED → VERIFIED → CONFIRMED/CANCELLED, or PROCESSING → FAILED. No backwards transitions. |
|||
5. **Cell mapping uniqueness**: One mapping per (partnershipId, boxNumber). Custom mappings for a partnership override the global default for that box number. |
|||
6. **Confirmation prerequisites**: Can only confirm when status is VERIFIED, partnership has at least one active member, and verifiedData is not null. |
|||
7. **Duplicate KDocument check**: Before creating a KDocument, check for existing (partnershipId, type=K1, taxYear). If found, require explicit user decision (update existing or reject). |
|||
8. **Distribution allocation**: Box 19a/19b amounts are allocated to members by ownership percentage as of the tax year's fiscal year end. Allocation amounts must sum exactly to the partnership-level total (handle rounding by adjusting the largest member's allocation). |
|||
@ -0,0 +1,121 @@ |
|||
# Implementation Plan: K-1 PDF Scan Import |
|||
|
|||
**Branch**: `004-k1-scan-import` | **Date**: 2026-03-18 | **Spec**: [spec.md](spec.md) |
|||
**Input**: Feature specification from `/specs/004-k1-scan-import/spec.md` |
|||
|
|||
## Summary |
|||
|
|||
Automated K-1 PDF scanning that extracts structured IRS Schedule K-1 (Form 1065) data from uploaded PDFs, presents a verification screen for manual review/correction, and auto-creates downstream model objects (KDocument, Distributions, member allocations, Document). Uses a two-tier extraction approach: `pdf-parse` for digital PDFs (free, instant, local) and Azure AI Document Intelligence / `tesseract.js` fallback for scanned PDFs. Supports per-partnership cell mapping customization and import history with re-processing. |
|||
|
|||
## Technical Context |
|||
|
|||
**Language/Version**: TypeScript 5.9.2, Node.js ≥ 22.18.0 |
|||
**Primary Dependencies**: NestJS 11.x (backend), Angular 21.x (frontend), Prisma 6.x (ORM), pdf-parse (PDF text), @azure/ai-form-recognizer (cloud OCR), tesseract.js (local OCR fallback) |
|||
**Storage**: PostgreSQL via Prisma (structured data), local filesystem `uploads/` (PDF files) |
|||
**Testing**: Jest (unit + integration), test K-1 PDF fixtures in `test/import/` |
|||
**Target Platform**: Docker (node:22-slim), self-hosted or Railway |
|||
**Project Type**: Web application (NestJS API + Angular SPA) — Nx monorepo |
|||
**Performance Goals**: PDF extraction < 30 seconds (SC-001), model creation < 5 seconds (SC-005), 90%+ accuracy for digital PDFs (SC-002) |
|||
**Constraints**: Self-hosted capable (Azure OCR optional), max PDF size 25 MB, K-1 Form 1065 only (V1) |
|||
**Scale/Scope**: Single family office (10–50 partnerships, 10–50 K-1s/year), 2 new API modules, 3 new frontend pages |
|||
|
|||
## Constitution Check |
|||
|
|||
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ |
|||
|
|||
No constitution.md exists for this project. Gates assessed against standard engineering principles: |
|||
|
|||
| Gate | Status | Notes | |
|||
|------|--------|-------| |
|||
| No unnecessary dependencies | PASS | 3 new packages (`pdf-parse`, `@azure/ai-form-recognizer`, `tesseract.js`) — each serves a distinct, justified purpose per research.md | |
|||
| Follows existing patterns | PASS | New NestJS modules follow existing controller/service/DTO pattern (mirrors `k-document`, `upload` modules) | |
|||
| No breaking changes | PASS | 2 new Prisma models + 1 enum, back-references only on existing models — no column changes | |
|||
| Test coverage | PASS | Unit tests for extractors, mapper, allocation; integration tests for full pipeline | |
|||
| Self-hosted compatible | PASS | Core extraction (pdf-parse) is fully local; Azure is optional with tesseract.js fallback | |
|||
|
|||
**Post-Phase 1 re-check**: PASS — data model adds 2 models/1 enum, no existing schema changes beyond back-references. API contracts follow existing REST patterns. No violations identified. |
|||
|
|||
## Project Structure |
|||
|
|||
### Documentation (this feature) |
|||
|
|||
```text |
|||
specs/004-k1-scan-import/ |
|||
├── plan.md # This file |
|||
├── research.md # Phase 0: OCR provider research & decisions |
|||
├── data-model.md # Phase 1: K1ImportSession, CellMapping models |
|||
├── quickstart.md # Phase 1: Setup & dev guide |
|||
├── contracts/ |
|||
│ └── k1-import-api.md # Phase 1: REST API contracts |
|||
├── checklists/ |
|||
│ └── requirements.md # Spec quality checklist |
|||
└── tasks.md # Phase 2 output (created by /speckit.tasks) |
|||
``` |
|||
|
|||
### Source Code (repository root) |
|||
|
|||
```text |
|||
apps/api/src/app/ |
|||
├── k1-import/ |
|||
│ ├── k1-import.module.ts |
|||
│ ├── k1-import.controller.ts |
|||
│ ├── k1-import.service.ts |
|||
│ ├── dto/ |
|||
│ │ ├── upload-k1.dto.ts |
|||
│ │ ├── verify-k1.dto.ts |
|||
│ │ └── confirm-k1.dto.ts |
|||
│ ├── extractors/ |
|||
│ │ ├── k1-extractor.interface.ts |
|||
│ │ ├── pdf-parse-extractor.ts |
|||
│ │ ├── azure-extractor.ts |
|||
│ │ └── tesseract-extractor.ts |
|||
│ ├── k1-field-mapper.service.ts |
|||
│ ├── k1-allocation.service.ts |
|||
│ └── k1-confidence.service.ts |
|||
├── cell-mapping/ |
|||
│ ├── cell-mapping.module.ts |
|||
│ ├── cell-mapping.controller.ts |
|||
│ └── cell-mapping.service.ts |
|||
|
|||
apps/client/src/app/ |
|||
├── pages/ |
|||
│ ├── k1-import/ |
|||
│ │ ├── k1-import-page.component.ts |
|||
│ │ ├── k1-import-page.html |
|||
│ │ ├── k1-import-page.scss |
|||
│ │ ├── k1-import-page.routes.ts |
|||
│ │ ├── k1-verification/ |
|||
│ │ │ ├── k1-verification.component.ts |
|||
│ │ │ ├── k1-verification.html |
|||
│ │ │ └── k1-verification.scss |
|||
│ │ └── k1-confirmation/ |
|||
│ │ ├── k1-confirmation.component.ts |
|||
│ │ ├── k1-confirmation.html |
|||
│ │ └── k1-confirmation.scss |
|||
│ └── cell-mapping/ |
|||
│ ├── cell-mapping-page.component.ts |
|||
│ ├── cell-mapping-page.html |
|||
│ └── cell-mapping-page.routes.ts |
|||
├── services/ |
|||
│ └── k1-import-data.service.ts |
|||
|
|||
libs/common/src/lib/ |
|||
├── interfaces/ |
|||
│ └── k1-import.interface.ts |
|||
├── dtos/ |
|||
│ └── k1-import/ |
|||
│ ├── create-k1-import.dto.ts |
|||
│ ├── verify-k1-import.dto.ts |
|||
│ └── confirm-k1-import.dto.ts |
|||
|
|||
prisma/ |
|||
├── schema.prisma # + K1ImportSession, CellMapping, K1ImportStatus |
|||
├── migrations/ |
|||
│ └── 2026XXXX_added_k1_import/ # New migration |
|||
|
|||
test/import/ |
|||
├── sample-k1-digital.pdf # Test fixture: digital K-1 |
|||
└── sample-k1-scanned.pdf # Test fixture: scanned K-1 |
|||
``` |
|||
|
|||
**Structure Decision**: Follows the existing Nx monorepo convention with new NestJS modules under `apps/api/src/app/` and new Angular pages under `apps/client/src/app/pages/`. Shared interfaces and DTOs in `libs/common/`. This mirrors the existing `k-document`, `upload`, and `family-office` module patterns. |
|||
@ -0,0 +1,120 @@ |
|||
# Quickstart: K-1 PDF Scan Import |
|||
|
|||
**Phase 1 Output** | **Date**: 2026-03-18 |
|||
|
|||
## Prerequisites |
|||
|
|||
1. Spec 001-family-office-transform models are implemented (Entity, Partnership, PartnershipMembership, KDocument, Distribution, Document) |
|||
2. At least one Partnership with one or more member Entities exists in the database |
|||
3. The existing upload infrastructure (`UploadController`, `uploads/` directory) is functional |
|||
4. Node.js ≥ 22.18.0, Docker for PostgreSQL/Redis |
|||
|
|||
## Environment Setup |
|||
|
|||
Add to `.env` (optional — for Azure OCR of scanned PDFs): |
|||
``` |
|||
AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT=https://your-resource.cognitiveservices.azure.com/ |
|||
AZURE_DOCUMENT_INTELLIGENCE_KEY=your-api-key |
|||
``` |
|||
|
|||
If these are empty, scanned PDFs fall back to `tesseract.js` (lower accuracy but fully self-hosted). |
|||
|
|||
## New Dependencies |
|||
|
|||
```bash |
|||
npm install pdf-parse @azure/ai-form-recognizer tesseract.js |
|||
npm install -D @types/pdf-parse |
|||
``` |
|||
|
|||
## Database Migration |
|||
|
|||
After adding the new Prisma models (`K1ImportSession`, `CellMapping`, `K1ImportStatus` enum): |
|||
|
|||
```bash |
|||
npx prisma db push # Development: sync schema |
|||
# OR |
|||
npx prisma migrate dev # Create a migration file |
|||
``` |
|||
|
|||
Seed the default IRS cell mappings (28 rows with partnershipId = null) via the existing seed mechanism or a dedicated seed script. |
|||
|
|||
## Key Files to Create |
|||
|
|||
### Backend (apps/api/src/) |
|||
|
|||
``` |
|||
app/k1-import/ |
|||
├── k1-import.module.ts # NestJS module |
|||
├── k1-import.controller.ts # REST endpoints (see contracts/k1-import-api.md) |
|||
├── k1-import.service.ts # Orchestration: upload → extract → verify → confirm |
|||
├── dto/ |
|||
│ ├── upload-k1.dto.ts # Multipart upload DTO |
|||
│ ├── verify-k1.dto.ts # Verification submission DTO |
|||
│ └── confirm-k1.dto.ts # Confirmation request DTO |
|||
├── extractors/ |
|||
│ ├── k1-extractor.interface.ts # Common extraction interface |
|||
│ ├── pdf-parse-extractor.ts # Tier 1: digital PDF text extraction |
|||
│ ├── azure-extractor.ts # Tier 2: Azure Document Intelligence |
|||
│ └── tesseract-extractor.ts # Tier 2 fallback: tesseract.js OCR |
|||
├── k1-field-mapper.service.ts # Maps raw extraction → K1ExtractedField[] |
|||
├── k1-allocation.service.ts # Allocates K-1 amounts to members by ownership % |
|||
└── k1-confidence.service.ts # Computes confidence scores with validation heuristics |
|||
|
|||
app/cell-mapping/ |
|||
├── cell-mapping.module.ts # NestJS module |
|||
├── cell-mapping.controller.ts # CRUD for cell mappings |
|||
└── cell-mapping.service.ts # Cell mapping business logic + seed data |
|||
``` |
|||
|
|||
### Shared Types (libs/common/src/lib/) |
|||
|
|||
``` |
|||
interfaces/ |
|||
├── k1-import.interface.ts # K1ExtractionResult, K1ExtractedField, K1ConfirmationRequest |
|||
dtos/ |
|||
├── k1-import/ |
|||
│ ├── create-k1-import.dto.ts |
|||
│ ├── verify-k1-import.dto.ts |
|||
│ └── confirm-k1-import.dto.ts |
|||
``` |
|||
|
|||
### Frontend (apps/client/src/app/) |
|||
|
|||
``` |
|||
pages/k1-import/ |
|||
├── k1-import-page.component.ts # Upload + history view |
|||
├── k1-import-page.html |
|||
├── k1-import-page.scss |
|||
├── k1-import-page.routes.ts |
|||
├── k1-verification/ |
|||
│ ├── k1-verification.component.ts # Verification/edit screen |
|||
│ ├── k1-verification.html |
|||
│ └── k1-verification.scss |
|||
└── k1-confirmation/ |
|||
├── k1-confirmation.component.ts # Confirmation result screen |
|||
├── k1-confirmation.html |
|||
└── k1-confirmation.scss |
|||
|
|||
pages/cell-mapping/ |
|||
├── cell-mapping-page.component.ts # Cell mapping configuration UI |
|||
├── cell-mapping-page.html |
|||
└── cell-mapping-page.routes.ts |
|||
|
|||
services/ |
|||
├── k1-import-data.service.ts # HTTP client for k1-import endpoints |
|||
``` |
|||
|
|||
## Verification Workflow |
|||
|
|||
1. **Upload**: User selects PDF → `POST /api/v1/k1-import/upload` → session created with status PROCESSING |
|||
2. **Extract**: Backend detects PDF type (digital vs. scanned) → routes to appropriate extractor → status becomes EXTRACTED |
|||
3. **Review**: Frontend polls/fetches session → displays verification screen with extracted fields, confidence indicators |
|||
4. **Edit**: User corrects values, overrides labels → `PUT /api/v1/k1-import/:id/verify` → status becomes VERIFIED |
|||
5. **Confirm**: User clicks "Confirm & Save" → `POST /api/v1/k1-import/:id/confirm` → KDocument + Distributions + Document created → status becomes CONFIRMED |
|||
|
|||
## Testing Strategy |
|||
|
|||
- **Unit tests**: Extractors (pdf-parse, azure, tesseract), field mapper, confidence scoring, allocation math |
|||
- **Integration tests**: Full upload → extract → verify → confirm flow with test PDF fixtures |
|||
- **Test fixtures**: Include sample K-1 PDFs (digital and scanned) in `test/import/` directory |
|||
- **Allocation accuracy**: Verify rounding behavior — allocated amounts must sum exactly to partnership total |
|||
@ -0,0 +1,154 @@ |
|||
# Research: K-1 PDF Scan Import |
|||
|
|||
**Phase 0 Output** | **Date**: 2026-03-18 |
|||
|
|||
## Decision 1: PDF Text Extraction (Tier 1 — Digital PDFs) |
|||
|
|||
**Decision**: Use `pdf-parse` npm package for digitally-generated K-1 PDFs. |
|||
|
|||
**Rationale**: Digitally-generated PDFs from fund administrators contain embedded text. `pdf-parse` extracts this text losslessly, is free, fully self-hosted, and instant. It has 3M+ weekly npm downloads and a stable API. No external API calls needed. |
|||
|
|||
**Alternatives Considered**: |
|||
- `pdfjs-dist` (Mozilla pdf.js) — lower-level, requires more boilerplate for text extraction; `pdf-parse` wraps this already. |
|||
- Cloud OCR for all PDFs — unnecessary cost and latency for digital PDFs where text extraction is 100% accurate. |
|||
|
|||
--- |
|||
|
|||
## Decision 2: OCR for Scanned PDFs (Tier 2) |
|||
|
|||
**Decision**: Use Azure AI Document Intelligence (Layout model) as primary Tier 2 provider, with `tesseract.js` as self-hosted fallback. |
|||
|
|||
**Rationale**: |
|||
- Azure has the best tax-form pedigree among cloud providers (prebuilt IRS models for W-2, 1098, 1099) |
|||
- Returns per-field confidence scores (0.0–1.0) natively, directly fulfilling FR-006/FR-009 |
|||
- 500 free pages/month covers typical family office volume (10–50 K-1s/year) |
|||
- `@azure/ai-form-recognizer` has full TypeScript types, aligns with NestJS patterns |
|||
- `tesseract.js` runs as WASM in Node.js (no system install), provides ~75% accuracy fallback |
|||
|
|||
**Alternatives Considered**: |
|||
- Google Document AI — good form parsing but no tax-specific models, more expensive for custom processors ($30/1K pages) |
|||
- AWS Textract — strong table extraction but less established for tax forms, requires IAM setup |
|||
- Tesseract.js only — accuracy drops to 70–85% for clean scans, no layout understanding; acceptable as fallback but not primary |
|||
|
|||
--- |
|||
|
|||
## Decision 3: Two-Tier Extraction Architecture |
|||
|
|||
**Decision**: Implement a PDF type detection step that routes digital PDFs to local extraction (free, instant) and scanned PDFs to cloud OCR. |
|||
|
|||
**Rationale**: Most K-1s from fund administrators are digitally generated. The two-tier approach avoids unnecessary API calls and costs for the majority case, while still supporting scanned documents. |
|||
|
|||
**Detection heuristic**: Extract text via `pdf-parse`; if extracted text length < 100 characters or does not contain K-1 keywords ("Schedule K-1", "Form 1065", "Partner's Share"), route to Tier 2 OCR. |
|||
|
|||
**Alternatives Considered**: |
|||
- Cloud OCR for everything — simpler but adds cost ($0.15/page) and latency (3–10s) for digital PDFs that don't need it |
|||
- Local OCR only (Tesseract.js) — insufficient accuracy (75%) for production tax data; too many manual corrections needed |
|||
|
|||
--- |
|||
|
|||
## Decision 4: K-1 Box Extraction Strategy |
|||
|
|||
**Decision**: Use regex-based box extraction for Tier 1 (digital text), and key-value pair extraction from the OCR provider for Tier 2. Both feed into a shared K-1 field mapper that applies the cell mapping configuration. |
|||
|
|||
**Rationale**: The IRS Schedule K-1 (Form 1065) has a consistent, standardized layout: |
|||
- Page 1: Header + Part I (partnership info) + Part II (partner info) + Boxes 1–11 |
|||
- Page 2: Boxes 12–20+ with code/sub-code details |
|||
- Box values sit in a numbered two-column grid: number label → description → value field |
|||
- Layout has been structurally stable for years, making template/regex extraction reliable |
|||
|
|||
**Challenges addressed**: |
|||
- Multi-line sub-codes (Boxes 11, 13, 15, 16, 17, 18, 20) — handle by extracting code-letter/value pairs within each box section |
|||
- Supplemental schedules — out of scope for V1 auto-extraction; captured as additional Document attachments |
|||
- Multi-entity PDFs — detect via repeated "Schedule K-1" headers; split and process each K-1 separately |
|||
|
|||
**Alternatives Considered**: |
|||
- Fixed coordinate-based extraction — too brittle across different PDF generators (varying margins, fonts) |
|||
- Machine learning model — overkill for V1 given the standardized form layout |
|||
|
|||
--- |
|||
|
|||
## Decision 5: Confidence Scoring Approach |
|||
|
|||
**Decision**: Three-level confidence display (High/Medium/Low) derived from extraction method and validation heuristics. |
|||
|
|||
**Rationale**: |
|||
|
|||
For **Tier 1** (digital text): |
|||
- Base confidence: 0.90 (text extraction is inherently reliable) |
|||
- +0.05 if box number regex matched cleanly |
|||
- +0.05 if value format validated (currency, percentage, integer) |
|||
- -0.10 to -0.30 for potential adjacent-box text contamination |
|||
|
|||
For **Tier 2** (cloud OCR): |
|||
- Use Azure's native per-field confidence score directly |
|||
- Layer cross-field validation (e.g., Box 6b ≤ Box 6a, sub-boxes sum to parent) |
|||
|
|||
**Display mapping**: |
|||
- High (≥ 0.85): Green — no user attention needed |
|||
- Medium (0.60–0.84): Yellow — optional review |
|||
- Low (< 0.60): Red — highlighted, requires manual review (FR-009) |
|||
|
|||
**Alternatives Considered**: |
|||
- Binary confidence (confident/not) — too coarse; doesn't guide the user's review attention |
|||
- Numeric score display — too technical for a non-engineer user; three levels with color coding is more actionable |
|||
|
|||
--- |
|||
|
|||
## Decision 6: New Database Models |
|||
|
|||
**Decision**: Add two new Prisma models (`K1ImportSession`, `CellMapping`) to support import tracking and cell mapping configuration, alongside the existing K-document models from spec 001. |
|||
|
|||
**Rationale**: |
|||
- `K1ImportSession` tracks the full import lifecycle (upload → processing → extracted → verified → confirmed/cancelled), enabling import history (FR-022) and re-processing (FR-023) |
|||
- `CellMapping` stores per-partnership cell label customizations (FR-017 through FR-021) separate from the KDocument data itself |
|||
|
|||
**Alternatives Considered**: |
|||
- Store import sessions as JSON metadata on KDocument — would conflate document data with import workflow state; makes import history harder to query |
|||
- Store cell mappings as JSON on Partnership — would work but loses the ability to query/manage mappings independently and doesn't support a global default set |
|||
|
|||
--- |
|||
|
|||
## Decision 7: File Storage |
|||
|
|||
**Decision**: Use the existing `uploads/` directory and `Document` model from spec 001. Uploaded K-1 PDFs are stored on the local filesystem, with metadata in the `Document` table. |
|||
|
|||
**Rationale**: The existing upload infrastructure (UploadController with `FileInterceptor`, Document model, `uploads/` directory) is already in place. No need to add a new storage mechanism. |
|||
|
|||
**Alternatives Considered**: |
|||
- S3/cloud storage — would require new infrastructure; the self-hosted philosophy favors local storage |
|||
- Database blob storage — increases database size and backup time for binary files |
|||
|
|||
--- |
|||
|
|||
## Decision 8: New Environment Variables |
|||
|
|||
**Decision**: Add two optional environment variables for Azure Document Intelligence, following the existing `ConfigurationService` pattern with `str({ default: '' })`. |
|||
|
|||
``` |
|||
AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT — Azure resource endpoint URL |
|||
AZURE_DOCUMENT_INTELLIGENCE_KEY — Azure API key |
|||
``` |
|||
|
|||
**Rationale**: When both are empty (default), the system falls back to `tesseract.js` for scanned PDFs. This makes Azure optional — the feature works fully self-hosted with degraded OCR accuracy. |
|||
|
|||
**Alternatives Considered**: |
|||
- Separate feature flag — unnecessary; empty credentials are sufficient to indicate "not configured" |
|||
- Google/AWS credentials — Azure recommended as primary; could add additional providers later |
|||
|
|||
--- |
|||
|
|||
## Decision 9: New npm Dependencies |
|||
|
|||
**Decision**: Add the following packages: |
|||
|
|||
| Package | Purpose | Tier | |
|||
|---|---|---| |
|||
| `pdf-parse` | Text extraction from digital PDFs | Tier 1 (required) | |
|||
| `@azure/ai-form-recognizer` | Cloud OCR for scanned PDFs | Tier 2 (optional) | |
|||
| `tesseract.js` | Self-hosted OCR fallback | Tier 2 fallback | |
|||
|
|||
**Rationale**: `pdf-parse` is essential for the Tier 1 (free, local) path. Azure SDK is optional (only loaded when credentials are configured). `tesseract.js` provides a zero-config fallback that runs as WASM — no system dependencies needed, works in the existing `node:22-slim` Docker image. |
|||
|
|||
**Alternatives Considered**: |
|||
- `pdfjs-dist` directly instead of `pdf-parse` — more boilerplate, `pdf-parse` wraps it with a simpler API |
|||
- Only cloud OCR — loses the self-hosted story and adds cost for digital PDFs |
|||
Loading…
Reference in new issue