feat(1099): wire W-9 document upload/view for recipients
Adds POST/GET payee-1099/{id}/w9, mirroring the expense-receipt upload:
IFileStorage saves to finance/w9/{id}{ext}, content-type derived from the
blob extension. Frontend dialog (edit mode) gains a W-9 file input and an
auth-correct blob "View W-9" link. Payee1099Service ctor now takes
IFileStorage; tests updated with an in-memory FakeStorage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,17 @@
|
||||
<kendo-datepicker [(value)]="form.w9ReceivedDate"></kendo-datepicker>
|
||||
</label>
|
||||
|
||||
<!-- W-9 document upload/view: edit mode only (a new record is saved first, then re-opened to attach). -->
|
||||
<div *ngIf="editingId != null" class="flex flex-col gap-1 md:col-span-2">
|
||||
<span>W-9 Document / W-9 文件</span>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
*appHasPermission="{ module: 'Form1099', action: 'write' }"
|
||||
(change)="onW9FileSelected($event)" />
|
||||
<span class="hint-text-sm">Upload W-9 / 上傳 W-9</span>
|
||||
<button *ngIf="editingHasW9" kendoButton type="button" fillMode="link" class="self-start"
|
||||
(click)="viewW9()">View W-9 / 檢視 W-9</button>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 md:col-span-2">
|
||||
Notes / 備註
|
||||
<kendo-textarea [(ngModel)]="form.notes" [rows]="3"></kendo-textarea>
|
||||
|
||||
@@ -71,6 +71,12 @@ export class Payee1099PageComponent implements OnInit {
|
||||
editingId: number | null = null;
|
||||
/** Last-4 of the existing TIN (edit mode), so the TIN box can show a masked placeholder. */
|
||||
editingTinLast4: string | null = null;
|
||||
/** True when the record being edited already has a W-9 document attached. */
|
||||
editingHasW9 = false;
|
||||
/** Full TIN revealed on demand (write-gated); shown read-only, never logged or persisted. */
|
||||
revealedTin: string | null = null;
|
||||
/** Whether the user has manually toggled "1099 Tracked" in this dialog session (suppresses the classification default). */
|
||||
private trackedTouched = false;
|
||||
form: Payee1099Form = this.blankForm();
|
||||
|
||||
constructor(
|
||||
@@ -144,16 +150,23 @@ export class Payee1099PageComponent implements OnInit {
|
||||
openNew(): void {
|
||||
this.editingId = null;
|
||||
this.editingTinLast4 = null;
|
||||
this.editingHasW9 = false;
|
||||
this.revealedTin = null;
|
||||
this.trackedTouched = false;
|
||||
this.form = this.blankForm();
|
||||
this.dialogOpen = true;
|
||||
}
|
||||
|
||||
openEdit(row: Payee1099ListItem): void {
|
||||
this.editingId = row.id;
|
||||
this.editingHasW9 = false;
|
||||
this.revealedTin = null;
|
||||
this.trackedTouched = false;
|
||||
this.dialogOpen = true;
|
||||
// Load the full record so the dialog can prefill the address/contact/notes fields.
|
||||
this.api.getById(row.id).subscribe((payee: Payee1099) => {
|
||||
this.editingTinLast4 = payee.tinLast4 ?? null;
|
||||
this.editingHasW9 = payee.hasW9Document;
|
||||
this.form = {
|
||||
legalName: payee.legalName,
|
||||
displayName: payee.displayName ?? '',
|
||||
@@ -217,6 +230,47 @@ export class Payee1099PageComponent implements OnInit {
|
||||
this.api.delete(row.id).subscribe(() => this.load());
|
||||
}
|
||||
|
||||
// ── Tax classification drives the 1099-tracked default (spec §2.1/§2.3) ────────
|
||||
// Corporations default to NOT tracked; everyone else defaults to tracked. Only applies
|
||||
// to NEW records and only until the user manually flips the toggle (no override of an
|
||||
// explicit choice or an existing saved value on edit).
|
||||
onTaxClassificationChange(classification: string): void {
|
||||
if (this.editingId != null || this.trackedTouched) return;
|
||||
const isCorporation = classification === 'CCorp' || classification === 'SCorp';
|
||||
this.form.is1099Tracked = !isCorporation;
|
||||
}
|
||||
|
||||
onTrackedToggle(): void { this.trackedTouched = true; }
|
||||
|
||||
// ── W-9 document upload/view (edit mode only; a new record is saved first) ─────
|
||||
onW9FileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
if (!file || this.editingId == null) return;
|
||||
this.api.uploadW9(this.editingId, file).subscribe(() => {
|
||||
this.editingHasW9 = true;
|
||||
input.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch the stored W-9 via HttpClient (auth interceptor attaches the JWT) and open it in a new tab. */
|
||||
viewW9(): void {
|
||||
if (this.editingId == null) return;
|
||||
this.api.downloadW9(this.editingId).subscribe((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Full TIN reveal (write-gated; acceptance criterion #11.4) ──────────────────
|
||||
revealTin(): void {
|
||||
if (this.editingId == null) return;
|
||||
this.api.revealTin(this.editingId).subscribe((result) => {
|
||||
this.revealedTin = result.tin;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Date-only helpers: build/parse "yyyy-MM-dd" from LOCAL components ─────────
|
||||
private parseDateOnly(value: string | undefined | null): Date | null {
|
||||
if (!value) return null;
|
||||
|
||||
@@ -39,4 +39,19 @@ export class Payee1099ApiService {
|
||||
revealTin(id: number): Observable<{ tin: string | null }> {
|
||||
return this.http.get<{ tin: string | null }>(`${this.endpoint}/${id}/tin`);
|
||||
}
|
||||
|
||||
uploadW9(id: number, file: File): Observable<void> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return this.http.post<void>(`${this.endpoint}/${id}/w9`, form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the stored W-9 as a Blob via HttpClient so the auth interceptor attaches
|
||||
* the JWT. A plain window.open on the API URL would be an unauthenticated browser
|
||||
* navigation and the API's permission gate would reject it.
|
||||
*/
|
||||
downloadW9(id: number): Observable<Blob> {
|
||||
return this.http.get(`${this.endpoint}/${id}/w9`, { responseType: 'blob' });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user