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 |
* 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() |
@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