Browse Source

feat(k1-import): Phase 5 US3 - confirm flow, allocation service, KDocument/Distribution creation, confirmation component

pull/6701/head
Robert Patch 2 months ago
parent
commit
904fe4b820
  1. 15
      apps/api/src/app/k1-import/dto/confirm-k1.dto.ts
  2. 90
      apps/api/src/app/k1-import/k1-allocation.service.ts
  3. 16
      apps/api/src/app/k1-import/k1-import.controller.ts
  4. 198
      apps/api/src/app/k1-import/k1-import.service.ts
  5. 205
      apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.component.ts
  6. 167
      apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.html
  7. 37
      apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.scss
  8. 9
      apps/client/src/app/pages/k1-import/k1-import-page.routes.ts
  9. 12
      specs/004-k1-scan-import/tasks.md

15
apps/api/src/app/k1-import/dto/confirm-k1.dto.ts

@ -0,0 +1,15 @@
import { KDocumentStatus } from '@prisma/client';
import { IsEnum, IsOptional, IsString } from 'class-validator';
/**
* DTO for confirming a verified K-1 import session.
* Triggers auto-creation of KDocument, Distributions, and Document linkage.
*/
export class ConfirmK1Dto {
@IsEnum(KDocumentStatus)
filingStatus: KDocumentStatus;
@IsOptional()
@IsString()
existingKDocumentAction?: 'UPDATE' | 'CREATE_NEW';
}

90
apps/api/src/app/k1-import/k1-allocation.service.ts

@ -1,8 +1,92 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable, Logger } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
interface MemberAllocation {
entityId: string;
entityName: string;
ownershipPercent: number;
allocatedValues: Record<string, number>;
}
/**
* Service for allocating K-1 line items to partnership members
* by ownership percentage. Implemented in Phase 5 (US3).
* by ownership percentage. FR-013.
* Rounding adjustment: residual cents assigned to the largest member (validation rule 8).
*/
@Injectable()
export class K1AllocationService {}
export class K1AllocationService {
private readonly logger = new Logger(K1AllocationService.name);
public constructor(private readonly prismaService: PrismaService) {}
/**
* Allocate K-1 box values to partnership members by ownership %.
* Returns allocations per member with proportional values.
*/
public async allocateToMembers(
partnershipId: string,
taxYear: number,
fields: Array<{ boxNumber: string; numericValue: number | null }>
): Promise<MemberAllocation[]> {
// Get active members as of tax year end
const taxYearEnd = new Date(taxYear, 11, 31); // Dec 31 of tax year
const memberships = await this.prismaService.partnershipMembership.findMany(
{
where: {
partnershipId,
effectiveDate: { lte: taxYearEnd },
OR: [{ endDate: null }, { endDate: { gte: taxYearEnd } }]
},
include: {
entity: true
},
orderBy: {
ownershipPercent: 'desc' // Largest member first for rounding
}
}
);
if (memberships.length === 0) {
return [];
}
const allocations: MemberAllocation[] = memberships.map((m) => ({
entityId: m.entityId,
entityName: m.entity.name || m.entityId,
ownershipPercent: new Decimal(m.ownershipPercent).toNumber(),
allocatedValues: {}
}));
// For each field with a numeric value, allocate proportionally
for (const field of fields) {
if (field.numericValue === null || field.numericValue === undefined) {
continue;
}
const totalCents = Math.round(field.numericValue * 100);
let allocatedCents = 0;
// Allocate to each member except the largest (first)
for (let i = 1; i < allocations.length; i++) {
const memberCents = Math.round(
(totalCents * allocations[i].ownershipPercent) / 100
);
allocations[i].allocatedValues[field.boxNumber] = memberCents / 100;
allocatedCents += memberCents;
}
// Largest member gets the remainder (rounding adjustment - validation rule 8)
allocations[0].allocatedValues[field.boxNumber] =
(totalCents - allocatedCents) / 100;
}
this.logger.log(
`Allocated ${fields.length} fields to ${memberships.length} members for partnership ${partnershipId}`
);
return allocations;
}
}

