Files
accounted/app/(dashboard)/invoices/[id]/credit/page.tsx
T
Mattsson f8db38f989 fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* fix(analytics): mask session replays by default, chrome-only unmask

Invert PostHog session-replay masking from visible-by-default with pattern
masking to deny-by-default: every input value is masked wholesale (rrweb
maskAllInputs, no maskInputFn) and every text node is masked unless it sits
under data-ph-unmask chrome or a table column header (th). Chrome tags live
on the shared UI primitives (PageHeader, Label, Button except combobox
triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help
popovers, empty states, settings labels), and tagged chrome is still
pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask
beats data-ph-unmask, so call sites that interpolate user data into chrome
stay masked; a very-thorough audit swept every unmasked primitive and each
found site got a call-site mask. Confirm-dialog wrappers and toasts stay
masked centrally: their copy describes user objects by design. Untagged new
UI over-masks instead of leaking. Privacy policy, RoPA and decision log
updated in the same change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(analytics): tag detail-section chrome merged from main

The register-detail primitives landed on main after the replay-masking
audit ran: kickers and DefRow labels are static i18n chrome, values stay
masked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(analytics): close skeptic and review findings on replay masking

Explicit data-ph tags now resolve before the th chrome fallback, so a th
nested inside a data-ph-mask container masks correctly (regression test
added). Seven missed text-leak sites get call-site masks: delete-invoice
and credit-page invoice numbers, IB-correction voucher reference, TIC
orgnr (served unnormalized, so the separator-based scrub cannot be relied
on), articles search-term empty state, dimension segment labels, and
activate-account buttons. The attribute channel is closed with rrweb's
blockClass: inputs whose placeholder carries an effective user value
(salary overrides, correction description, danger-zone confirms, credit
confirm) get ph-no-capture, removing the element from recordings while
the prefill UX stays intact; the pivot-th title attribute is dropped.
Privacy-policy effective date bumped to 2026-08-17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:32:45 +02:00

405 lines
14 KiB
TypeScript

