chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)

* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:51:16 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 18cbc4c30a
commit f266c386f3
359 changed files with 1796 additions and 11199 deletions
@@ -8,13 +8,9 @@
import type {
ArcimProvider,
ConsentRecord,
OtcResponse,
PaginatedResponse,
CompanyInformationDto,
CustomerDto,
SupplierDto,
SalesInvoiceDto,
SupplierInvoiceDto,
} from '../types'
function getBaseUrl(): string {
@@ -105,70 +101,6 @@ export async function getConsent(consentId: string): Promise<ConsentRecord> {
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
}
export async function generateOtc(
consentId: string,
expiresInMinutes: number = 60
): Promise<OtcResponse> {
return request<OtcResponse>(`/api/v1/consents/${consentId}/otc`, {
method: 'POST',
body: JSON.stringify({ expiresInMinutes }),
})
}
export async function deleteConsent(consentId: string): Promise<void> {
await request(`/api/v1/consents/${consentId}`, { method: 'DELETE' })
}
// ── OAuth helpers ───────────────────────────────────────────────────
export async function getAuthUrl(
provider: ArcimProvider,
state?: string,
redirectUri?: string
): Promise<{ url: string }> {
const params = new URLSearchParams()
if (state) params.set('state', state)
if (redirectUri) params.set('redirectUri', redirectUri)
const qs = params.toString()
return request<{ url: string }>(`/api/v1/auth/${provider}/url${qs ? `?${qs}` : ''}`)
}
export async function exchangeAuthToken(
consentId: string,
provider: ArcimProvider,
otcCode: string,
oauthCode: string,
redirectUri?: string
): Promise<{ success: boolean; consentId: string }> {
return request(`/api/v1/auth/${provider}/callback`, {
method: 'POST',
body: JSON.stringify({
code: oauthCode,
consentId,
otcCode,
...(redirectUri ? { redirectUri } : {}),
}),
})
}
// ── Token-based auth (Bokio, Björn Lundén, Briox) ──────────────────
export async function submitProviderToken(
consentId: string,
provider: ArcimProvider,
apiToken: string,
companyId?: string
): Promise<{ success: boolean; consentId: string }> {
return request(`/api/v1/auth/${provider}/callback`, {
method: 'POST',
body: JSON.stringify({
code: apiToken,
consentId,
...(companyId ? { companyId } : {}),
}),
})
}
// ── Resource fetching (paginated) ───────────────────────────────────
async function fetchAllPages<T>(
@@ -215,24 +147,6 @@ export async function fetchCustomers(consentId: string): Promise<CustomerDto[]>
return fetchAllPages<CustomerDto>(consentId, 'customers')
}
export async function fetchSuppliers(consentId: string): Promise<SupplierDto[]> {
return fetchAllPages<SupplierDto>(consentId, 'suppliers')
}
export async function fetchSalesInvoices(
consentId: string,
params?: Record<string, string>
): Promise<SalesInvoiceDto[]> {
return fetchAllPages<SalesInvoiceDto>(consentId, 'salesinvoices', params)
}
export async function fetchSupplierInvoices(
consentId: string,
params?: Record<string, string>
): Promise<SupplierInvoiceDto[]> {
return fetchAllPages<SupplierInvoiceDto>(consentId, 'supplierinvoices', params)
}
// The gateway SIE export path (fetchSIEExport/SIEExportFile) was deliberately
// removed: it returned SIE as a pre-decoded string, and the gateway's decode of
// CP437 bytes as windows-1252 caused the 2026-03-17 mojibake incident. Provider
@@ -18,6 +18,7 @@
* entities completes in a handful of Supabase requests per step.
*/
import { chunk } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { MigrationProgress, MigrationResults, MigrationStepError, SkipReasons } from '../types'
import type { ProviderName } from '@/lib/providers/types'
@@ -90,12 +91,6 @@ function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
options.onProgress?.(progress)
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
/**
* Codes that doom every remaining provider call in this run: dead/expired
* grant, missing integration license, API module not activated. Continuing
@@ -99,13 +99,7 @@ export class ProviderCompanyMismatchError extends Error {
// Re-export data fetching functions from the provider layer
export { resolveConsent } from '@/lib/providers/resolve-consent'
export {
fetchCompanyInfoDirect,
fetchCustomersDirect,
fetchSuppliersDirect,
fetchSalesInvoicesDirect,
fetchSupplierInvoicesDirect,
} from '@/lib/providers/provider-data-fetcher'
export { fetchCompanyInfoDirect } from '@/lib/providers/provider-data-fetcher'
// ── Consent lifecycle (direct Supabase) ─────────────────────────────
+2 -15
View File
@@ -7,30 +7,17 @@
import type { HydrationReport } from '@/lib/providers/provider-data-fetcher'
// Re-export canonical DTOs used by entity-mapper and migration-orchestrator
// Re-export the canonical DTOs used by arcim-client (entity-mapper imports
// its DTOs straight from '@/lib/providers/dto')
export type {
AmountType,
PostalAddress,
Contact,
PartyIdentification,
PartyLegalEntity,
PartyDto,
PaginatedResponse,
TaxSubtotalDto,
TaxTotalDto,
LegalMonetaryTotalDto,
PaymentStatusDto,
CompanyInformationDto,
CustomerDto,
SupplierDto,
InvoiceStatusCode,
SalesInvoiceLineDto,
SalesInvoiceDto,
SupplierInvoiceLineDto,
SupplierInvoiceDto,
} from '@/lib/providers/dto'
export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
// ── Supported providers ─────────────────────────────────────────────
-8
View File
@@ -90,14 +90,6 @@ export interface KontrolleraSvar {
// ---- inlamning (v2.1) ------------------------------------------------------
export interface InlamningBegaran {
undertecknare: string
epostadresser: string[]
kvittensepostadresser?: string[]
notifieringEpostadresser?: string[]
handling: { fil: string; typ: HandlingTyp }
}
export interface InlamningSvar {
orgnr: string
avsandare: string
@@ -1,108 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Invoice } from '@/types'
import { calculatePeriodSummary, SWEDISH_MONTHS } from '@/lib/calendar/utils'
import { formatCurrency } from '@/lib/utils'
import { TrendingUp, Clock, CheckCircle, AlertTriangle } from 'lucide-react'
interface PaymentSummaryCardProps {
invoices: Invoice[]
year: number
month: number
}
export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCardProps) {
const summary = calculatePeriodSummary(invoices)
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">
Sammanfattning {SWEDISH_MONTHS[month]} {year}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Expected */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Förväntat</p>
<p className="text-sm font-medium">{summary.pendingCount} fakturor</p>
</div>
</div>
<p className="text-lg font-semibold">{formatCurrency(summary.totalExpected)}</p>
</div>
{/* Overdue */}
{summary.overdueCount > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Förfallen</p>
<p className="text-sm font-medium text-destructive">
{summary.overdueCount} fakturor
</p>
</div>
</div>
<p className="text-lg font-semibold text-destructive">
{formatCurrency(summary.totalOverdue)}
</p>
</div>
)}
{/* Paid */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Betald</p>
<p className="text-sm font-medium">{summary.paidCount} fakturor</p>
</div>
</div>
<p className="text-lg font-semibold text-success">
{formatCurrency(summary.totalPaid)}
</p>
</div>
{/* Pending */}
{summary.pendingCount > 0 && summary.overdueCount < summary.pendingCount && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Väntande</p>
<p className="text-sm font-medium">
{summary.pendingCount - summary.overdueCount} fakturor
</p>
</div>
</div>
<p className="text-lg font-semibold">
{formatCurrency(summary.totalExpected - summary.totalOverdue)}
</p>
</div>
)}
{/* Foreign-currency invoices without a stored SEK conversion are
excluded from the sums above rather than silently mixed in. */}
{summary.unconvertedCount > 0 && (
<p className="text-xs text-warning">
{summary.unconvertedCount === 1
? '1 faktura i utländsk valuta utan växelkurs ingår inte i beloppen.'
: `${summary.unconvertedCount} fakturor i utländsk valuta utan växelkurs ingår inte i beloppen.`}
</p>
)}
</CardContent>
</Card>
)
}
@@ -15,6 +15,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
import { escapeHtml } from '@/lib/email/user-text'
const log = createLogger('cloud-backup-alert')
@@ -127,11 +128,3 @@ async function fetchCompanyName(
return (data?.company_name as string) || 'ditt företag'
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -18,8 +18,6 @@ import type {
CloudLastSync,
} from '../types'
export { ROOT_FOLDER_NAME } from './folder-names'
/**
* Google Drive's storage keys, re-exported for the call sites that predate
* multi-provider support. Provider-aware code reads `provider.keys` instead:
@@ -27,7 +25,6 @@ export { ROOT_FOLDER_NAME } from './folder-names'
*/
export const CONNECTION_KEY = 'google_drive_connection'
export const LAST_SYNC_KEY = 'google_drive_last_sync'
export const SCHEDULE_KEY = 'google_drive_schedule'
/**
* Per-FILE ceiling, not per-backup: the backup splits into one archive per
* räkenskapsår plus Grunddata.zip, and uploads are resumable/chunked. The
-3
View File
@@ -75,9 +75,6 @@ export interface CloudFileState {
uploaded_at: string
}
/** @deprecated Use {@link CloudFileState}. Kept for existing call sites. */
export type DriveFileState = CloudFileState
/**
* Last-sync snapshot stored under key `google_drive_last_sync` /
* `dropbox_last_sync`.
@@ -145,15 +145,6 @@ export interface TransactionsResponse {
*/
export type TransactionsFetchStrategy = 'default' | 'longest'
// Legacy types for backward compatibility
export interface Bank {
id: string
name: string
bic?: string
countries: string[]
logo_url?: string
}
export interface BankTransaction {
id: string
date: string
@@ -456,32 +447,6 @@ export async function getPreferredAuthMethod(
return method?.name
}
/**
* Get list of supported banks (legacy format for backward compatibility)
*/
export async function getSupportedBanks(): Promise<Bank[]> {
try {
const aspsps = await getASPSPs('SE')
return aspsps.map((aspsp) => ({
id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-se`,
name: aspsp.name,
bic: aspsp.bic,
countries: [aspsp.country],
logo_url: aspsp.logo,
}))
} catch (error) {
console.error('Error fetching banks:', error)
// Return fallback list
return [
{ id: 'nordea-se', name: 'Nordea', bic: 'NDEASESS', countries: ['SE'] },
{ id: 'seb-se', name: 'SEB', bic: 'ESSESESS', countries: ['SE'] },
{ id: 'swedbank-se', name: 'Swedbank', bic: 'SWEDSESS', countries: ['SE'] },
{ id: 'handelsbanken-se', name: 'Handelsbanken', bic: 'HANDSESS', countries: ['SE'] },
]
}
}
/**
* Start bank authorization flow
*
@@ -1171,19 +1136,6 @@ export function convertTransaction(tx: Transaction, accountCurrency: string): Ba
}
}
/**
* Get transactions in legacy format
*/
export async function getTransactions(
accountUid: string,
fromDate?: string,
toDate?: string,
accountCurrency: string = 'SEK'
): Promise<BankTransaction[]> {
const transactions = await getAllTransactions(accountUid, fromDate, toDate)
return transactions.map(tx => convertTransaction(tx, accountCurrency))
}
/**
* Whether the current configuration targets the sandbox API
*/
+1 -5
View File
@@ -7,8 +7,4 @@
* it here so its own imports and tests are unchanged. The re-import direction
* (extension -> lib) is the allowed one.
*/
export {
generateJWT,
getAuthorizationHeader,
_resetTokenCache,
} from '@/lib/connect/upstreams/enable-banking-jwt'
export { getAuthorizationHeader, _resetTokenCache } from '@/lib/connect/upstreams/enable-banking-jwt'
+3 -15
View File
@@ -42,18 +42,6 @@ export interface StoredAccount {
deselected_elsewhere?: boolean
}
// Re-export API types from the client
export type {
ASPSP,
AuthMethod,
AuthResponse,
SessionResponse,
AccountInfo,
Balance,
BalanceResponse,
Transaction as EnableBankingTransaction,
TransactionsResponse,
TransactionsFetchStrategy,
Bank,
BankTransaction,
} from './lib/api-client'
// Re-exported from the client for lib/sync.ts; every other api-client type
// is imported from './lib/api-client' directly.
export type { TransactionsFetchStrategy } from './lib/api-client'
@@ -1,8 +1,6 @@
import { Resend } from 'resend'
import type { EmailReceivedEvent, GetReceivingEmailResponseSuccess, WebhookEventPayload } from 'resend'
export type ResendInboundEvent = EmailReceivedEvent
export type ResendReceivedEmail = GetReceivingEmailResponseSuccess
export interface ResendAttachmentDownload {
@@ -12,6 +12,7 @@ import { matchSupplierId } from '@/lib/suppliers/match-supplier'
import type { InvoiceExtractionResult } from '@/types'
import { PDFDocument } from 'pdf-lib'
import path from 'node:path'
import { escapeHtml } from '@/lib/email/user-text'
// Verdicts decidable before touching the file. `ai_unconfigured` is the
// deployment-level "no AI backend" state (self-host without a key), distinct
@@ -139,15 +140,6 @@ export const UPLOAD_ALLOWED_MIME_TYPES = new Set([
// inbound webhook, where the preview surfaces render it fully sandboxed.
export const EMAIL_ALLOWED_MIME_TYPES = new Set([...UPLOAD_ALLOWED_MIME_TYPES, 'text/html'])
function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Wrap HTML in a minimal document shell unless it already is a full document.
* Mail bodies are usually fragments (a bare <div>/<table>); the document
@@ -34,13 +34,13 @@ export function isOptionalCompanyTool(toolName: string): boolean {
return OPTIONAL_COMPANY_TOOLS.has(toolName)
}
export const COMPANY_ID_INPUT_PROPERTY = {
const COMPANY_ID_INPUT_PROPERTY = {
type: 'string',
format: 'uuid',
description: 'Target company ID. Omit for default.',
} as const
export interface McpCompanyContext {
interface McpCompanyContext {
companyId: string
role: CompanyRole
isDefault: boolean
File diff suppressed because it is too large Load Diff
@@ -26,11 +26,6 @@ export const workflowSkills: Skill[] = [
onboardingSkill,
]
/** @deprecated Use `workflowSkills` for the static set, or `loadAllSkills(supabase)`
* for the unified list (workflows + atoms). Kept for backwards compatibility
* with prior imports. */
export const skills = workflowSkills
/**
* Resolve a skill by slug. Checks the static workflow array first (synchronous,
* always available), then falls back to the registry-backed atom set
@@ -1,386 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { createClient } from '@/lib/supabase/client'
import { Bell, BellOff, Loader2, Mail, Moon } from 'lucide-react'
import type { NotificationSettings as NotificationSettingsType } from '@/types'
interface NotificationSettingsProps {
onSettingsChange?: () => void
}
export function NotificationSettings({ onSettingsChange }: NotificationSettingsProps) {
const { toast } = useToast()
const supabase = createClient()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<NotificationSettingsType | null>(null)
const [isPushSupported, setIsPushSupported] = useState(false)
const [isPushSubscribed, setIsPushSubscribed] = useState(false)
useEffect(() => {
fetchSettings()
checkPushSupport()
}, [])
const checkPushSupport = async () => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsPushSupported(true)
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsPushSubscribed(!!subscription)
} catch (error) {
console.error('Error checking push subscription:', error)
}
}
}
const fetchSettings = async () => {
setIsLoading(true)
// Notification settings are per user, not per company: quiet hours and the
// per-category toggles belong to the person, and push_subscriptions are
// keyed on a device endpoint. Neither table carries a company_id.
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
setIsLoading(false)
return
}
const { data, error } = await supabase
.from('notification_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (!error && data) {
setSettings(data)
} else {
// Create default settings if not found
const { data: newSettings, error: insertError } = await supabase
.from('notification_settings')
.insert({
user_id: user.id,
tax_deadlines_enabled: true,
invoice_reminders_enabled: true,
push_enabled: true,
email_enabled: true,
quiet_start: '21:00',
quiet_end: '08:00',
})
.select()
.single()
if (!insertError && newSettings) {
setSettings(newSettings)
}
}
setIsLoading(false)
}
const updateSetting = async (key: keyof NotificationSettingsType, value: boolean | string) => {
if (!settings) return
setIsSaving(true)
const { error } = await supabase
.from('notification_settings')
.update({ [key]: value })
.eq('id', settings.id)
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte spara inställning',
variant: 'destructive',
})
} else {
setSettings({ ...settings, [key]: value })
onSettingsChange?.()
}
setIsSaving(false)
}
const subscribeToPush = async () => {
try {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
toast({
title: 'Aviseringar avslagna',
description: 'Du behöver ge tillåtelse för push-aviseringar i webbläsaren.',
})
return
}
const vapidResponse = await fetch('/api/extensions/ext/push-notifications/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
throw new Error('Push not configured')
}
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
const response = await fetch('/api/extensions/ext/push-notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
})
if (!response.ok) {
throw new Error('Failed to save subscription')
}
setIsPushSubscribed(true)
toast({
title: 'Push-aviseringar aktiverade',
description: 'Du kommer nu få push-aviseringar.',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte aktivera push-aviseringar.',
variant: 'destructive',
})
}
}
const unsubscribeFromPush = async () => {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
await fetch('/api/extensions/ext/push-notifications/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
})
}
setIsPushSubscribed(false)
toast({
title: 'Push-aviseringar inaktiverade',
description: 'Du kommer inte längre få push-aviseringar.',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte inaktivera push-aviseringar.',
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
if (!settings) {
return null
}
return (
<div className="space-y-6">
{/* Push notification status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Push-aviseringar
</CardTitle>
<CardDescription>
Få påminnelser direkt i din webbläsare
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isPushSupported ? (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">
{isPushSubscribed ? 'Aktiverade' : 'Inaktiverade'}
</p>
<p className="text-sm text-muted-foreground">
{isPushSubscribed
? 'Du får push-aviseringar på denna enhet'
: 'Aktivera för att få push-aviseringar'}
</p>
</div>
<Button
variant={isPushSubscribed ? 'outline' : 'default'}
onClick={isPushSubscribed ? unsubscribeFromPush : subscribeToPush}
>
{isPushSubscribed ? (
<>
<BellOff className="mr-2 h-4 w-4" />
Inaktivera
</>
) : (
<>
<Bell className="mr-2 h-4 w-4" />
Aktivera
</>
)}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">
Din webbläsare stöder inte push-aviseringar.
</p>
)}
</CardContent>
</Card>
{/* Category settings */}
<Card>
<CardHeader>
<CardTitle>Aviseringskategorier</CardTitle>
<CardDescription>
Välj vilka typer av aviseringar du vill få
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="tax-deadlines">Skattedeadlines</Label>
<p className="text-sm text-muted-foreground">
Moms, F-skatt, inkomstdeklaration m.m.
</p>
</div>
<Switch
id="tax-deadlines"
checked={settings.tax_deadlines_enabled}
onCheckedChange={(checked) =>
updateSetting('tax_deadlines_enabled', checked)
}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="invoice-reminders">Fakturapåminnelser</Label>
<p className="text-sm text-muted-foreground">
Fakturor som förfaller eller är försenade
</p>
</div>
<Switch
id="invoice-reminders"
checked={settings.invoice_reminders_enabled}
onCheckedChange={(checked) =>
updateSetting('invoice_reminders_enabled', checked)
}
disabled={isSaving}
/>
</div>
</CardContent>
</Card>
{/* Email digest */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
E-post
</CardTitle>
<CardDescription>
Sammanfattningar via e-post
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="email-digest">Nytt att bokföra</Label>
<p className="text-sm text-muted-foreground">
Dagligt mejl när nya banktransaktioner eller underlag har kommit in
</p>
</div>
<Switch
id="email-digest"
checked={settings.email_digest_enabled}
onCheckedChange={(checked) =>
updateSetting('email_digest_enabled', checked)
}
disabled={isSaving}
/>
</div>
</CardContent>
</Card>
{/* Quiet hours */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Moon className="h-5 w-5" />
Tysta timmar
</CardTitle>
<CardDescription>
Inga aviseringar skickas under dessa timmar
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="quiet-start">Från</Label>
<Input
id="quiet-start"
type="time"
value={settings.quiet_start}
onChange={(e) => updateSetting('quiet_start', e.target.value)}
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="quiet-end">Till</Label>
<Input
id="quiet-end"
type="time"
value={settings.quiet_end}
onChange={(e) => updateSetting('quiet_end', e.target.value)}
disabled={isSaving}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Standard: 21:00 - 08:00 (Sveriges tid)
</p>
</CardContent>
</Card>
</div>
)
}
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray.buffer
}
@@ -1,211 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Bell, BellOff, Loader2 } from 'lucide-react'
interface PushPromptProps {
onSubscribed?: () => void
onDismissed?: () => void
}
export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
const { toast } = useToast()
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isSupported, setIsSupported] = useState(false)
const [isSubscribed, setIsSubscribed] = useState(false)
useEffect(() => {
// Check if push notifications are supported
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
checkSubscription()
}
}, [])
const checkSubscription = async () => {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
} catch (error) {
console.error('Error checking push subscription:', error)
}
}
const requestPermission = async () => {
if (!isSupported) return
setIsLoading(true)
try {
// Request notification permission
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
toast({
title: 'Aviseringar avslagna',
description: 'Du kan aktivera aviseringar senare i inställningarna.',
})
setIsOpen(false)
onDismissed?.()
return
}
// Get VAPID public key
const vapidResponse = await fetch('/api/extensions/ext/push-notifications/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
throw new Error('Push notifications not configured')
}
// Subscribe to push notifications
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
// Send subscription to server
const response = await fetch('/api/extensions/ext/push-notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
})
if (!response.ok) {
throw new Error('Failed to save subscription')
}
setIsSubscribed(true)
setIsOpen(false)
toast({
title: 'Aviseringar aktiverade',
description: 'Du kommer få påminnelser om viktiga deadlines.',
})
onSubscribed?.()
} catch (error) {
console.error('Error subscribing to push:', error)
toast({
title: 'Fel',
description: 'Kunde inte aktivera aviseringar. Försök igen senare.',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
const handleDismiss = () => {
setIsOpen(false)
// Store dismissal in localStorage to not show again
localStorage.setItem('push-prompt-dismissed', 'true')
onDismissed?.()
}
// Don't show if not supported, already subscribed, or previously dismissed
if (!isSupported || isSubscribed) {
return null
}
// Show dialog after a delay or when triggered
return (
<>
{/* Floating button to show prompt */}
<Button
variant="outline"
size="sm"
className="fixed bottom-4 right-4 gap-2 shadow-lg z-50"
onClick={() => setIsOpen(true)}
>
<Bell className="h-4 w-4" />
Aktivera aviseringar
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
Aktivera push-aviseringar
</DialogTitle>
<DialogDescription>
Få påminnelser om viktiga skattedeadlines, fakturor som förfaller och
kampanjleveranser direkt i din webbläsare.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid gap-2 text-sm">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full" />
<span>Skattedeadlines (7 och 1 dag innan)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full" />
<span>Fakturor som förfaller</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full" />
<span>Kampanjleveranser</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Du kan när som helst stänga av aviseringar i inställningarna.
Vi respekterar tysta timmar (21:00-08:00).
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={handleDismiss}>
Inte nu
</Button>
<Button onClick={requestPermission} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Aktiverar...
</>
) : (
<>
<Bell className="mr-2 h-4 w-4" />
Aktivera
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* Convert base64 VAPID key to Uint8Array for subscription
*/
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray.buffer
}
@@ -1,2 +0,0 @@
// Re-export from canonical source: kept for backwards compatibility with extension-internal imports
export type { PushSubscription, NotificationSettings, NotificationType, NotificationLog } from '@/types'
+1 -4
View File
@@ -1,5 +1,6 @@
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
import type { ShopifyOrder, ShopifyShopInfo } from '../types'
import { sleep } from '@/lib/utils'
/**
* Minimal Shopify GraphQL Admin API client for the order feed.
@@ -141,10 +142,6 @@ function asTerminalGuardError(err: unknown): ShopifyApiError | null {
return null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
// safeFetch: public address only (checked now, not at connect time), no
// redirects. A 3xx from the host is a failure, never a hop.
@@ -6,9 +6,11 @@
* because component logic has no tests in this repo.
*/
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { ActionFailure } from '@/lib/browser/action-failure'
import {
panelRequest,
type PanelRequestOptions,
type PanelRequestResult,
} from '@/lib/browser/panel-request'
/**
* Deadline for the quick calls (status, toggle, disconnect). Connect probes
@@ -27,77 +29,14 @@ export const SHOPIFY_CONNECT_TIMEOUT_MS = 120_000
*/
export const SHOPIFY_SYNC_TIMEOUT_MS = 310_000
export type ShopifyRequestResult<T> =
/** 2xx. `data` is null when the body was not readable JSON. */
| { ok: true; data: T | null }
| ActionFailure
export type ShopifyRequestResult<T> = PanelRequestResult<T>
export type ShopifyRequestOptions = PanelRequestOptions
export interface ShopifyRequestOptions {
url: string
method?: 'GET' | 'POST' | 'DELETE'
body?: unknown
locale?: ErrorLocale
timeoutMs?: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** The one sentence for a non-2xx; route copy wins over the generic map. */
export function serverErrorMessage(
body: unknown,
status: number,
locale: ErrorLocale,
): string {
if (isRecord(body)) {
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
return body.error_en.trim()
}
if (typeof body.error === 'string' && body.error.trim()) {
return body.error.trim()
}
}
return getErrorMessage(body, { statusCode: status, locale })
}
export { serverErrorMessage } from '@/lib/browser/panel-request'
/** Call one of the panel's endpoints and report exactly why it failed. */
export async function shopifyRequest<T>({
url,
method = 'POST',
body,
locale = 'sv',
timeoutMs = SHOPIFY_ACTION_TIMEOUT_MS,
}: ShopifyRequestOptions): Promise<ShopifyRequestResult<T>> {
try {
const res = await fetchWithTimeout(
url,
body === undefined
? { method }
: {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
{ timeoutMs, description: `${method} ${url}` },
)
const payload = await res.json().catch(() => null)
if (!res.ok) {
return {
ok: false,
reason: 'server',
status: res.status,
message: serverErrorMessage(payload, res.status, locale),
}
}
return { ok: true, data: payload as T | null }
} catch (err) {
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
}
export function shopifyRequest<T>(options: ShopifyRequestOptions): Promise<ShopifyRequestResult<T>> {
return panelRequest<T>({ timeoutMs: SHOPIFY_ACTION_TIMEOUT_MS, ...options })
}
/** Success body of POST /api/extensions/ext/shopify/sync. */
+1 -2
View File
@@ -1,3 +1,4 @@
import { sleep } from '@/lib/utils'
import crypto from 'crypto'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -2927,8 +2928,6 @@ function skatteverketEnabled(): boolean {
return process.env.SKATTEVERKET_ENABLED === 'true'
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const EXTENSION_DISABLED_RESULT: Extract<SkvSubmitResult, { ok: false }> = {
ok: false,
code: 'EXTENSION_DISABLED',
@@ -313,8 +313,3 @@ export async function findContestedOrgNumbers(): Promise<Set<string>> {
export async function isOrgNumberContested(orgNumber: string): Promise<boolean> {
return (await findContestedOrgNumbers()).has(orgNumber)
}
/** Test hook: reset the memoized service client. */
export function __resetConnectionStoreForTests(): void {
_serviceClient = null
}
@@ -27,6 +27,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
import { escapeHtml } from '@/lib/email/user-text'
const log = createLogger('skv-kvittens-notification')
@@ -173,11 +174,3 @@ async function releaseClaim(
}
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -7,6 +7,7 @@ import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
EntityType,
JournalEntry,
} from '@/types'
import type {
@@ -43,7 +44,7 @@ import type {
const PRIMARY_SEK_SENTINEL = '__PRIMARY_SEK__'
const PRIMARY_SEK_FALLBACK = '1930'
export type EntityType = 'enskild_firma' | 'aktiebolag'
export type { EntityType } from '@/types'
interface SkattekontoRuleRow {
id: string
-29
View File
@@ -68,12 +68,6 @@ export interface SkatteverketInlamnatResponse {
signerare?: string // Personnummer of signer
}
/** Response from fetching decisions */
export interface SkatteverketBeslutatResponse {
beslutsdatum?: string
momsBeslut?: SkatteverketMomsuppgift
}
/** Stored token pair (decrypted form) */
export interface SkatteverketTokens {
access_token: string
@@ -83,13 +77,6 @@ export interface SkatteverketTokens {
scope: string
}
/** Declaration submission status tracking */
export type DeclarationStatus =
| 'draft_saved'
| 'draft_locked'
| 'signed'
| 'decided'
// ── AGI (Arbetsgivardeklaration) types ──────────────────────────
//
// Field shapes mirror the Skatteverket RAMLs in dev_docs/:
@@ -232,20 +219,6 @@ export interface SkatteverketAGIKontrollsvarFel {
felmeddelande?: string
}
export interface SkatteverketSubmission {
id: string
user_id: string
redovisare: string // 12-digit org/personnummer
redovisningsperiod: string // YYYYMM
status: DeclarationStatus
kvittensnummer: string | null
signeringslank: string | null
kontrollresultat: SkatteverketKontrollResultat | null
momsuppgift: SkatteverketMomsuppgift
created_at: string
updated_at: string
}
// ── Skattekonto (tax account) types ────────────────────────────
//
// Field names match Skatteverket's Skattekonto API v2.1.0 JSON schema.
@@ -323,8 +296,6 @@ export interface SkatteverketFel {
// code continues to import from this module for backwards compatibility.
export type {
StoredSkattekontoTransaction,
SkattekontoMatchSuggestion,
SkattekontoBookingSuggestion,
SkattekontoTransactionWithSuggestion,
} from '@/types/skatteverket'
+11 -102
View File
@@ -22,9 +22,11 @@
* one is ever rendered. Every outcome below therefore resolves to one sentence.
*/
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { ActionFailure } from '@/lib/browser/action-failure'
import {
panelRequest,
type PanelRequestOptions,
type PanelRequestResult,
} from '@/lib/browser/panel-request'
/**
* Deadline for the panel's quick calls: the status read, the connect handshake,
@@ -50,107 +52,14 @@ export const STRIPE_ACTION_TIMEOUT_MS = 15_000
*/
export const STRIPE_SYNC_TIMEOUT_MS = 310_000
export type StripeRequestResult<T> =
/** 2xx. `data` is null when the body was not readable JSON. */
| { ok: true; data: T | null }
| ActionFailure
export type StripeRequestResult<T> = PanelRequestResult<T>
export type StripeRequestOptions = PanelRequestOptions
export interface StripeRequestOptions {
url: string
/** Defaults to POST: three of the four calls are mutations. */
method?: 'GET' | 'POST' | 'DELETE'
/** JSON request body. Omitted entirely for the routes that ignore it. */
body?: unknown
/** UI locale, so a server error is reported in the language the user reads. */
locale?: ErrorLocale
timeoutMs?: number
}
export { serverErrorMessage } from '@/lib/browser/panel-request'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/**
* The one sentence for a non-2xx.
*
* The Stripe extension routes (extensions/general/stripe/index.ts) refuse with a
* hand-written Swedish sentence in `{ error }`, and that sentence is the most
* specific thing anyone can say: "Inget anslutet Stripe-konto." tells the user
* to reconnect, where the status map's "Resursen kunde inte hittas." tells them
* nothing. `getErrorMessage` keeps such a sentence only when it happens to carry
* one of its Swedish trigger words, which this route copy does not, so the
* string is preferred explicitly.
*
* `error_en` is honoured first for an English UI because the shared capability
* guard emits both (`capabilityBlockedResponse` in
* lib/entitlements/has-capability.ts), and `getErrorMessage` only reads
* `message_en` inside a structured envelope, not a top-level `error_en`.
*
* Everything else is `getErrorMessage`'s: a structured envelope, an HTML 502
* from the platform, a body that never parsed.
*/
export function serverErrorMessage(
body: unknown,
status: number,
locale: ErrorLocale,
): string {
if (isRecord(body)) {
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
return body.error_en.trim()
}
if (typeof body.error === 'string' && body.error.trim()) {
return body.error.trim()
}
}
return getErrorMessage(body, { statusCode: status, locale })
}
/**
* Call one of the panel's endpoints and report exactly why it failed.
*
* Never throws: every arm resolves to a member of the union, so the call site
* cannot have a silent path again by forgetting a `catch`.
*/
export async function stripeRequest<T>({
url,
method = 'POST',
body,
locale = 'sv',
timeoutMs = STRIPE_ACTION_TIMEOUT_MS,
}: StripeRequestOptions): Promise<StripeRequestResult<T>> {
try {
const res = await fetchWithTimeout(
url,
body === undefined
? { method }
: {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
{ timeoutMs, description: `${method} ${url}` },
)
// Read the body on both arms: the failure arm needs the route's own
// sentence, the success arm needs the sync counts. A body that is not JSON
// (an HTML error page, an empty 502, a response truncated mid-stream) leaves
// null, and neither arm then claims anything it cannot support.
const payload = await res.json().catch(() => null)
if (!res.ok) {
return {
ok: false,
reason: 'server',
status: res.status,
message: serverErrorMessage(payload, res.status, locale),
}
}
return { ok: true, data: payload as T | null }
} catch (err) {
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
}
/** Call one of the panel's endpoints and report exactly why it failed. */
export function stripeRequest<T>(options: StripeRequestOptions): Promise<StripeRequestResult<T>> {
return panelRequest<T>({ timeoutMs: STRIPE_ACTION_TIMEOUT_MS, ...options })
}
/** Success body of POST /api/extensions/ext/stripe/sync. */
@@ -1,3 +1,4 @@
import { chunk as chunked } from '@/lib/utils'
import type Stripe from 'stripe'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getStripe } from '@/lib/stripe/client'
@@ -478,12 +479,6 @@ async function linkBookedPayouts(
return linked
}
function chunked<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
export async function syncStripeBalanceTransactions(
supabase: SupabaseClient,
connection: StripeConnection,
@@ -134,26 +134,3 @@ export interface EnrichmentData {
spar?: SparData
companyRoles?: CompanyRole[]
}
// ---------------------------------------------------------------------------
// Complete endpoint request/response (Accounted internal)
// ---------------------------------------------------------------------------
export interface BankIdCompleteRequest {
sessionId: string
mode: 'login' | 'signup'
email?: string
}
export interface BankIdCompleteResponse {
tokenHash: string
type: string
isNewUser: boolean
enrichmentData?: EnrichmentData
}
export interface BankIdCompleteErrorResponse {
error: 'no_account' | 'already_linked' | 'account_exists' | 'session_invalid' | 'session_expired'
givenName?: string
surname?: string
}
-12
View File
@@ -8,7 +8,6 @@ import type {
TICCompanyPurpose,
TICDocument,
TICFiscalYear,
TICAccountingPeriod,
TICPayrollSummary,
TICSignatory,
TICRepresentatives,
@@ -251,17 +250,6 @@ export async function getFiscalYears(companyId: number): Promise<TICFiscalYear[]
return ticApiFetch<TICFiscalYear[]>(`/companies/${companyId}/fiscal-years`)
}
/**
* Get accounting-period change history for a company. v2 endpoint with
* no v1 equivalent: surfaces "this company has shifted its year-end"
* during onboarding/customer-setup.
*/
export async function getAccountingPeriods(
companyId: number
): Promise<TICAccountingPeriod[] | null> {
return ticApiFetch<TICAccountingPeriod[]>(`/companies/${companyId}/accounting-periods`)
}
/**
* Get payroll summary for a company. v2 endpoint: restructured from
* v1's `/se/payroll`, returns `{ payroll2, payrolls }` where `payroll2`
-15
View File
@@ -264,21 +264,6 @@ export interface TICFiscalYear {
lastUpdatedAtUtc?: string | null
}
/**
* v2 `/companies/{id}/accounting-periods` returns
* `CompanyAccountingPeriod_Dto[]`: history of period-end changes
* (e.g. shifted year-end). Useful as a "this company changed its books"
* indicator during onboarding.
*/
export interface TICAccountingPeriod {
companyAccountingPeriodId?: number
companyId?: number
endingDatePriorToChange?: string | null
endingDateAfterChange?: string | null
firstSeenAtUtc?: string | null
lastUpdatedAtUtc?: string | null
}
/**
* v2 `/companies/{id}/payrolls` returns a wrapper with two arrays.
* `payroll2` is the modern per-period breakdown with deviation vs the
@@ -94,14 +94,7 @@ const STATE_FOR_QUESTION: Record<QuestionType, WhatsAppConversation['state']> =
resend: 'awaiting_resend',
}
// Injectable sleep so tests never wait on real debounce windows.
let sleepFn: (ms: number) => Promise<void> = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms))
/** Test-only: replace (or restore) the debounce sleep implementation. */
export function __setSleepForTests(fn: ((ms: number) => Promise<void>) | null): void {
sleepFn = fn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
}
const sleepFn = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
function fallbackFilename(mime: string): string {
const ext = EXTENSION_FOR_MIME[mime] ?? 'bin'
@@ -1,3 +1,4 @@
import { sleep } from '@/lib/utils'
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
@@ -133,10 +134,6 @@ function storeOriginOf(creds: WooCredentials): string {
return normalized
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function buildUrl(
creds: WooCredentials,
path: string,
@@ -6,9 +6,11 @@
* tests in this repo.
*/
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { ActionFailure } from '@/lib/browser/action-failure'
import {
panelRequest,
type PanelRequestOptions,
type PanelRequestResult,
} from '@/lib/browser/panel-request'
/**
* Deadline for the quick calls (status, toggle, disconnect). Connect and
@@ -27,77 +29,14 @@ export const WOO_CONNECT_TIMEOUT_MS = 120_000
*/
export const WOO_SYNC_TIMEOUT_MS = 310_000
export type WooRequestResult<T> =
/** 2xx. `data` is null when the body was not readable JSON. */
| { ok: true; data: T | null }
| ActionFailure
export type WooRequestResult<T> = PanelRequestResult<T>
export type WooRequestOptions = PanelRequestOptions
export interface WooRequestOptions {
url: string
method?: 'GET' | 'POST' | 'DELETE'
body?: unknown
locale?: ErrorLocale
timeoutMs?: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** The one sentence for a non-2xx; route copy wins over the generic map. */
export function serverErrorMessage(
body: unknown,
status: number,
locale: ErrorLocale,
): string {
if (isRecord(body)) {
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
return body.error_en.trim()
}
if (typeof body.error === 'string' && body.error.trim()) {
return body.error.trim()
}
}
return getErrorMessage(body, { statusCode: status, locale })
}
export { serverErrorMessage } from '@/lib/browser/panel-request'
/** Call one of the panel's endpoints and report exactly why it failed. */
export async function wooRequest<T>({
url,
method = 'POST',
body,
locale = 'sv',
timeoutMs = WOO_ACTION_TIMEOUT_MS,
}: WooRequestOptions): Promise<WooRequestResult<T>> {
try {
const res = await fetchWithTimeout(
url,
body === undefined
? { method }
: {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
{ timeoutMs, description: `${method} ${url}` },
)
const payload = await res.json().catch(() => null)
if (!res.ok) {
return {
ok: false,
reason: 'server',
status: res.status,
message: serverErrorMessage(payload, res.status, locale),
}
}
return { ok: true, data: payload as T | null }
} catch (err) {
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
}
export function wooRequest<T>(options: WooRequestOptions): Promise<WooRequestResult<T>> {
return panelRequest<T>({ timeoutMs: WOO_ACTION_TIMEOUT_MS, ...options })
}
/** Success body of POST /api/extensions/ext/woocommerce/sync. */