16
apps/api/src/app/k1-import/k1-import.controller.ts

@ -21,6 +21,7 @@ import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { StatusCodes } from 'http-status-codes';
import { ConfirmK1Dto } from './dto/confirm-k1.dto';
import { VerifyK1Dto } from './dto/verify-k1.dto';
import { K1ImportService } from './k1-import.service';
@ -88,4 +89,19 @@ export class K1ImportController {
public async cancelImportSession(@Param('id') id: string) {
return this.k1ImportService.cancel(id, this.request.user.id);
}
/**
* POST /api/v1/k1-import/:id/confirm
* Confirm verified data and trigger auto-creation of model objects.
*/
@HasPermission(permissions.createKDocument)
@Post(':id/confirm')
@HttpCode(StatusCodes.CREATED)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async confirmImportSession(
@Param('id') id: string,
@Body() data: ConfirmK1Dto
) {
return this.k1ImportService.confirm(id, this.request.user.id, data);
}
}

198
apps/api/src/app/k1-import/k1-import.service.ts

@ -2,7 +2,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import type { K1ExtractionResult } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable, Logger } from '@nestjs/common';
import { K1ImportStatus } from '@prisma/client';
import { K1ImportStatus, KDocumentStatus } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
@ -389,6 +389,202 @@ export class K1ImportService {
return updated;
}
/**
* Confirm verified data and auto-create model objects.
* VERIFIED CONFIRMED transition.
* FR-012 (KDocument), FR-013 (allocations), FR-014 (Distributions), FR-015 (Document linkage), FR-016 (duplicate detection).
*/
public async confirm(
sessionId: string,
userId: string,
data: {
filingStatus: KDocumentStatus;
existingKDocumentAction?: 'UPDATE' | 'CREATE_NEW';
}
) {
const session = await this.getSession(sessionId, userId);
// Only VERIFIED sessions can be confirmed
if (session.status !== K1ImportStatus.VERIFIED) {
throw new HttpException(
'Session must be in VERIFIED status to confirm',
StatusCodes.BAD_REQUEST
);
}
const verifiedData = session.verifiedData as any;
if (!verifiedData?.fields || verifiedData.fields.length === 0) {
throw new HttpException(
'No verified data available',
StatusCodes.BAD_REQUEST
);
}
// Check for active members (FR-013)
const memberships =
await this.prismaService.partnershipMembership.findMany({
where: {
partnershipId: session.partnershipId,
effectiveDate: {
lte: new Date(session.taxYear, 11, 31)
},
OR: [
{ endDate: null },
{ endDate: { gte: new Date(session.taxYear, 11, 31) } }
]
},
include: { entity: true }
});
if (memberships.length === 0) {
throw new HttpException(
'Partnership has no active members',
StatusCodes.BAD_REQUEST
);
}
// FR-016: Check for existing KDocument (duplicate detection)
const existingKDocument = await this.prismaService.kDocument.findUnique({
where: {
partnershipId_type_taxYear: {
partnershipId: session.partnershipId,
type: 'K1',
taxYear: session.taxYear
}
}
});
if (existingKDocument && !data.existingKDocumentAction) {
throw new HttpException(
'A KDocument already exists for this partnership, type, and tax year. Specify existingKDocumentAction (UPDATE or CREATE_NEW).',
StatusCodes.CONFLICT
);
}
// Build KDocument data from verified fields
const kDocumentData: Record<string, number | null> = {};
for (const field of verifiedData.fields) {
kDocumentData[field.boxNumber] = field.numericValue ?? null;
}
// FR-012: Create or update KDocument
let kDocument;
if (existingKDocument && data.existingKDocumentAction === 'UPDATE') {
kDocument = await this.prismaService.kDocument.update({
where: { id: existingKDocument.id },
data: {
filingStatus: data.filingStatus,
data: kDocumentData as any,
documentFileId: session.documentId
}
});
} else {
// CREATE_NEW or no existing document
if (existingKDocument && data.existingKDocumentAction === 'CREATE_NEW') {
// Delete existing unique constraint holder to create new
await this.prismaService.kDocument.delete({
where: { id: existingKDocument.id }
});
}
kDocument = await this.prismaService.kDocument.create({
data: {
partnershipId: session.partnershipId,
type: 'K1',
taxYear: session.taxYear,
filingStatus: data.filingStatus,
data: kDocumentData as any,
documentFileId: session.documentId
}
});
}
// FR-013: Allocate K-1 amounts to members
const allocations = await this.allocationService.allocateToMembers(
session.partnershipId,
session.taxYear,
verifiedData.fields
);
// FR-014: Create Distribution records for Box 19a and Box 19b
const distributions: any[] = [];
const distributionDate = new Date(session.taxYear, 11, 31); // Dec 31
for (const allocation of allocations) {
// Box 19a: Cash and marketable securities
const box19a = allocation.allocatedValues['19a'];
if (box19a && box19a !== 0) {
const dist = await this.prismaService.distribution.create({
data: {
partnershipId: session.partnershipId,
entityId: allocation.entityId,
type: 'RETURN_OF_CAPITAL',
amount: box19a,
date: distributionDate,
currency: 'USD',
notes: `K-1 Box 19a (Cash distributions) - Tax Year ${session.taxYear}`
}
});
distributions.push(dist);
}
// Box 19b: Other property distributions
const box19b = allocation.allocatedValues['19b'];
if (box19b && box19b !== 0) {
const dist = await this.prismaService.distribution.create({
data: {
partnershipId: session.partnershipId,
entityId: allocation.entityId,
type: 'RETURN_OF_CAPITAL',
amount: box19b,
date: distributionDate,
currency: 'USD',
notes: `K-1 Box 19b (Property distributions) - Tax Year ${session.taxYear}`
}
});
distributions.push(dist);
}
}
// Update session to CONFIRMED and link KDocument
await this.prismaService.k1ImportSession.update({
where: { id: sessionId },
data: {
status: K1ImportStatus.CONFIRMED,
kDocumentId: kDocument.id
}
});
this.logger.log(
`Session ${sessionId}: Confirmed. KDocument ${kDocument.id} created, ${distributions.length} distributions, ${allocations.length} member allocations`
);
return {
importSession: {
id: sessionId,
status: 'CONFIRMED'
},
kDocument: {
id: kDocument.id,
partnershipId: kDocument.partnershipId,
type: kDocument.type,
taxYear: kDocument.taxYear,
filingStatus: kDocument.filingStatus,
data: kDocument.data
},
distributions,
allocations: allocations.map((a) => ({
entityId: a.entityId,
entityName: a.entityName,
ownershipPercent: a.ownershipPercent,
allocatedValues: a.allocatedValues
})),
document: session.documentId
? { id: session.documentId, type: 'K1', name: session.fileName }
: null
};
}
/**
* Check if a PDF is password-protected (FR-029).
*/

