From 904fe4b820974d298633c14702d0970d7fc30d15 Mon Sep 17 00:00:00 2001 From: Robert Patch Date: Wed, 18 Mar 2026 01:37:00 -0700 Subject: [PATCH] feat(k1-import): Phase 5 US3 - confirm flow, allocation service, KDocument/Distribution creation, confirmation component --- .../src/app/k1-import/dto/confirm-k1.dto.ts | 15 ++ .../app/k1-import/k1-allocation.service.ts | 90 +++++++- .../src/app/k1-import/k1-import.controller.ts | 16 ++ .../src/app/k1-import/k1-import.service.ts | 198 ++++++++++++++++- .../k1-confirmation.component.ts | 205 ++++++++++++++++++ .../k1-confirmation/k1-confirmation.html | 167 ++++++++++++++ .../k1-confirmation/k1-confirmation.scss | 37 ++++ .../pages/k1-import/k1-import-page.routes.ts | 9 + specs/004-k1-scan-import/tasks.md | 12 +- 9 files changed, 739 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/app/k1-import/dto/confirm-k1.dto.ts create mode 100644 apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.component.ts create mode 100644 apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.html create mode 100644 apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.scss diff --git a/apps/api/src/app/k1-import/dto/confirm-k1.dto.ts b/apps/api/src/app/k1-import/dto/confirm-k1.dto.ts new file mode 100644 index 000000000..477ab8dba --- /dev/null +++ b/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'; +} diff --git a/apps/api/src/app/k1-import/k1-allocation.service.ts b/apps/api/src/app/k1-import/k1-allocation.service.ts index 0a3b02631..b007db978 100644 --- a/apps/api/src/app/k1-import/k1-allocation.service.ts +++ b/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; +} /** * 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 { + // 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; + } +} diff --git a/apps/api/src/app/k1-import/k1-import.controller.ts b/apps/api/src/app/k1-import/k1-import.controller.ts index af107f9ac..f6ddb1c58 100644 --- a/apps/api/src/app/k1-import/k1-import.controller.ts +++ b/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); + } } diff --git a/apps/api/src/app/k1-import/k1-import.service.ts b/apps/api/src/app/k1-import/k1-import.service.ts index f1dcd987d..4106cd97b 100644 --- a/apps/api/src/app/k1-import/k1-import.service.ts +++ b/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 = {}; + 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). */ diff --git a/apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.component.ts b/apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.component.ts new file mode 100644 index 000000000..c30215e84 --- /dev/null +++ b/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; + }; + distributions: Array<{ + id: string; + entityId: string; + type: string; + amount: number; + date: string; + }>; + allocations: Array<{ + entityId: string; + entityName: string; + ownershipPercent: number; + allocatedValues: Record; + }>; + 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(); + } + }); + } +} diff --git a/apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.html b/apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.html new file mode 100644 index 000000000..23f65ff3f --- /dev/null +++ b/apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.html @@ -0,0 +1,167 @@ +
+
+
+ @if (error) { +
+ {{ error }} +
+ } + + @if (isLoading) { + + } @else if (result) { + +

K-1 Import Confirmed

+ + +
+

KDocument Created

+
+
+ ID + {{ result.kDocument.id }} +
+
+ Type + {{ result.kDocument.type }} +
+
+ Tax Year + {{ result.kDocument.taxYear }} +
+
+ Filing Status + {{ result.kDocument.filingStatus }} +
+
+
+ + + @if (result.allocations.length > 0) { +
+

Member Allocations

+ + + + + + + + + + + + + + + +
Member{{ a.entityName }}Ownership %{{ a.ownershipPercent }}%Key Values + @if (a.allocatedValues['1'] !== undefined) { + Box 1: {{ a.allocatedValues['1'] | number:'1.2-2' }} + } +
+
+ } + + + @if (result.distributions.length > 0) { +
+

Distribution Records

+ + + + + + + + + + + + + + + + + + + +
Member{{ d.entityId }}Type{{ d.type }}Amount{{ d.amount | number:'1.2-2' }}Date{{ d.date | date:'mediumDate' }}
+
+ } + + + @if (result.document) { +
+

Linked Document

+
+
+ File + {{ result.document.name }} +
+
+ Type + {{ result.document.type }} +
+
+
+ } + + +
+ + +
+ } @else { + +

Confirm K-1 Import

+ +
+
+ + Filing Status + + @for (status of filingStatusOptions; track status) { + {{ status }} + } + + +
+ + @if (hasConflict) { +
+ + Existing KDocument Action + + Update existing + Create new version + + +
+ } + +
+ + +
+
+ } +
+
+
diff --git a/apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.scss b/apps/client/src/app/pages/k1-import/k1-confirmation/k1-confirmation.scss new file mode 100644 index 000000000..adae32601 --- /dev/null +++ b/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; +} diff --git a/apps/client/src/app/pages/k1-import/k1-import-page.routes.ts b/apps/client/src/app/pages/k1-import/k1-import-page.routes.ts index 9fe39929d..004e7dbea 100644 --- a/apps/client/src/app/pages/k1-import/k1-import-page.routes.ts +++ b/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' } ]; diff --git a/specs/004-k1-scan-import/tasks.md b/specs/004-k1-scan-import/tasks.md index 28e7c3b65..9b7e25e4f 100644 --- a/specs/004-k1-scan-import/tasks.md +++ b/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