f8db38f989
* 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>
405 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|