205
apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.component.ts

@ -0,0 +1,205 @@
import { K1ImportDataService } from '@ghostfolio/client/services/k1-import-data.service';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
interface ConfirmationResult {
importSession: { id: string; status: string };
kDocument: {
id: string;
partnershipId: string;
type: string;
taxYear: number;
filingStatus: string;
data: Record<string, number | null>;
};
distributions: Array<{
id: string;
entityId: string;
type: string;
amount: number;
date: string;
}>;
allocations: Array<{
entityId: string;
entityName: string;
ownershipPercent: number;
allocatedValues: Record<string, number>;
}>;
document: { id: string; type: string; name: string } | null;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatProgressBarModule,
MatSelectModule,
MatTableModule
],
selector: 'gf-k1-confirmation',
styleUrls: ['./k1-confirmation.scss'],
templateUrl: './k1-confirmation.html'
})
export class K1ConfirmationComponent implements OnInit {
public error: string | null = null;
public filingStatus: 'DRAFT' | 'ESTIMATED' | 'FINAL' = 'DRAFT';
public filingStatusOptions = ['DRAFT', 'ESTIMATED', 'FINAL'];
public existingKDocumentAction: 'UPDATE' | 'CREATE_NEW' | null = null;
public hasConflict = false;
public isConfirming = false;
public isLoading = true;
public result: ConfirmationResult | null = null;
public sessionId: string;
public sessionStatus: string;
public allocationColumns = [
'entityName',
'ownershipPercent',
'allocatedValues'
];
public distributionColumns = ['entityId', 'type', 'amount', 'date'];
public constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly destroyRef: DestroyRef,
private readonly k1ImportDataService: K1ImportDataService,
private readonly router: Router
) {}
public ngOnInit(): void {
this.sessionId = this.activatedRoute.snapshot.params['id'];
this.loadSession();
}
/**
* Confirm the verified K-1 data.
*/
public confirmImport(): void {
this.isConfirming = true;
this.error = null;
this.changeDetectorRef.markForCheck();
const data: any = {
filingStatus: this.filingStatus
};
if (this.existingKDocumentAction) {
data.existingKDocumentAction = this.existingKDocumentAction;
}
this.k1ImportDataService
.confirmImportSession(this.sessionId, data)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: ConfirmationResult) => {
this.result = res;
this.isConfirming = false;
this.changeDetectorRef.markForCheck();
},
error: (err) => {
this.isConfirming = false;
// Handle conflict (409) — existing KDocument
if (err?.status === 409) {
this.hasConflict = true;
this.error =
'A KDocument already exists for this partnership and tax year. Choose an action below.';
} else {
this.error =
err?.error?.message || err?.message || 'Confirmation failed.';
}
this.changeDetectorRef.markForCheck();
}
});
}
/**
* Navigate back to the K-1 import list.
*/
public goToImportList(): void {
this.router.navigate(['/k1-import']);
}
/**
* Navigate to the created KDocument detail.
*/
public viewKDocument(): void {
if (this.result?.kDocument?.id) {
this.router.navigate([
'/k-documents',
this.result.kDocument.id
]);
}
}
/**
* Cancel and go back to verification.
*/
public goBackToVerify(): void {
this.router.navigate(['/k1-import', this.sessionId, 'verify']);
}
private loadSession(): void {
this.k1ImportDataService
.fetchImportSession(this.sessionId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (session: any) => {
this.sessionStatus = session.status;
if (session.status === 'CONFIRMED') {
// Already confirmed — show result view
this.result = {
importSession: { id: session.id, status: session.status },
kDocument: session.kDocumentId
? {
id: session.kDocumentId,
partnershipId: session.partnershipId,
type: 'K1',
taxYear: session.taxYear,
filingStatus: '',
data: {}
}
: null,
distributions: [],
allocations: [],
document: null
} as any;
} else if (session.status !== 'VERIFIED') {
this.error = `Session is in ${session.status} status. Must be VERIFIED to confirm.`;
}
this.isLoading = false;
this.changeDetectorRef.markForCheck();
},
error: (err) => {
this.error =
err?.error?.message || err?.message || 'Failed to load session.';
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}
});
}
}

