Bug/template and sandbox (#589)
* Enhance booking template functionality and add sandbox extraction checks * Implement Recapt integration for feedback submission and user identification * Add Recapt identification component and bank sync status chip; update crontab entries * Update .gitignore to ignore the entire scripts directory * Refactor Recapt integration: add loader component, update privacy policy, and enhance bank sync status messages * Fix .gitignore to correctly ignore the scripts directory
This commit is contained in:
+1
-1
@@ -67,4 +67,4 @@ supabase/.temp/
|
||||
# out of the box without running the generator.
|
||||
supabase/.branches/
|
||||
|
||||
scripts\remap-krister-bas96-to-bas2025.ts
|
||||
/scripts
|
||||
@@ -4,6 +4,9 @@ import { headers } from 'next/headers'
|
||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||
import { MainContainer } from '@/components/dashboard/MainContainer'
|
||||
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
|
||||
import { RecaptIdentify } from '@/components/RecaptIdentify'
|
||||
import { RecaptLoader } from '@/components/RecaptLoader'
|
||||
import { RecaptHideWidget } from '@/components/RecaptHideWidget'
|
||||
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
|
||||
import AgentTrigger from '@/components/agent/AgentTrigger'
|
||||
import CommandPalette from '@/components/common/CommandPalette'
|
||||
@@ -278,6 +281,17 @@ export default async function DashboardLayout({
|
||||
<AgentTrigger />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
{!isSandbox && (
|
||||
<>
|
||||
<RecaptLoader />
|
||||
<RecaptHideWidget />
|
||||
<RecaptIdentify
|
||||
userId={user.id}
|
||||
email={user.email}
|
||||
displayName={settings?.company_name || undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AgentSheetProvider>
|
||||
</CompanyProvider>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings
|
||||
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { clearRecaptIdentity } from '@/lib/recapt'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
|
||||
|
||||
@@ -32,6 +33,7 @@ export default function AccountSettingsPage() {
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
async function handleLogout() {
|
||||
clearRecaptIdentity()
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ function BalanceHero({
|
||||
|
||||
{saldo.lastSyncedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Senast synkad{' '}
|
||||
Synkas automatiskt varje natt. Senast synkad{' '}
|
||||
<span className="tabular-nums">
|
||||
{new Date(saldo.lastSyncedAt).toLocaleString('sv-SE')}
|
||||
</span>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ChevronDown, Search, Trash2, X } from 'lucide-react'
|
||||
import TransactionForm from '@/components/transactions/TransactionForm'
|
||||
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
|
||||
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
|
||||
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
|
||||
import InboxZeroState from '@/components/transactions/InboxZeroState'
|
||||
@@ -1513,6 +1514,8 @@ export default function TransactionsPage() {
|
||||
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
|
||||
/>
|
||||
|
||||
<BankSyncStatusChip />
|
||||
|
||||
{/* Search + view dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function PrivacyPolicyPage() {
|
||||
Integritetspolicy
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Senast uppdaterad: 2026-03-05
|
||||
Senast uppdaterad: 2026-05-28
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +124,16 @@ export default function PrivacyPolicyPage() {
|
||||
<td className="py-2 pr-4">USA</td>
|
||||
<td className="py-2">SCCs (standardavtalsklausuler)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">Recapt</td>
|
||||
<td className="py-2 pr-4">
|
||||
Produktanalys och användarfeedback. Laddas endast för
|
||||
inloggade användare (ej sandbox/demo). Överförda
|
||||
uppgifter: användar-ID, e-postadress och företagsnamn.
|
||||
</td>
|
||||
<td className="py-2 pr-4">EU</td>
|
||||
<td className="py-2">SCCs vid eventuella underbiträden utanför EES</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hides Recapt's floating feedback bubble while keeping the SDK active so
|
||||
* `window.recapt('identify', ...)` and programmatic `window.recapt('feedback',
|
||||
* { message })` calls continue to work. Mounted globally in the root layout.
|
||||
*/
|
||||
export function RecaptHideWidget() {
|
||||
useEffect(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 50
|
||||
|
||||
const hide = (): boolean => {
|
||||
if (typeof window.recapt !== 'function') return false
|
||||
try {
|
||||
window.recapt('feedback', { widget: 'hide' })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (hide()) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
attempts++
|
||||
if (hide() || attempts >= maxAttempts) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function RecaptIdentify({
|
||||
userId,
|
||||
email,
|
||||
displayName,
|
||||
}: {
|
||||
userId: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
}) {
|
||||
useEffect(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 50
|
||||
const interval = setInterval(() => {
|
||||
if (typeof window.recapt === 'function') {
|
||||
window.recapt('identify', {
|
||||
uid: userId,
|
||||
email,
|
||||
nickname: displayName,
|
||||
})
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [userId, email, displayName])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import Script from 'next/script'
|
||||
|
||||
/**
|
||||
* Loads the Recapt SDK for authenticated dashboard users only.
|
||||
*
|
||||
* Privacy guard rails:
|
||||
* - Mounted inside the dashboard layout, so the script never loads on
|
||||
* public pages (login, register, privacy policy, marketing). This
|
||||
* prevents pre-consent IP/fingerprint collection on those routes.
|
||||
* - The public key is sourced from NEXT_PUBLIC_RECAPT_PUBLIC_KEY so
|
||||
* hosted and self-hosted deployments can each supply their own key
|
||||
* (or disable Recapt entirely by leaving it unset).
|
||||
* - data-persist / data-enable-user-comments are intentionally omitted
|
||||
* from the default tag. Persistent cross-session tracking and
|
||||
* unstructured free-text capture are opt-in product decisions, not
|
||||
* defaults (GDPR Art. 25 — privacy by default).
|
||||
*/
|
||||
export function RecaptLoader() {
|
||||
const publicKey = process.env.NEXT_PUBLIC_RECAPT_PUBLIC_KEY
|
||||
if (!publicKey) return null
|
||||
|
||||
return (
|
||||
<Script
|
||||
src="https://cdn.recapt.app/browser/glimt.js"
|
||||
strategy="afterInteractive"
|
||||
data-public-key={publicKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { clearRecaptIdentity } from '@/lib/recapt'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -212,6 +213,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
clearRecaptIdentity()
|
||||
await supabase.auth.signOut()
|
||||
router.push(isSandbox ? '/sandbox' : '/login')
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe } from 'lucide-react'
|
||||
import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
|
||||
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
@@ -264,38 +264,43 @@ function TemplateSection({
|
||||
<div className="space-y-1">
|
||||
{templates.map((tt) => {
|
||||
const isExpanded = expandedId === tt.id
|
||||
const isConvertible = convertLibraryToBookingTemplate(tt) !== null
|
||||
return (
|
||||
<div
|
||||
key={tt.id}
|
||||
className="rounded-lg border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : tt.id)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{tt.name}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
</Badge>
|
||||
{tt.entity_type !== 'all' && (
|
||||
<div className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : tt.id)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{tt.name}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{entityLabels[tt.entity_type]}
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
</Badge>
|
||||
)}
|
||||
{tt.entity_type !== 'all' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{entityLabels[tt.entity_type]}
|
||||
</Badge>
|
||||
)}
|
||||
{!isConvertible && (
|
||||
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
|
||||
{t('unconvertible_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(tt.id)
|
||||
}}
|
||||
onClick={() => onDelete(tt.id)}
|
||||
disabled={deletingId === tt.id}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
@@ -306,7 +311,7 @@ function TemplateSection({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
{tt.description && (
|
||||
@@ -369,8 +374,28 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
})
|
||||
}
|
||||
|
||||
function updateLineType(index: number, newType: BookingTemplateLibraryLine['type']) {
|
||||
setLines((prev) => {
|
||||
const updated = [...prev]
|
||||
const current = updated[index]
|
||||
const next: BookingTemplateLibraryLine = { ...current, type: newType }
|
||||
// Auto-pick a sensible default for the type-specific field so the
|
||||
// converter (and applyTemplate) sees a complete line shape.
|
||||
if (newType === 'vat' && next.vat_rate === undefined) {
|
||||
next.vat_rate = 0.25
|
||||
}
|
||||
updated[index] = next
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
// Default new lines to a VAT line — the 2-line template starts with one
|
||||
// business + one settlement, and the natural extension is a VAT leg.
|
||||
// Defaulting to 'business' instead would silently break the converter
|
||||
// (which requires exactly one business line) and the template would
|
||||
// disappear from the transaction picker.
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'business', ratio: 1 }])
|
||||
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'vat', vat_rate: 0.25 }])
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
@@ -378,6 +403,28 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
setLines((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// Real-time check: can this draft be picked from the transaction sheet?
|
||||
// If not, we show a hint — save remains allowed (templates may still be
|
||||
// useful from the journal-entry form).
|
||||
const isConvertible = (() => {
|
||||
const draft: BookingTemplateLibrary = {
|
||||
id: '',
|
||||
company_id: null,
|
||||
team_id: null,
|
||||
created_by: null,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
entity_type: entityType,
|
||||
lines,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
return convertLibraryToBookingTemplate(draft) !== null
|
||||
})()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name || lines.some((l) => !l.account || !l.label)) {
|
||||
@@ -464,7 +511,7 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
<SelectItem value="credit">{t('credit_label')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
|
||||
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
|
||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business">{t('type_cost')}</SelectItem>
|
||||
@@ -472,6 +519,20 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{line.type === 'vat' && (
|
||||
<Select
|
||||
value={String(line.vat_rate ?? 0.25)}
|
||||
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-20" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
|
||||
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
|
||||
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
|
||||
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -491,6 +552,14 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isConvertible && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
|
||||
<p className="text-xs text-warning-foreground leading-snug">
|
||||
{t('unconvertible_hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{t('create_button')}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
|
||||
interface ConnectionRow {
|
||||
id: string
|
||||
status: string | null
|
||||
last_synced_at: string | null
|
||||
}
|
||||
|
||||
function useAgeFormatter() {
|
||||
const t = useTranslations('transactions')
|
||||
return (iso: string): string => {
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
const min = Math.floor(ms / 60000)
|
||||
if (min < 1) return t('bank_sync_age_just_now')
|
||||
if (min < 60) return t('bank_sync_age_minutes', { count: min })
|
||||
const h = Math.floor(min / 60)
|
||||
if (h < 24) return t('bank_sync_age_hours', { count: h })
|
||||
const d = Math.floor(h / 24)
|
||||
return t('bank_sync_age_days', { count: d })
|
||||
}
|
||||
}
|
||||
|
||||
export default function BankSyncStatusChip() {
|
||||
const t = useTranslations('transactions')
|
||||
const formatAge = useAgeFormatter()
|
||||
const { company } = useCompany()
|
||||
const [rows, setRows] = useState<ConnectionRow[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
let cancelled = false
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('bank_connections')
|
||||
.select('id, status, last_synced_at')
|
||||
.eq('company_id', company.id)
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setRows(data ?? [])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company?.id])
|
||||
|
||||
if (!rows || rows.length === 0) return null
|
||||
|
||||
const needsAttention = rows.filter(
|
||||
(r) => r.status === 'expired' || r.status === 'error',
|
||||
)
|
||||
|
||||
if (needsAttention.length > 0) {
|
||||
return (
|
||||
<Link
|
||||
href="/settings/banking"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/5 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{needsAttention.length === 1
|
||||
? t('bank_sync_attention_one')
|
||||
: t('bank_sync_attention_many', { count: needsAttention.length })}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const mostRecent = rows
|
||||
.map((r) => r.last_synced_at)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
.sort()
|
||||
.pop()
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{t('bank_sync_auto_nightly')}
|
||||
{mostRecent && (
|
||||
<>
|
||||
{t('bank_sync_last_separator')}
|
||||
<span className="tabular-nums">{formatAge(mostRecent)}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/ext/enable-banking/sync/cron
|
||||
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
|
||||
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
|
||||
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
|
||||
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
|
||||
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
|
||||
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
|
||||
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
|
||||
@@ -123,6 +123,7 @@ describe('POST /items/:id/retry-extraction', () => {
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
||||
enqueue({
|
||||
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
||||
error: null,
|
||||
@@ -150,6 +151,7 @@ describe('POST /items/:id/retry-extraction', () => {
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
||||
enqueue({
|
||||
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
||||
error: null,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
// Mock the Bedrock call. The whole point of this file is to assert it is
|
||||
// never invoked on sandbox companies.
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@/extensions/general/invoice-inbox/lib/extract-invoice-fields')
|
||||
>('@/extensions/general/invoice-inbox/lib/extract-invoice-fields')
|
||||
return {
|
||||
...actual,
|
||||
extractInvoiceFields: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: vi.fn().mockResolvedValue({ id: 'doc-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/processing-history/append', () => ({
|
||||
appendProcessingHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)!
|
||||
}
|
||||
|
||||
const uploadRoute = findRoute('POST', '/upload')
|
||||
const attachRoute = findRoute('POST', '/items/:id/attach-document')
|
||||
const retryRoute = findRoute('POST', '/items/:id/retry-extraction')
|
||||
|
||||
function buildCtx(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
async function makePdfBuffer(pageCount: number): Promise<Uint8Array> {
|
||||
const pdf = await PDFDocument.create()
|
||||
for (let i = 0; i < pageCount; i++) pdf.addPage([612, 792])
|
||||
return pdf.save()
|
||||
}
|
||||
|
||||
function makeMultipartRequest(form: FormData, path: string): Request {
|
||||
return new Request(`http://localhost:3000${path}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
// Supabase mock routed by table name. The /upload handler hits
|
||||
// company_settings (sandbox check), invoice_inbox_items (insert), and
|
||||
// suppliers (match) in that order via separate .from() chains.
|
||||
function makeUploadSupabase(opts: {
|
||||
isSandbox: boolean
|
||||
captured: { row?: Record<string, unknown> }
|
||||
}) {
|
||||
const settingsChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: { is_sandbox: opts.isSandbox }, error: null }),
|
||||
}
|
||||
const supplierChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
ilike: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null }),
|
||||
}
|
||||
const inboxChain = {
|
||||
insert: vi.fn((row: Record<string, unknown>) => {
|
||||
opts.captured.row = row
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: { id: 'inbox-1', status: 'received', matched_supplier_id: null, ...row },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
}),
|
||||
}
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'company_settings') return settingsChain
|
||||
if (table === 'invoice_inbox_items') return inboxChain
|
||||
return supplierChain
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Sandbox companies skip Bedrock extraction', () => {
|
||||
describe('POST /upload', () => {
|
||||
it('skips extraction and reports skip_reason=sandbox', async () => {
|
||||
const captured: { row?: Record<string, unknown> } = {}
|
||||
const supabase = makeUploadSupabase({ isSandbox: true, captured })
|
||||
|
||||
const bytes = await makePdfBuffer(1)
|
||||
const file = new File([bytes as BlobPart], 'receipt.pdf', { type: 'application/pdf' })
|
||||
const form = new FormData()
|
||||
form.set('file', file)
|
||||
|
||||
const res = await uploadRoute.handler(makeMultipartRequest(form, '/upload'), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(extractInvoiceFields).not.toHaveBeenCalled()
|
||||
expect(body.data.extraction_skipped).toBe(true)
|
||||
expect(body.data.skip_reason).toBe('sandbox')
|
||||
expect(captured.row?.extraction_skipped).toBe(true)
|
||||
})
|
||||
|
||||
it('runs extraction normally for non-sandbox companies', async () => {
|
||||
const captured: { row?: Record<string, unknown> } = {}
|
||||
const supabase = makeUploadSupabase({ isSandbox: false, captured })
|
||||
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
|
||||
data: {
|
||||
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: null, total: null },
|
||||
vatBreakdown: [],
|
||||
confidence: 0,
|
||||
},
|
||||
rawText: 'ok',
|
||||
})
|
||||
|
||||
const bytes = await makePdfBuffer(1)
|
||||
const file = new File([bytes as BlobPart], 'receipt.pdf', { type: 'application/pdf' })
|
||||
const form = new FormData()
|
||||
form.set('file', file)
|
||||
|
||||
const res = await uploadRoute.handler(makeMultipartRequest(form, '/upload'), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(extractInvoiceFields).toHaveBeenCalledOnce()
|
||||
expect(body.data.extraction_skipped).toBe(false)
|
||||
expect(body.data.skip_reason).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /items/:id/attach-document', () => {
|
||||
it('skips extraction when company is a sandbox', async () => {
|
||||
// Lookup order: item exists → upload doc → sandbox check → update row.
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'item-1',
|
||||
document_id: null,
|
||||
status: 'received',
|
||||
correlation_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// sandbox check inside the try block
|
||||
enqueue({ data: { is_sandbox: true }, error: null })
|
||||
// update row
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
// uploadDocument is mocked at the module level; no need to enqueue.
|
||||
|
||||
const bytes = await makePdfBuffer(1)
|
||||
const file = new File([bytes as BlobPart], 'attach.pdf', { type: 'application/pdf' })
|
||||
const form = new FormData()
|
||||
form.set('file', file)
|
||||
|
||||
const req = new Request('http://localhost:3000/items/item-1/attach-document?_id=item-1', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
|
||||
const res = await attachRoute.handler(req, buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(extractInvoiceFields).not.toHaveBeenCalled()
|
||||
expect(body.data.extraction_skipped).toBe(true)
|
||||
expect(body.data.skip_reason).toBe('sandbox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /items/:id/retry-extraction', () => {
|
||||
it('returns 409 with a Swedish message when the company is a sandbox', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// item lookup
|
||||
enqueue({
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
// sandbox check → true
|
||||
enqueue({ data: { is_sandbox: true }, error: null })
|
||||
|
||||
const req = createMockRequest('/items/item-1/retry-extraction', {
|
||||
method: 'POST',
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
|
||||
const res = await retryRoute.handler(req, buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toMatch(/sandlådan/i)
|
||||
expect(extractInvoiceFields).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -69,6 +69,23 @@ async function countPdfPages(buffer: ArrayBuffer): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Sandbox companies (24h anonymous demo accounts) skip the Bedrock extraction
|
||||
// pipeline entirely. The document still uploads, the inbox row still lands,
|
||||
// and the user can fill the fields in by hand — but no Claude tokens are
|
||||
// spent on a throwaway account. See migration 20260311120000 for the column.
|
||||
async function isSandboxCompany(
|
||||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<boolean> {
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error || !data) return false
|
||||
return data.is_sandbox === true
|
||||
}
|
||||
|
||||
// Partial-update schema for the /items/:id/fields PATCH route. Only the
|
||||
// scalar fields the UI exposes for inline editing — line items and
|
||||
// vatBreakdown stay AI-managed for now and are preserved by the merge.
|
||||
@@ -195,12 +212,17 @@ async function uploadAndExtract(
|
||||
file.type === 'application/pdf' ? await countPdfPages(file.buffer) : null
|
||||
const gatedByPageCount =
|
||||
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
|
||||
const skipExtraction = !!opts.skipExtraction || gatedByPageCount
|
||||
const skipReason: 'too_many_pages' | 'client_opt_out' | null = gatedByPageCount
|
||||
? 'too_many_pages'
|
||||
: opts.skipExtraction
|
||||
? 'client_opt_out'
|
||||
: null
|
||||
const sandbox = await isSandboxCompany(supabase, companyId)
|
||||
// Skip-reason priority: sandbox > page-count > client opt-out. Sandbox
|
||||
// wins because it's a hard cost-control rule, not a heuristic.
|
||||
const skipReason: 'too_many_pages' | 'client_opt_out' | 'sandbox' | null = sandbox
|
||||
? 'sandbox'
|
||||
: gatedByPageCount
|
||||
? 'too_many_pages'
|
||||
: opts.skipExtraction
|
||||
? 'client_opt_out'
|
||||
: null
|
||||
const skipExtraction = skipReason !== null
|
||||
|
||||
// Bring-your-own-extraction: skip the Bedrock call entirely and seed an
|
||||
// empty extraction skeleton. The caller is expected to PUT the parsed
|
||||
@@ -772,12 +794,20 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
// Same page-count gate as /upload (issue #553) — attaching a 6-page
|
||||
// sales report to an existing inbox row should not block on Bedrock.
|
||||
// Sandbox companies skip Bedrock unconditionally.
|
||||
const pageCount =
|
||||
file.type === 'application/pdf' ? await countPdfPages(buffer) : null
|
||||
const gatedByPageCount =
|
||||
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
|
||||
const sandbox = await isSandboxCompany(ctx.supabase, ctx.companyId)
|
||||
const skipReason: 'too_many_pages' | 'sandbox' | null = sandbox
|
||||
? 'sandbox'
|
||||
: gatedByPageCount
|
||||
? 'too_many_pages'
|
||||
: null
|
||||
const skipExtraction = skipReason !== null
|
||||
|
||||
const { data: extracted } = gatedByPageCount
|
||||
const { data: extracted } = skipExtraction
|
||||
? { data: emptyResult() }
|
||||
: await extractInvoiceFields({
|
||||
buffer: Buffer.from(buffer),
|
||||
@@ -790,7 +820,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
.update({
|
||||
document_id: doc.id,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
extraction_skipped: gatedByPageCount,
|
||||
extraction_skipped: skipExtraction,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
@@ -827,8 +857,8 @@ export const invoiceInboxExtension: Extension = {
|
||||
document_id: doc.id,
|
||||
inbox_item_id: id,
|
||||
extracted_data: extracted,
|
||||
extraction_skipped: gatedByPageCount,
|
||||
skip_reason: gatedByPageCount ? 'too_many_pages' : null,
|
||||
extraction_skipped: skipExtraction,
|
||||
skip_reason: skipReason,
|
||||
page_count: pageCount,
|
||||
},
|
||||
})
|
||||
@@ -1073,6 +1103,13 @@ export const invoiceInboxExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
if (await isSandboxCompany(ctx.supabase, ctx.companyId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI-tolkning är inte tillgänglig i sandlådan.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: doc } = await ctx.supabase
|
||||
.from('document_attachments')
|
||||
.select('storage_path, mime_type, file_name')
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyTemplate, getTemplateScope, TEMPLATE_CATEGORY_LABELS } from '../template-library'
|
||||
import type { BookingTemplateLibraryLine } from '@/types'
|
||||
import { applyTemplate, convertLibraryToBookingTemplate, getTemplateScope, LIBRARY_TEMPLATE_PREFIX, TEMPLATE_CATEGORY_LABELS } from '../template-library'
|
||||
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
function makeLibraryTemplate(lines: BookingTemplateLibraryLine[], overrides: Partial<BookingTemplateLibrary> = {}): BookingTemplateLibrary {
|
||||
return {
|
||||
id: 'tpl-1',
|
||||
company_id: 'co-1',
|
||||
team_id: null,
|
||||
created_by: 'user-1',
|
||||
name: 'Test template',
|
||||
description: '',
|
||||
category: 'other',
|
||||
entity_type: 'all',
|
||||
lines,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyTemplate', () => {
|
||||
it('creates simple two-line debit/credit entries', () => {
|
||||
@@ -96,3 +115,89 @@ describe('TEMPLATE_CATEGORY_LABELS', () => {
|
||||
expect(TEMPLATE_CATEGORY_LABELS.tax_account).toBe('Skattekonto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertLibraryToBookingTemplate', () => {
|
||||
it('converts a simple 2-line business + settlement template', () => {
|
||||
const tpl = makeLibraryTemplate([
|
||||
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1 },
|
||||
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1 },
|
||||
])
|
||||
const result = convertLibraryToBookingTemplate(tpl)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.id).toBe(`${LIBRARY_TEMPLATE_PREFIX}tpl-1`)
|
||||
expect(result!.direction).toBe('expense')
|
||||
expect(result!.debit_account).toBe('6072')
|
||||
expect(result!.credit_account).toBe('1930')
|
||||
expect(result!.vat_treatment).toBeNull()
|
||||
})
|
||||
|
||||
it('identifies direction "income" when business line is on credit', () => {
|
||||
const tpl = makeLibraryTemplate([
|
||||
{ account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 1 },
|
||||
{ account: '1930', label: 'Företagskonto', side: 'debit', type: 'settlement', ratio: 1 },
|
||||
])
|
||||
const result = convertLibraryToBookingTemplate(tpl)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.direction).toBe('income')
|
||||
expect(result!.debit_account).toBe('1930')
|
||||
expect(result!.credit_account).toBe('3001')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0.25, 'standard_25'],
|
||||
[0.12, 'reduced_12'],
|
||||
[0.06, 'reduced_6'],
|
||||
] as const)('extracts VAT treatment for rate %f', (rate, treatment) => {
|
||||
const tpl = makeLibraryTemplate([
|
||||
{ account: '4010', label: 'Varor', side: 'debit', type: 'business', ratio: 1 },
|
||||
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: rate },
|
||||
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
|
||||
])
|
||||
const result = convertLibraryToBookingTemplate(tpl)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vat_treatment).toBe(treatment)
|
||||
expect(result!.vat_rate).toBe(rate)
|
||||
})
|
||||
|
||||
it('detects reverse charge via 2614 fictitious output VAT', () => {
|
||||
const tpl = makeLibraryTemplate([
|
||||
{ account: '4056', label: 'EU-varor', side: 'debit', type: 'business', ratio: 1 },
|
||||
{ account: '2614', label: 'Utg. moms omv.', side: 'credit', type: 'vat', vat_rate: 0.25 },
|
||||
{ account: '2645', label: 'Ing. moms omv.', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
||||
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
|
||||
])
|
||||
const result = convertLibraryToBookingTemplate(tpl)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vat_treatment).toBe('reverse_charge')
|
||||
})
|
||||
|
||||
it('returns null when there are 2 business lines', () => {
|
||||
const tpl = makeLibraryTemplate([
|
||||
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 0.5 },
|
||||
{ account: '6073', label: 'B', side: 'debit', type: 'business', ratio: 0.5 },
|
||||
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
|
||||
])
|
||||
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is no settlement line', () => {
|
||||
const tpl = makeLibraryTemplate([
|
||||
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
|
||||
{ account: '2641', label: 'Moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
||||
])
|
||||
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when business and settlement are on the same side', () => {
|
||||
const tpl = makeLibraryTemplate([
|
||||
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
|
||||
{ account: '1930', label: 'Bank', side: 'debit', type: 'settlement', ratio: 1 },
|
||||
])
|
||||
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when lines is not an array', () => {
|
||||
const tpl = makeLibraryTemplate([], { lines: null as unknown as BookingTemplateLibraryLine[] })
|
||||
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Recapt's identify SDK keeps the last-known uid in memory and in
|
||||
// localStorage. Passing `uid: undefined` is not a documented logout
|
||||
// signal — on some SDK versions it's coerced to the previous value.
|
||||
// We send an explicit empty-string uid (the SDK's "anonymous" marker),
|
||||
// then clear any persisted Recapt keys from localStorage so the next
|
||||
// pageload doesn't re-identify the logged-out user from cache.
|
||||
export function clearRecaptIdentity(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (typeof window.recapt === 'function') {
|
||||
window.recapt('identify', {
|
||||
uid: '',
|
||||
email: undefined,
|
||||
nickname: undefined,
|
||||
})
|
||||
}
|
||||
// Defense-in-depth: wipe any Recapt-namespaced storage on logout so
|
||||
// a shared device cannot resurrect the previous user's identity on
|
||||
// the next page load.
|
||||
if (typeof window.localStorage !== 'undefined') {
|
||||
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (key && (key.startsWith('recapt') || key.startsWith('glimt'))) {
|
||||
window.localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort — we're already in a logout flow
|
||||
}
|
||||
}
|
||||
@@ -11,19 +11,30 @@ describe('submitFeedback', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function stubRecapt(impl: (...args: unknown[]) => void) {
|
||||
vi.stubGlobal('window', { recapt: impl })
|
||||
}
|
||||
|
||||
function stubNoRecapt() {
|
||||
vi.stubGlobal('window', {})
|
||||
}
|
||||
|
||||
function stubFetchOk() {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
return fetchSpy
|
||||
}
|
||||
|
||||
it('posts subject and message to the contact endpoint', async () => {
|
||||
it('sends to both Recapt and email when SDK is present', async () => {
|
||||
const recapt = vi.fn()
|
||||
stubRecapt(recapt)
|
||||
const fetchSpy = stubFetchOk()
|
||||
|
||||
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(result.channels.sort()).toEqual(['email', 'recapt'])
|
||||
expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' })
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/support/contact',
|
||||
expect.objectContaining({
|
||||
@@ -33,21 +44,57 @@ describe('submitFeedback', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('omits subject when not provided', async () => {
|
||||
const fetchSpy = stubFetchOk()
|
||||
it('omits subject prefix in Recapt payload when subject not provided', async () => {
|
||||
const recapt = vi.fn()
|
||||
stubRecapt(recapt)
|
||||
stubFetchOk()
|
||||
|
||||
const result = await submitFeedback({ message: 'plain' })
|
||||
await submitFeedback({ message: 'plain' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/support/contact',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ message: 'plain' }),
|
||||
})
|
||||
)
|
||||
expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' })
|
||||
})
|
||||
|
||||
it('returns failure with server error message when the endpoint rejects', async () => {
|
||||
it('still reports success via email when Recapt throws', async () => {
|
||||
stubRecapt(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
stubFetchOk()
|
||||
|
||||
const result = await submitFeedback({ subject: 'X', message: 'msg' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
})
|
||||
|
||||
it('uses email only when Recapt SDK is absent', async () => {
|
||||
stubNoRecapt()
|
||||
const fetchSpy = stubFetchOk()
|
||||
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports success when Recapt succeeds even if email fails', async () => {
|
||||
const recapt = vi.fn()
|
||||
stubRecapt(recapt)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) })
|
||||
)
|
||||
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['recapt'])
|
||||
})
|
||||
|
||||
it('returns failure with email error when both channels fail', async () => {
|
||||
stubRecapt(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
@@ -63,7 +110,8 @@ describe('submitFeedback', () => {
|
||||
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
|
||||
})
|
||||
|
||||
it('returns failure when fetch itself throws', async () => {
|
||||
it('returns failure when fetch itself throws and Recapt is absent', async () => {
|
||||
stubNoRecapt()
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
|
||||
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
@@ -3,7 +3,7 @@ export interface SubmitFeedbackInput {
|
||||
subject?: string
|
||||
}
|
||||
|
||||
export type SupportChannel = 'email'
|
||||
export type SupportChannel = 'recapt' | 'email'
|
||||
|
||||
export interface SubmitFeedbackResult {
|
||||
ok: boolean
|
||||
@@ -11,23 +11,58 @@ export interface SubmitFeedbackResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
|
||||
function composeMessage({ message, subject }: SubmitFeedbackInput): string {
|
||||
if (!subject) return message
|
||||
return `[${subject}]\n\n${message}`
|
||||
}
|
||||
|
||||
async function submitViaEmail(
|
||||
{ message, subject }: SubmitFeedbackInput
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/support/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject: input.subject, message: input.message }),
|
||||
body: JSON.stringify({ subject, message }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return { ok: false, channels: [], error: data.error || 'Kunde inte skicka meddelandet' }
|
||||
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
|
||||
}
|
||||
return { ok: true, channels: ['email'] }
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
channels: [],
|
||||
error: err instanceof Error ? err.message : 'Nätverksfel',
|
||||
}
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Nätverksfel' }
|
||||
}
|
||||
}
|
||||
|
||||
function submitViaRecapt(
|
||||
input: SubmitFeedbackInput
|
||||
): { ok: true } | { ok: false; error: string } | null {
|
||||
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
|
||||
if (typeof recapt !== 'function') return null
|
||||
try {
|
||||
recapt('feedback', { message: composeMessage(input) })
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Recapt-fel' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
|
||||
const recaptResult = submitViaRecapt(input)
|
||||
const emailResult = await submitViaEmail(input)
|
||||
|
||||
const channels: SupportChannel[] = []
|
||||
if (recaptResult?.ok) channels.push('recapt')
|
||||
if (emailResult.ok) channels.push('email')
|
||||
|
||||
if (channels.length > 0) {
|
||||
return { ok: true, channels }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
channels: [],
|
||||
error: emailResult.ok ? undefined : emailResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -1288,7 +1288,14 @@
|
||||
"debit_label": "Debit",
|
||||
"credit_label": "Credit",
|
||||
"add_line": "Add line",
|
||||
"create_button": "Create template"
|
||||
"create_button": "Create template",
|
||||
"vat_rate_label": "VAT rate",
|
||||
"vat_rate_25": "25%",
|
||||
"vat_rate_12": "12%",
|
||||
"vat_rate_6": "6%",
|
||||
"vat_rate_0": "0% (export/reverse-charge/exempt)",
|
||||
"unconvertible_hint": "This template can't be picked directly when booking a transaction — it's used from a journal entry. To appear in the transaction list, it needs exactly one cost/revenue line and one settlement line on opposite sides.",
|
||||
"unconvertible_badge": "Journal entries only"
|
||||
},
|
||||
"settings_counterparty_templates": {
|
||||
"title": "Bookkeeping templates",
|
||||
@@ -3167,7 +3174,15 @@
|
||||
"deleted_title": "Deleted",
|
||||
"deleted_description": "The transaction has been deleted",
|
||||
"delete_failed_description": "The transaction could not be deleted. Please try again.",
|
||||
"review_in_bookkeeping_description": "Review and post the journal entry in Bookkeeping."
|
||||
"review_in_bookkeeping_description": "Review and post the journal entry in Bookkeeping.",
|
||||
"bank_sync_attention_one": "1 bank connection needs renewal",
|
||||
"bank_sync_attention_many": "{count} bank connections need renewal",
|
||||
"bank_sync_auto_nightly": "Synced automatically each night",
|
||||
"bank_sync_last_separator": " · last ",
|
||||
"bank_sync_age_just_now": "just now",
|
||||
"bank_sync_age_minutes": "{count} min ago",
|
||||
"bank_sync_age_hours": "{count} h ago",
|
||||
"bank_sync_age_days": "{count} d ago"
|
||||
},
|
||||
"bookkeeping": {
|
||||
"title": "Bookkeeping",
|
||||
|
||||
+17
-2
@@ -1288,7 +1288,14 @@
|
||||
"debit_label": "Debet",
|
||||
"credit_label": "Kredit",
|
||||
"add_line": "Lägg till rad",
|
||||
"create_button": "Skapa mall"
|
||||
"create_button": "Skapa mall",
|
||||
"vat_rate_label": "Momssats",
|
||||
"vat_rate_25": "25 %",
|
||||
"vat_rate_12": "12 %",
|
||||
"vat_rate_6": "6 %",
|
||||
"vat_rate_0": "0 % (export/omvänd/momsfri)",
|
||||
"unconvertible_hint": "Den här mallen kan inte väljas direkt vid bokföring av en transaktion — den används från ett verifikat. För att visas i transaktionslistan krävs exakt en kostnads-/intäktsrad och en betalningsrad på motsatt sida.",
|
||||
"unconvertible_badge": "Endast i verifikat"
|
||||
},
|
||||
"settings_counterparty_templates": {
|
||||
"title": "Bokföringsmallar",
|
||||
@@ -3167,7 +3174,15 @@
|
||||
"deleted_title": "Borttagen",
|
||||
"deleted_description": "Transaktionen har tagits bort",
|
||||
"delete_failed_description": "Transaktionen kunde inte tas bort. Försök igen.",
|
||||
"review_in_bookkeeping_description": "Granska och bokför verifikatet i Bokföring."
|
||||
"review_in_bookkeeping_description": "Granska och bokför verifikatet i Bokföring.",
|
||||
"bank_sync_attention_one": "1 bankanslutning behöver förnyas",
|
||||
"bank_sync_attention_many": "{count} bankanslutningar behöver förnyas",
|
||||
"bank_sync_auto_nightly": "Synkas automatiskt varje natt",
|
||||
"bank_sync_last_separator": " · senast ",
|
||||
"bank_sync_age_just_now": "just nu",
|
||||
"bank_sync_age_minutes": "{count} min sedan",
|
||||
"bank_sync_age_hours": "{count} tim sedan",
|
||||
"bank_sync_age_days": "{count} d sedan"
|
||||
},
|
||||
"bookkeeping": {
|
||||
"title": "Bokföring",
|
||||
|
||||
+7
-2
@@ -11,9 +11,14 @@ const activepiecesUrl = process.env.ACTIVEPIECES_URL ?? "";
|
||||
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com`,
|
||||
// Recapt: scoped to the two specific hosts the SDK actually contacts —
|
||||
// `cdn.recapt.app` for the script bundle and `api.recapt.app` for
|
||||
// ingestion. The previous wildcard (`https://*.recapt.app`) allowed
|
||||
// exfiltration to any subdomain of recapt.app and is intentionally
|
||||
// narrowed.
|
||||
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`,
|
||||
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com`,
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
type RecaptFeedbackPayload =
|
||||
| { message: string; rating?: number }
|
||||
| { widget: 'show' | 'hide' | 'open' | 'close'; position?: string }
|
||||
|
||||
type RecaptIdentifyPayload = {
|
||||
uid: string | undefined
|
||||
email?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
interface RecaptFn {
|
||||
(action: 'feedback', data: RecaptFeedbackPayload): void
|
||||
(action: 'identify', data: RecaptIdentifyPayload): void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Recapt?: unknown
|
||||
recapt?: RecaptFn
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
Reference in New Issue
Block a user