Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 174 additions & 2 deletions apps/admin/src/app/trips/[id]/_components/trip-insurance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdvisorConsentMethod, string> = {
verbal_phone: 'Verbal (phone)',
email_confirmation: 'Email confirmation',
in_person_signature: 'In-person signature',
}

interface TripInsuranceProps {
trip: TripResponseDto
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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({
Expand All @@ -333,6 +357,7 @@ export function TripInsurance({ trip }: TripInsuranceProps) {
declinedReason: '',
premiumPaidCents: null,
notes: '',
consentMethod: null,
})
}
setShowTravelerDialog(true)
Expand Down Expand Up @@ -533,6 +558,13 @@ export function TripInsurance({ trip }: TripInsuranceProps) {
<span className="text-red-600">{insurance.declinedReason}</span>
)}
{status === 'pending' && <span className="italic">Awaiting response</span>}
{/* refs #1466 P3b — link the signed waiver when the resolution
came from a client e-sign (form_token_id present). */}
{insurance?.formTokenId && (
<div className="mt-1.5">
<ViewSignedWaiverLink tripId={trip.id} formTokenId={insurance.formTokenId} />
</div>
)}
</TableCell>
<TableCell>
<Button variant="outline" size="sm" onClick={() => handleOpenTravelerDialog(traveler.id)}>
Expand Down Expand Up @@ -862,6 +894,9 @@ function AssignPackageDialog({
const { toast } = useToast()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
// refs #1466 P3b — assigning a package resolves the traveler to 'selected_package',
// which the server requires a consent method for. One selector covers the batch.
const [consentMethod, setConsentMethod] = useState<AdvisorConsentMethod | null>(null)

useEffect(() => {
if (open && pkg) {
Expand All @@ -872,6 +907,7 @@ function AssignPackageDialog({
.map((t) => t.tripTravelerId),
)
setSelectedIds(pre)
setConsentMethod(null)
}
}, [open, pkg, travelerInsuranceList])

Expand All @@ -893,16 +929,19 @@ function AssignPackageDialog({
const checked = selectedIds.has(traveler.id)
if (checked) {
// Assign this package to the traveler (create or update — same endpoint).
// consentMethod threaded so the resolved status carries P3b evidence.
if (existing) {
await api.patch(`/trips/${tripId}/insurance/travelers/${existing.id}`, {
status: 'selected_package',
selectedPackageId: pkg.id,
consentMethod,
})
} else {
await api.post(`/trips/${tripId}/insurance/travelers`, {
tripTravelerId: traveler.id,
status: 'selected_package',
selectedPackageId: pkg.id,
consentMethod,
})
}
} else if (existing && existing.status === 'selected_package' && existing.selectedPackageId === pkg.id) {
Expand Down Expand Up @@ -962,11 +1001,38 @@ function AssignPackageDialog({
)}
</div>

{/* refs #1466 P3b — required consent capture for the batch assignment. */}
{selectedIds.size > 0 && (
<div className="grid gap-2 border-t border-ash-100 pt-3">
<Label htmlFor="assignConsentMethod">
Confirmed via <span className="text-red-500">*</span>
</Label>
<Select
value={consentMethod ?? ''}
onValueChange={(value) => setConsentMethod(value as AdvisorConsentMethod)}
>
<SelectTrigger id="assignConsentMethod">
<SelectValue placeholder="How was this confirmed?" />
</SelectTrigger>
<SelectContent>
{ADVISOR_CONSENT_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{CONSENT_METHOD_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || travelers.length === 0}>
<Button
onClick={handleSave}
disabled={saving || travelers.length === 0 || (selectedIds.size > 0 && !consentMethod)}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Expand All @@ -982,6 +1048,48 @@ function AssignPackageDialog({
)
}

// refs #1466 P3b — open a traveler's signed insurance waiver in a new tab. Reuses
// the existing GET /trips/:id/forms/:formId/pdf endpoint (short-lived signed URL);
// no new endpoint. Mirrors the trip-forms download click-then-navigate pattern so
// the post-fetch navigation isn't treated as a blocked popup.
function ViewSignedWaiverLink({ tripId, formTokenId }: { tripId: string; formTokenId: string }) {
const { toast } = useToast()
const [loading, setLoading] = useState(false)

const open = async () => {
setLoading(true)
const win = window.open('about:blank', '_blank')
try {
const { url } = await fetchTripFormPdfUrl(tripId, formTokenId)
if (win) win.location.href = url
else window.open(url, '_blank', 'noopener,noreferrer')
} catch {
win?.close()
toast({
title: 'Document unavailable',
description: 'The signed waiver for this traveler could not be retrieved.',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}

return (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit gap-1.5"
onClick={open}
disabled={loading}
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Shield className="h-3.5 w-3.5" />}
View signed waiver
</Button>
)
}

// Separate component for traveler insurance dialog to use hooks properly
interface TravelerInsuranceDialogProps {
open: boolean
Expand All @@ -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
Expand All @@ -1008,6 +1120,7 @@ interface TravelerInsuranceDialogProps {
declinedReason: string
premiumPaidCents: number | null
notes: string
consentMethod: AdvisorConsentMethod | null
}
setForm: (form: any) => void
travelerName: string
Expand Down Expand Up @@ -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

Expand All @@ -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) {
Expand Down Expand Up @@ -1219,6 +1357,40 @@ function TravelerInsuranceDialog({
</div>
)}

{/* refs #1466 P3b — required consent capture for an advisor-recorded
resolution. Hidden for a client e-sign record (evidence on file). */}
{showConsent && (
<div className="grid gap-2">
<Label htmlFor="consentMethod">
Confirmed via <span className="text-red-500">*</span>
</Label>
<Select
value={form.consentMethod ?? ''}
onValueChange={(value) =>
setForm({ ...form, consentMethod: value as AdvisorConsentMethod })
}
>
<SelectTrigger id="consentMethod">
<SelectValue placeholder="How was this confirmed?" />
</SelectTrigger>
<SelectContent>
{ADVISOR_CONSENT_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{CONSENT_METHOD_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-ash-500">
Records how the traveler&apos;s insurance decision was confirmed (compliance).
</p>
</div>
)}

{isClientEsign && existingInsurance?.formTokenId && (
<ViewSignedWaiverLink tripId={tripId} formTokenId={existingInsurance.formTokenId} />
)}

<div className="grid gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
Expand All @@ -1235,7 +1407,7 @@ function TravelerInsuranceDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isPending || needsPackage}>
<Button onClick={handleSave} disabled={isPending || needsPackage || needsConsent}>
{isPending ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
Expand Down
50 changes: 50 additions & 0 deletions apps/api/src/forms/__tests__/forms.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,54 @@ describe('FormsService — submission requires a signature on both paths', () =>
expect(mockStorage.uploadDocument).not.toHaveBeenCalled()
expect(dbChain.transaction).not.toHaveBeenCalled()
})

// refs #1466 P3b — the client waiver path stamps compliance evidence
// (resolution_source='client_esign', form_token_id, offered snapshot) on EVERY
// write inside the atomic-claim tx: both actions (purchase/decline) × both
// branches (insert new / update existing).
const offered = [{ id: 'pkg-1', packageName: 'Gold', premiumCents: 12000 }]

it('stamps client_esign evidence on the INSERT branch (new record, purchase)', async () => {
insertedValues.length = 0 // this describe doesn't reset it between tests
dbChain.limit.mockResolvedValue([]) // no existing insurance → insert branch
const form = {
...baseForm,
contextData: { ...baseForm.contextData, packages: offered },
}
await service.handleInsuranceWaiverSubmission(
form,
{ decisions: [{ travelerId: 't1', action: 'purchase', packageId: 'pkg-1' }] },
{ ...validSig, decision: 'purchase' },
)
const evidenceInsert = insertedValues.find((v) => v.resolutionSource === 'client_esign')
expect(evidenceInsert).toBeDefined()
expect(evidenceInsert!.status).toBe('selected_package')
expect(evidenceInsert!.formTokenId).toBe('form-1')
expect(evidenceInsert!.offeredCoveragesSnapshot).toEqual(offered)
})

it('stamps client_esign evidence on the UPDATE branch (existing record, decline)', async () => {
dbChain.limit.mockResolvedValue([{ id: 'existing-ins-1' }]) // existing → update branch
const form = {
...baseForm,
contextData: { ...baseForm.contextData, packages: offered },
}
await service.handleInsuranceWaiverSubmission(
form,
{ decisions: [{ travelerId: 't1', action: 'decline', reason: 'has coverage' }] },
validSig,
)
const evidenceSet = (dbChain.set.mock.calls as any[])
.map((c) => c[0])
.find((v) => v?.resolutionSource === 'client_esign')
expect(evidenceSet).toBeDefined()
expect(evidenceSet.status).toBe('declined')
expect(evidenceSet.formTokenId).toBe('form-1')
expect(evidenceSet.offeredCoveragesSnapshot).toEqual(offered)
// Codex P3b block #2: a prior advisor consent_method must be cleared when the
// row is (re)resolved by the client's signed waiver.
expect(evidenceSet.consentMethod).toBeNull()
// reset so later suites see the default no-existing behavior
dbChain.limit.mockResolvedValue([])
})
})
Loading
Loading