167
apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.html

@ -0,0 +1,167 @@
<div class="container">
<div class="row">
<div class="col">
@if (error) {
<div class="alert alert-danger mb-3">
{{ error }}
</div>
}
@if (isLoading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
} @else if (result) {
<!-- Confirmation Result -->
<h1 class="h3 mb-4 text-center text-success">K-1 Import Confirmed</h1>
<!-- KDocument Summary -->
<section class="mb-4">
<h2 class="h5 mb-3">KDocument Created</h2>
<div class="summary-card p-3">
<div class="d-flex justify-content-between">
<span>ID</span>
<strong>{{ result.kDocument.id }}</strong>
</div>
<div class="d-flex justify-content-between">
<span>Type</span>
<strong>{{ result.kDocument.type }}</strong>
</div>
<div class="d-flex justify-content-between">
<span>Tax Year</span>
<strong>{{ result.kDocument.taxYear }}</strong>
</div>
<div class="d-flex justify-content-between">
<span>Filing Status</span>
<strong>{{ result.kDocument.filingStatus }}</strong>
</div>
</div>
</section>
<!-- Member Allocations -->
@if (result.allocations.length > 0) {
<section class="mb-4">
<h2 class="h5 mb-3">Member Allocations</h2>
<table mat-table [dataSource]="result.allocations" class="w-100">
<ng-container matColumnDef="entityName">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let a">{{ a.entityName }}</td>
</ng-container>
<ng-container matColumnDef="ownershipPercent">
<th mat-header-cell *matHeaderCellDef>Ownership %</th>
<td mat-cell *matCellDef="let a">{{ a.ownershipPercent }}%</td>
</ng-container>
<ng-container matColumnDef="allocatedValues">
<th mat-header-cell *matHeaderCellDef>Key Values</th>
<td mat-cell *matCellDef="let a">
@if (a.allocatedValues['1'] !== undefined) {
Box 1: {{ a.allocatedValues['1'] | number:'1.2-2' }}
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="allocationColumns"></tr>
<tr mat-row *matRowDef="let row; columns: allocationColumns"></tr>
</table>
</section>
}
<!-- Distributions -->
@if (result.distributions.length > 0) {
<section class="mb-4">
<h2 class="h5 mb-3">Distribution Records</h2>
<table mat-table [dataSource]="result.distributions" class="w-100">
<ng-container matColumnDef="entityId">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let d">{{ d.entityId }}</td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let d">{{ d.type }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let d">{{ d.amount | number:'1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let d">{{ d.date | date:'mediumDate' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="distributionColumns"></tr>
<tr mat-row *matRowDef="let row; columns: distributionColumns"></tr>
</table>
</section>
}
<!-- Linked Document -->
@if (result.document) {
<section class="mb-4">
<h2 class="h5 mb-3">Linked Document</h2>
<div class="summary-card p-3">
<div class="d-flex justify-content-between">
<span>File</span>
<strong>{{ result.document.name }}</strong>
</div>
<div class="d-flex justify-content-between">
<span>Type</span>
<strong>{{ result.document.type }}</strong>
</div>
</div>
</section>
}
<!-- Actions -->
<div class="actions d-flex justify-content-between mt-4">
<button mat-stroked-button (click)="goToImportList()">
Back to Import List
</button>
<button mat-flat-button color="primary" (click)="viewKDocument()">
View KDocument
</button>
</div>
} @else {
<!-- Confirmation Form -->
<h1 class="h3 mb-4 text-center">Confirm K-1 Import</h1>
<div class="confirmation-form mx-auto">
<div class="mb-3">
<mat-form-field class="w-100">
<mat-label>Filing Status</mat-label>
<mat-select [(ngModel)]="filingStatus">
@for (status of filingStatusOptions; track status) {
<mat-option [value]="status">{{ status }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (hasConflict) {
<div class="mb-3">
<mat-form-field class="w-100">
<mat-label>Existing KDocument Action</mat-label>
<mat-select [(ngModel)]="existingKDocumentAction">
<mat-option value="UPDATE">Update existing</mat-option>
<mat-option value="CREATE_NEW">Create new version</mat-option>
</mat-select>
</mat-form-field>
</div>
}
<div class="d-flex justify-content-between">
<button mat-stroked-button (click)="goBackToVerify()">
Back to Verify
</button>
<button
mat-flat-button
color="primary"
[disabled]="isConfirming || (hasConflict && !existingKDocumentAction)"
(click)="confirmImport()">
@if (isConfirming) {
Confirming...
} @else {
Confirm & Create KDocument
}
</button>
</div>
</div>
}
</div>
</div>
</div>

37
apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.scss

@ -0,0 +1,37 @@
:host {
display: block;
}
.confirmation-form {
max-width: 480px;
}
.summary-card {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
> div {
padding: 4px 0;
border-bottom: 1px solid var(--border-color, #f0f0f0);
&:last-child {
border-bottom: none;
}
}
}
.text-success {
color: #4caf50;
}
.alert-danger {
background-color: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 4px;
color: #f44336;
padding: 12px 16px;
}
.actions {
padding-bottom: 2rem;
}

9
apps/client/src/app/pages/k1-import/k1-import-page.routes.ts

@ -20,5 +20,14 @@ export const routes: Routes = [
),
path: ':id/verify',
title: 'Verify K-1 Import'
},
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./k1-confirmation/k1-confirmation.component').then(
(c) => c.K1ConfirmationComponent
),
path: ':id/confirm',
title: 'Confirm K-1 Import'
}
];

12
specs/004-k1-scan-import/tasks.md

@ -106,12 +106,12 @@
### Implementation for User Story 3
- [ ] T032 [P] [US3] Create confirm DTO (filingStatus, existingKDocumentAction) in apps/api/src/app/k1-import/dto/confirm-k1.dto.ts
- [ ] T033 [US3] Implement K1 allocation service (allocate line items to members by ownership % as of tax year end, rounding adjustment on largest member per validation rule 8) in apps/api/src/app/k1-import/k1-allocation.service.ts
- [ ] T034 [US3] Implement confirmation logic in K1 import service (create KDocument with type K1 and verified box values, create Distribution records for Box 19a/19b, create Document record for PDF, link all records per FR-012 through FR-015) in apps/api/src/app/k1-import/k1-import.service.ts
- [ ] T035 [US3] Implement duplicate KDocument detection (check existing partnershipId + type K1 + taxYear, prompt UPDATE vs CREATE_NEW per FR-016) in apps/api/src/app/k1-import/k1-import.service.ts
- [ ] T036 [US3] Add POST /api/v1/k1-import/:id/confirm endpoint to apps/api/src/app/k1-import/k1-import.controller.ts
- [ ] T037 [US3] Create K1 confirmation result component (displays created KDocument summary, member allocations table, distribution records, linked Document) in apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.component.ts
- [X] T032 [P] [US3] Create confirm DTO (filingStatus, existingKDocumentAction) in apps/api/src/app/k1-import/dto/confirm-k1.dto.ts
- [X] T033 [US3] Implement K1 allocation service (allocate line items to members by ownership % as of tax year end, rounding adjustment on largest member per validation rule 8) in apps/api/src/app/k1-import/k1-allocation.service.ts
- [X] T034 [US3] Implement confirmation logic in K1 import service (create KDocument with type K1 and verified box values, create Distribution records for Box 19a/19b, create Document record for PDF, link all records per FR-012 through FR-015) in apps/api/src/app/k1-import/k1-import.service.ts
- [X] T035 [US3] Implement duplicate KDocument detection (check existing partnershipId + type K1 + taxYear, prompt UPDATE vs CREATE_NEW per FR-016) in apps/api/src/app/k1-import/k1-import.service.ts
- [X] T036 [US3] Add POST /api/v1/k1-import/:id/confirm endpoint to apps/api/src/app/k1-import/k1-import.controller.ts
- [X] T037 [US3] Create K1 confirmation result component (displays created KDocument summary, member allocations table, distribution records, linked Document) in apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.component.ts
**Checkpoint**: At this point, the complete K-1 import pipeline (upload → extract → verify → confirm → auto-create) is functional — this is the MVP

Loading…
Cancel
Save