mirror of https://github.com/ghostfolio/ghostfolio
9 changed files with 739 additions and 10 deletions
@ -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'; |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
|
|||
@ -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(); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
Loading…
Reference in new issue