'use client'
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { Loader2, ArrowLeft, AlertTriangle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import type { Invoice, InvoiceItem, Customer } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
}
export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoice_credit')
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [reason, setReason] = useState('')
const [confirmText, setConfirmText] = useState('')
const [createdCreditNote, setCreatedCreditNote] = useState<InvoiceWithRelations | null>(null)
const [showSendPrompt, setShowSendPrompt] = useState(false)
async function fetchInvoice() {
setIsLoading(true)
const { data, error } = await supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.single()
if (error || !data) {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
router.push('/invoices')
return
}
// Check if invoice can be credited
if (!['sent', 'paid', 'overdue'].includes(data.status)) {
toast({
title: t('cannot_credit_title'),
description: t('cannot_credit_description'),
variant: 'destructive',
})
router.push(`/invoices/${id}`)
return
}
if (data.status === 'credited') {
toast({
title: t('already_credited_title'),
description: t('already_credited_description'),
variant: 'destructive',
})
router.push(`/invoices/${id}`)
return
}
// Sort items by sort_order
if (data.items) {
data.items.sort((a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order)
}
setInvoice(data as InvoiceWithRelations)
setReason(t('reason_default', { number: data.invoice_number ?? '' }))
setIsLoading(false)
}
useEffect(() => {
fetchInvoice()
}, [id])
async function handleSubmit() {
if (!invoice) return
setIsSubmitting(true)
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credited_invoice_id: invoice.id,
reason,
}),
})
if (!response.ok) {
// Map the parsed body plus the status, never `new Error(data.error)`:
// the route answers thrown errors with the canonical envelope
// `{ error: { code, message } }`, and the Error constructor would
// stringify that object to "[object Object]", discarding the route's
// own Swedish reason.
const body = await response.json().catch(() => null)
toast({
title: t('create_failed_title'),
description: getUserErrorMessage(body, { statusCode: response.status }),
variant: 'destructive',
})
setIsSubmitting(false)
return
}
const { data: creditNote } = await response.json() as { data: InvoiceWithRelations }
toast({
title: t('created_toast_title'),
description: creditNote.invoice_number
? t('created_toast_description', { number: creditNote.invoice_number })
: undefined,
})
setCreatedCreditNote(creditNote)
setShowSendPrompt(true)
} catch (error) {
toast({
title: t('create_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('try_again'),
variant: 'destructive',
})
}
setIsSubmitting(false)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (!invoice) {
return null
}
const customer = invoice.customer
const sendMode = getCreditNoteSendMode({
customerHasEmail: !!createdCreditNote?.customer.email,
isSandbox,
canEmail,
})
function handleSendPromptOpenChange(open: boolean) {
setShowSendPrompt(open)
if (!open && createdCreditNote) {
router.push(`/invoices/${createdCreditNote.id}`)
}
}
return (
<div className="space-y-6 max-w-3xl mx-auto">
{createdCreditNote && (
<SendInvoiceDialog
open={showSendPrompt}
onOpenChange={handleSendPromptOpenChange}
invoice={createdCreditNote}
mode={sendMode}
onSuccess={() => undefined}
/>
)}
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">
{t('subtitle', { number: invoice.invoice_number ?? '' })}
</p>
</div>
</div>
{/* Warning */}
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-start gap-4 pt-6">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">{t('warning_title')}</p>
<p className="text-sm text-muted-foreground mt-1">
{t('warning_description')}
</p>
</div>
</CardContent>
</Card>
{/* Original invoice info */}
<Card>
<CardHeader>
<CardTitle>{t('original_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{t('invoice_number_label')}</span>
<span className="ml-2 font-medium">{invoice.invoice_number}</span>
</div>
<div>
<span className="text-muted-foreground">{t('date_label')}</span>
<span className="ml-2">{formatDate(invoice.invoice_date)}</span>
</div>
<div>
<span className="text-muted-foreground">{t('customer_label')}</span>
<span className="ml-2">{customer.name}</span>
</div>
<div>
<span className="text-muted-foreground">{t('vat_treatment_label')}</span>
<span className="ml-2">{getVatTreatmentLabel(invoice.vat_treatment)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Credit note preview */}
<Card>
<CardHeader>
<CardTitle>{t('preview_card_title')}</CardTitle>
{/* data-ph-mask: the invoice number is user data */}
<CardDescription data-ph-mask="">
{t('preview_card_description', { number: invoice.invoice_number ?? '' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header */}
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">{t('th_description')}</div>
<div className="col-span-2 text-right">{t('th_quantity')}</div>
<div className="col-span-1 text-center">{t('th_unit')}</div>
<div className="col-span-2 text-right">{t('th_unit_price')}</div>
<div className="col-span-2 text-right">{t('th_amount')}</div>
</div>
{/* Items (negated) */}
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right text-destructive">
-{Math.abs(item.quantity)}
</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
</div>
<div className="col-span-2 text-right font-medium text-destructive">
{formatCurrency(-Math.abs(item.line_total), invoice.currency)}
</div>
</div>
))}
<Separator />
{/* Totals (negated) */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('subtotal')}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.subtotal), invoice.currency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_at_rate', { rate: invoice.vat_rate })}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.vat_amount), invoice.currency)}
</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>{t('total')}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.total), invoice.currency)}
</span>
</div>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>{t('in_sek', { rate: invoice.exchange_rate ?? 1 })}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.total_sek))}
</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Reason */}
<Card>
<CardHeader>
<CardTitle>{t('reason_card_title')}</CardTitle>
<CardDescription>
{t('reason_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="reason">{t('reason_label')}</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('reason_placeholder')}
rows={3}
/>
</div>
</CardContent>
</Card>
{/* Confirmation */}
<Card>
<CardHeader>
<CardTitle>{t('confirm_card_title')}</CardTitle>
<CardDescription>
{t('confirm_card_description_1')}
{/* data-ph-mask: the invoice number is user data */}
<span data-ph-mask="" className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
{t('confirm_card_description_2')}
</CardDescription>
</CardHeader>
<CardContent>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={invoice.invoice_number ?? ''}
disabled={!invoice.invoice_number}
className={cn(
// ph-no-capture: the placeholder carries the invoice number, and
// replay masking covers input values, not attributes.
'ph-no-capture',
confirmText && confirmText !== invoice.invoice_number && 'border-destructive'
)}
/>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => router.back()}>
{t('cancel')}
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={
isSubmitting ||
!invoice.invoice_number ||
confirmText !== invoice.invoice_number ||
!canWrite
}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
{t('create_credit_note')}
</>
) : (
t('create_credit_note')
)}
</Button>
</div>
</div>
)
}