From ca801212ab5bd2431df6179edb1792e0dcf8e851 Mon Sep 17 00:00:00 2001 From: Al Date: Sun, 5 Jul 2026 10:16:51 -0400 Subject: [PATCH] feat(#1466 P3b): compliance-grade insurance resolution evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advances #837 P2 (documentation) + P3 (declinations). Captures HOW and by WHOM a traveler's insurance resolution was recorded, closing the "rubber-stamp" gap where declined/has_own/selected could be set with no consent or offered- coverage record. Additive/forward-only; ships live on merge. Migration (20260705120000): new enum insurance_resolution_source ('advisor_manual','client_esign') + 4 additive columns on trip_traveler_insurance (resolution_source NOT NULL DEFAULT 'advisor_manual', consent_method, form_token_id FK→form_tokens ON DELETE SET NULL, offered_coverages_snapshot jsonb). The ~93 existing prod rows keep the default with null evidence — a documented pre-P3b audit gap, NOT retro-enforced (flag to Al). Backend: - insurance.service: validateStatusRequirements now REQUIRES a valid consent_method when an advisor records a resolved status (400 otherwise); pending is exempt. create/updateTravelerInsurance FORCE resolution_source='advisor_manual' server-side (mirrors the activity_id-strip; spoofed values stripped) and freeze offered_coverages_snapshot from active packages. A client_esign record is preserved (no consent demanded, evidence not clobbered) when the advisor edits without changing the resolved status; an override flips it to advisor_manual with fresh consent and unlinks the token. formatTravelerInsurance surfaces all 4 fields. - forms.service handleInsuranceWaiverSubmission: stamps resolution_source='client_esign', form_token_id, offered_coverages_snapshot on every write (insert+update, both actions) inside the existing atomic-claim tx. No new send. - Shared insurance-offer-snapshot.mapper (extracted verbatim from queueProposalEmails' contextData snapshot, §7 colocated spec) so the advisor and waiver paths record an identical offer shape. - insurance.queries declines report surfaces resolution_source + consent_method. Frontend (trip-insurance.tsx): - TravelerInsuranceDialog + AssignPackageDialog: required "Confirmed via" selector shown for a resolved status on a non-e-sign record; Save gated until chosen. - Status table + dialog: "View signed waiver" link when form_token_id is present (reuses GET /trips/:id/forms/:formId/pdf — no new endpoint). Shared-types: InsuranceResolutionSource, AdvisorConsentMethod (+ADVISOR_CONSENT_METHODS, excludes client-self-service), InsuranceOfferSnapshotItem; 4 read-DTO fields + consentMethod on Create/Update. STRICT error mode (compliance never best-effort). Money boundary untouched (commissionTotalCents/activityId/mirror/link paths unchanged). No TripsService touch. Tests (all foreground): mapper spec; service evidence spec (consent required per resolved status + exempt for pending; resolution_source always advisor_manual even if spoofed; snapshot frozen; client_esign preserve vs override); forms spec extended (client_esign+form_token_id+snapshot on both branches inside the tx). Co-Authored-By: Claude Opus 4.8 --- .../trips/[id]/_components/trip-insurance.tsx | 176 ++++++++++- .../src/forms/__tests__/forms.service.spec.ts | 50 ++++ apps/api/src/forms/forms.service.ts | 17 ++ .../reporting/queries/insurance.queries.ts | 5 + .../src/trips/insurance-automation.service.ts | 25 +- .../insurance-offer-snapshot.mapper.spec.ts | 85 ++++++ .../trips/insurance-offer-snapshot.mapper.ts | 38 +++ .../trips/insurance.service.evidence.spec.ts | 282 ++++++++++++++++++ apps/api/src/trips/insurance.service.ts | 133 ++++++++- packages/database/src/index.ts | 9 + ...000_1466_insurance_resolution_evidence.sql | 24 ++ .../src/migrations/meta/_journal.json | 7 + .../database/src/schema/insurance.schema.ts | 25 ++ .../shared-types/src/api/insurance.types.ts | 53 ++++ 14 files changed, 905 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/trips/insurance-offer-snapshot.mapper.spec.ts create mode 100644 apps/api/src/trips/insurance-offer-snapshot.mapper.ts create mode 100644 apps/api/src/trips/insurance.service.evidence.spec.ts create mode 100644 packages/database/src/migrations/20260705120000_1466_insurance_resolution_evidence.sql diff --git a/apps/admin/src/app/trips/[id]/_components/trip-insurance.tsx b/apps/admin/src/app/trips/[id]/_components/trip-insurance.tsx index bcb65b61d..6d4e7feb0 100644 --- a/apps/admin/src/app/trips/[id]/_components/trip-insurance.tsx +++ b/apps/admin/src/app/trips/[id]/_components/trip-insurance.tsx @@ -63,10 +63,30 @@ import type { TravelerInsuranceStatus, InsurancePolicyType, InsuranceProductDto, + AdvisorConsentMethod, + InsuranceResolutionSource, } from '@tailfire/shared-types/api' +import { ADVISOR_CONSENT_METHODS } from '@tailfire/shared-types/api' import { formatCurrency, dollarsToCents, centsToDollars } from '@/lib/pricing/currency-helpers' import { InsuranceProposalDialog } from '@/components/insurance/insurance-proposal-dialog' import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { fetchTripFormPdfUrl } from '@/hooks/use-trip-forms' + +// refs #1466 P3b — statuses that constitute a compliance "resolution". +const RESOLVED_TRAVELER_STATUSES: TravelerInsuranceStatus[] = [ + 'declined', + 'has_own_insurance', + 'selected_package', +] +const isResolvedTravelerStatus = (s: TravelerInsuranceStatus) => + RESOLVED_TRAVELER_STATUSES.includes(s) + +// refs #1466 P3b — human labels for how advisor consent was captured. +const CONSENT_METHOD_LABELS: Record = { + verbal_phone: 'Verbal (phone)', + email_confirmation: 'Email confirmation', + in_person_signature: 'In-person signature', +} interface TripInsuranceProps { trip: TripResponseDto @@ -193,6 +213,8 @@ export function TripInsurance({ trip }: TripInsuranceProps) { declinedReason: '', premiumPaidCents: null as number | null, notes: '', + // refs #1466 P3b — how the advisor captured consent for a resolved status. + consentMethod: null as AdvisorConsentMethod | null, }) const handleOpenPackageDialog = (pkg?: TripInsurancePackageDto) => { @@ -323,6 +345,8 @@ export function TripInsurance({ trip }: TripInsuranceProps) { declinedReason: existing.declinedReason || '', premiumPaidCents: existing.premiumPaidCents ?? null, notes: existing.notes || '', + // Pre-fill the prior consent method so re-saving doesn't force a re-pick. + consentMethod: (existing.consentMethod as AdvisorConsentMethod | null) ?? null, }) } else { setTravelerForm({ @@ -333,6 +357,7 @@ export function TripInsurance({ trip }: TripInsuranceProps) { declinedReason: '', premiumPaidCents: null, notes: '', + consentMethod: null, }) } setShowTravelerDialog(true) @@ -533,6 +558,13 @@ export function TripInsurance({ trip }: TripInsuranceProps) { {insurance.declinedReason} )} {status === 'pending' && Awaiting response} + {/* refs #1466 P3b — link the signed waiver when the resolution + came from a client e-sign (form_token_id present). */} + {insurance?.formTokenId && ( +
+ +
+ )} - + ) +} + // Separate component for traveler insurance dialog to use hooks properly interface TravelerInsuranceDialogProps { open: boolean @@ -999,6 +1107,10 @@ interface TravelerInsuranceDialogProps { declinedReason: string | null premiumPaidCents: number | null notes: string | null + // refs #1466 P3b — evidence context for the consent selector + waiver link. + resolutionSource?: InsuranceResolutionSource + consentMethod?: string | null + formTokenId?: string | null } form: { status: TravelerInsuranceStatus @@ -1008,6 +1120,7 @@ interface TravelerInsuranceDialogProps { declinedReason: string premiumPaidCents: number | null notes: string + consentMethod: AdvisorConsentMethod | null } setForm: (form: any) => void travelerName: string @@ -1051,6 +1164,28 @@ function TravelerInsuranceDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, packagesLoaded, packages, form.selectedPackageId]) + // refs #1466 P3b — a resolved status recorded via the ADVISOR path must carry a + // consent method. A record already resolved by the client (client_esign) keeps + // its signed-waiver evidence for evidence-NEUTRAL edits (notes/premium). But if + // the advisor MATERIALLY changes the resolution (status or a status-defining + // field), it becomes an advisor override that must re-capture consent — mirror + // the server rule so the override isn't silently 400'd. + const isClientEsign = existingInsurance?.resolutionSource === 'client_esign' + const statusIsResolved = isResolvedTravelerStatus(form.status) + const esignMaterialChange = + !!existingInsurance && + isClientEsign && + (form.status !== existingInsurance.status || + (form.status === 'selected_package' && + (form.selectedPackageId ?? null) !== (existingInsurance.selectedPackageId ?? null)) || + (form.status === 'has_own_insurance' && + ((form.externalPolicyNumber || null) !== (existingInsurance.externalPolicyNumber ?? null) || + (form.externalProviderName || null) !== (existingInsurance.externalProviderName ?? null))) || + (form.status === 'declined' && + (form.declinedReason || null) !== (existingInsurance.declinedReason ?? null))) + const showConsent = statusIsResolved && (!isClientEsign || esignMaterialChange) + const needsConsent = showConsent && !form.consentMethod + const handleSave = async () => { if (!travelerId) return @@ -1064,6 +1199,9 @@ function TravelerInsuranceDialog({ // Premium payment only applies to an agency-sold package; clear it otherwise. premiumPaidCents: form.status === 'selected_package' ? form.premiumPaidCents : null, notes: form.notes || null, + // Only send consent when the advisor actually captured it (resolved status, + // non-e-sign record). The server ignores it for pending/e-sign records. + ...(showConsent ? { consentMethod: form.consentMethod } : {}), } if (existingInsurance) { @@ -1219,6 +1357,40 @@ function TravelerInsuranceDialog({ )} + {/* refs #1466 P3b — required consent capture for an advisor-recorded + resolution. Hidden for a client e-sign record (evidence on file). */} + {showConsent && ( +
+ + +

+ Records how the traveler's insurance decision was confirmed (compliance). +

+
+ )} + + {isClientEsign && existingInsurance?.formTokenId && ( + + )} +