Fix/transaction inconsitensies (#190)

* fix: request explicit page size from Enable Banking API to fetch all transactions

The API defaults to ~10 transactions per page when no limit is specified,
causing incomplete syncs for users with more transactions.


* fix: enhance DELETE operations and add missing RLS policies for multi-tenant support
This commit is contained in:
Mattsson
2026-04-08 11:25:46 +02:00
committed by GitHub
parent 3dd1f782c3
commit 6486e0d9e2
10 changed files with 381 additions and 45 deletions
+6 -2
View File
@@ -174,9 +174,9 @@ export async function DELETE(
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
const { error, count } = await supabase
.from('customers')
.delete()
.delete({ count: 'exact' })
.eq('id', id)
.eq('company_id', companyId)
@@ -184,5 +184,9 @@ export async function DELETE(
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (count === 0) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
}
+6 -2
View File
@@ -141,9 +141,9 @@ export async function DELETE(
)
}
const { error } = await supabase
const { error, count: deleteCount } = await supabase
.from('suppliers')
.delete()
.delete({ count: 'exact' })
.eq('id', id)
.eq('company_id', companyId)
@@ -151,5 +151,9 @@ export async function DELETE(
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (deleteCount === 0) {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
}
@@ -44,12 +44,19 @@ const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 't
{ id: 'briox', name: 'Briox', authType: 'token' },
]
interface SkipReasons {
duplicate?: number
inactive?: number
failed?: number
noMatch?: number
}
interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; skipped: number }
suppliers?: { total: number; imported: number; skipped: number }
salesInvoices?: { total: number; imported: number; skipped: number }
supplierInvoices?: { total: number; imported: number; skipped: number }
customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
}
import AccountMappingStep from '@/components/import/AccountMappingStep'
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
@@ -120,6 +127,7 @@ interface PreviewData {
transactionCount: number
fiscalYears: number[]
} | null
hasSieData: boolean
}
interface SIEFileStatus {
@@ -476,6 +484,7 @@ function ConnectStep({
<Input
id="apiToken"
type="password"
autoComplete="off"
placeholder={provider === 'briox' ? 'Klistra in din applikationstoken' : 'Klistra in din API-nyckel'}
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
@@ -489,6 +498,7 @@ function ConnectStep({
</label>
<Input
id="companyId"
autoComplete="off"
placeholder={isClientCredentials ? 'GUID från företagsinställningar' : 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'}
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
@@ -584,17 +594,36 @@ function PreviewStep({
</div>
)}
{preview && !preview.sieAvailable && !isLoading && (
<div className="flex gap-3 rounded-lg border border-warning/30 bg-warning/10 p-4">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-warning-foreground" />
{preview && !preview.sieAvailable && !isLoading && preview.hasSieData && (
<div className="flex gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-4">
<CheckCircle className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
<div>
<p className="text-sm font-medium text-warning-foreground">SIE-hämtning inte tillgänglig</p>
<p className="text-sm font-medium">SIE-data redan importerad</p>
<p className="text-xs text-muted-foreground">
SIE-hämtning är inte tillgänglig för denna leverantör ännu. Du kan importera SIE-filen manuellt via <Link href="/import?mode=sie" className="underline hover:text-foreground">SIE-importen</Link>.
Bokföringsdata har redan importerats via SIE-fil. Du kan fortsätta med att importera kunder, leverantörer och fakturor.
</p>
</div>
</div>
)}
{preview && !preview.sieAvailable && !isLoading && !preview.hasSieData && (
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">SIE-import krävs</p>
<p className="text-xs text-muted-foreground">
Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil innan kunder, leverantörer och fakturor kan hämtas. Exportera en SIE-fil från {ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? 'ditt bokföringssystem'} och ladda upp den i gnubok.
</p>
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href="/import?mode=sie">
<BookOpen className="mr-2 h-4 w-4" />
Gå till SIE-importen
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</Link>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
@@ -603,7 +632,7 @@ function PreviewStep({
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={isLoading}>
<Button className="min-h-11" onClick={onContinue} disabled={isLoading || (!!preview && !preview.sieAvailable && !preview.hasSieData)}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
@@ -817,15 +846,15 @@ function OptionsStep({
/>
<OptionRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor (öppna)"
description="Obetalda kundfakturor"
label="Kundfakturor"
description="Alla kundfakturor (betalda och obetalda)"
checked={options.importSalesInvoices}
onChange={() => toggleOption('importSalesInvoices')}
/>
<OptionRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor (öppna)"
description="Obetalda leverantörsfakturor"
label="Leverantörsfakturor"
description="Alla leverantörsfakturor (betalda och obetalda)"
checked={options.importSupplierInvoices}
onChange={() => toggleOption('importSupplierInvoices')}
/>
@@ -1292,7 +1321,7 @@ function ResultStep({
label="Kunder"
status="success"
statusText={`${results.customers!.imported} importerade`}
detail={results.customers!.skipped > 0 ? `${results.customers!.skipped} fanns redan` : undefined}
detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer') ?? `${results.customers!.skipped} hoppades över` : undefined}
/>
)}
{hasSuppliers && (
@@ -1301,7 +1330,7 @@ function ResultStep({
label="Leverantörer"
status="success"
statusText={`${results.suppliers!.imported} importerade`}
detail={results.suppliers!.skipped > 0 ? `${results.suppliers!.skipped} fanns redan` : undefined}
detail={results.suppliers!.skipped > 0 ? formatSkipReasons(results.suppliers!.skipReasons, 'supplier') ?? `${results.suppliers!.skipped} hoppades över` : undefined}
/>
)}
{hasSalesInvoices && (
@@ -1310,7 +1339,7 @@ function ResultStep({
label="Kundfakturor"
status="success"
statusText={`${results.salesInvoices!.imported} importerade`}
detail={results.salesInvoices!.skipped > 0 ? `${results.salesInvoices!.skipped} hoppades över` : undefined}
detail={results.salesInvoices!.skipped > 0 ? formatSkipReasons(results.salesInvoices!.skipReasons, 'invoice') ?? `${results.salesInvoices!.skipped} hoppades över` : undefined}
/>
)}
{hasSupplierInvoices && (
@@ -1319,7 +1348,7 @@ function ResultStep({
label="Leverantörsfakturor"
status="success"
statusText={`${results.supplierInvoices!.imported} importerade`}
detail={results.supplierInvoices!.skipped > 0 ? `${results.supplierInvoices!.skipped} hoppades över` : undefined}
detail={results.supplierInvoices!.skipped > 0 ? formatSkipReasons(results.supplierInvoices!.skipReasons, 'invoice') ?? `${results.supplierInvoices!.skipped} hoppades över` : undefined}
/>
)}
</div>
@@ -1387,6 +1416,19 @@ function ResultStep({
)
}
function formatSkipReasons(reasons?: SkipReasons, entityType?: 'customer' | 'supplier' | 'invoice'): string | undefined {
if (!reasons) return undefined
const parts: string[] = []
if (reasons.duplicate) parts.push(`${reasons.duplicate} fanns redan`)
if (reasons.inactive) parts.push(`${reasons.inactive} inaktiv${reasons.inactive > 1 ? 'a' : ''}`)
if (reasons.noMatch) {
const matchLabel = entityType === 'invoice' ? 'utan matchning' : 'utan matchning'
parts.push(`${reasons.noMatch} ${matchLabel}`)
}
if (reasons.failed) parts.push(`${reasons.failed} misslyckades`)
return parts.length > 0 ? parts.join(', ') : undefined
}
/** Simple row for non-SIE entity results (customers, invoices, etc.) */
function EntityResultRow({
icon,
@@ -452,6 +452,13 @@ export const arcimMigrationExtension: Extension = {
}
}
// Check if the company already has completed SIE imports (from manual upload)
const { count: sieImportCount } = await supabase
.from('sie_imports')
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'completed')
return NextResponse.json({
consent: {
id: consent.id,
@@ -462,6 +469,7 @@ export const arcimMigrationExtension: Extension = {
companyInfo: mapped,
sieAvailable,
sieStats,
hasSieData: (sieImportCount ?? 0) > 0,
})
} catch (error) {
log.error('Preview error:', error)
@@ -8,12 +8,12 @@
* 1. Company info → pre-fill company_settings
* 2. Customers → needed before sales invoices
* 3. Suppliers → needed before supplier invoices
* 4. Sales invoices (open only)
* 5. Supplier invoices (open only)
* 4. Sales invoices (all statuses, duplicates skipped)
* 5. Supplier invoices (all statuses, duplicates skipped)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { MigrationProgress, MigrationResults } from '../types'
import type { MigrationProgress, MigrationResults, SkipReasons } from '../types'
import type { ProviderName } from '@/lib/providers/types'
import { resolveConsent } from '@/lib/providers/resolve-consent'
import {
@@ -111,10 +111,12 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const customers = await fetchCustomersDirect(provider, accessToken, providerCompanyId)
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
for (const customer of customers) {
if (!customer.active) {
console.log(`[migration] Customer skipped (inactive): ${customer.party.name}`)
skipReasons.inactive = (skipReasons.inactive ?? 0) + 1
skipped++
continue
}
@@ -132,6 +134,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (existing && existing.length > 0) {
console.log(`[migration] Customer skipped (duplicate org_number ${orgNumber}): ${customer.party.name}`)
customerIdMap.set(customer.id, existing[0].id)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
@@ -146,6 +149,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (error || !inserted) {
console.error(`[migration] Customer insert failed: ${customer.party.name}`, error?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
} else {
customerIdMap.set(customer.id, inserted.id)
@@ -153,7 +157,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.customers = { total: customers.length, imported, skipped }
results.customers = { total: customers.length, imported, skipped, skipReasons }
} catch (err) {
console.error('Failed to import customers:', err)
}
@@ -168,10 +172,12 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const suppliers = await fetchSuppliersDirect(provider, accessToken, providerCompanyId)
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
for (const supplier of suppliers) {
if (!supplier.active) {
console.log(`[migration] Supplier skipped (inactive): ${supplier.party.name}`)
skipReasons.inactive = (skipReasons.inactive ?? 0) + 1
skipped++
continue
}
@@ -189,6 +195,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (existing && existing.length > 0) {
console.log(`[migration] Supplier skipped (duplicate org_number ${orgNumber}): ${supplier.party.name}`)
supplierIdMap.set(supplier.id, existing[0].id)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
@@ -203,6 +210,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (error || !inserted) {
console.error(`[migration] Supplier insert failed: ${supplier.party.name}`, error?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
} else {
supplierIdMap.set(supplier.id, inserted.id)
@@ -210,26 +218,24 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
results.suppliers = { total: suppliers.length, imported, skipped }
results.suppliers = { total: suppliers.length, imported, skipped, skipReasons }
} catch (err) {
console.error('Failed to import suppliers:', err)
}
}
// ── Step 4: Sales invoices (open/unpaid only) ─────────────────
// ── Step 4: Sales invoices ────────────────────────────────────
if (options.importSalesInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar kundfakturor...', progress: 60 })
try {
const invoices = await fetchSalesInvoicesDirect(provider, accessToken, providerCompanyId)
const openInvoices = invoices.filter(i =>
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked'
)
console.log(`[migration] Sales invoices: ${invoices.length} total, ${openInvoices.length} open (filtered by status: sent/overdue/booked)`)
console.log(`[migration] Sales invoices: ${invoices.length} total`)
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
for (const inv of openInvoices) {
for (const inv of invoices) {
const customerOrgNumber = inv.customer.legalEntity?.companyId ||
inv.customer.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
@@ -280,6 +286,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (!customerId) {
console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — no customer match for "${inv.customer.name}" (org: ${customerOrgNumber || 'n/a'})`)
skipReasons.noMatch = (skipReasons.noMatch ?? 0) + 1
skipped++
continue
}
@@ -293,6 +300,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (existingInv && existingInv.length > 0) {
console.log(`[migration] Sales invoice ${inv.invoiceNumber} skipped — already exists`)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
@@ -307,6 +315,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (invError || !insertedInv) {
console.error(`[migration] Sales invoice ${inv.invoiceNumber} insert failed:`, invError?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
continue
}
@@ -322,26 +331,24 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
imported++
}
results.salesInvoices = { total: openInvoices.length, imported, skipped }
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons }
} catch (err) {
console.error('Failed to import sales invoices:', err)
}
}
// ── Step 5: Supplier invoices (open/unpaid only) ──────────────
// ── Step 5: Supplier invoices ─────────────────────────────────
if (options.importSupplierInvoices !== false) {
emitProgress(options, { status: 'importing', currentStep: 'Importerar leverantörsfakturor...', progress: 80 })
try {
const invoices = await fetchSupplierInvoicesDirect(provider, accessToken, providerCompanyId)
const openInvoices = invoices.filter(i =>
i.status === 'sent' || i.status === 'overdue' || i.status === 'booked' || i.status === 'draft'
)
console.log(`[migration] Supplier invoices: ${invoices.length} total, ${openInvoices.length} open (filtered by status: sent/overdue/booked/draft)`)
console.log(`[migration] Supplier invoices: ${invoices.length} total`)
let imported = 0
let skipped = 0
const skipReasons: SkipReasons = {}
for (const inv of openInvoices) {
for (const inv of invoices) {
const supplierOrgNumber = inv.supplier.legalEntity?.companyId ||
inv.supplier.identifications?.find(i => i.schemeId === 'SE:ORGNR')?.id
@@ -392,6 +399,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (!supplierId) {
console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — no supplier match for "${inv.supplier.name}" (org: ${supplierOrgNumber || 'n/a'})`)
skipReasons.noMatch = (skipReasons.noMatch ?? 0) + 1
skipped++
continue
}
@@ -406,6 +414,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (existingInv && existingInv.length > 0) {
console.log(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — already exists for supplier "${inv.supplier.name}"`)
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
skipped++
continue
}
@@ -418,6 +427,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (arrivalError || arrivalNum == null) {
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not get arrival number:`, arrivalError?.message)
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
continue
}
@@ -432,6 +442,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (invError || !insertedInv) {
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} insert failed for "${inv.supplier.name}":`, invError?.message, JSON.stringify(mappedInvoice, null, 2))
skipReasons.failed = (skipReasons.failed ?? 0) + 1
skipped++
continue
}
@@ -447,7 +458,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
imported++
}
results.supplierInvoices = { total: openInvoices.length, imported, skipped }
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons }
} catch (err) {
console.error('Failed to import supplier invoices:', err)
}
+11 -4
View File
@@ -52,12 +52,19 @@ export interface MigrationProgress {
error?: string
}
export interface SkipReasons {
duplicate?: number
inactive?: number
failed?: number
noMatch?: number
}
export interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; skipped: number }
suppliers?: { total: number; imported: number; skipped: number }
salesInvoices?: { total: number; imported: number; skipped: number }
supplierInvoices?: { total: number; imported: number; skipped: number }
customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
}
// ── Consent flow ────────────────────────────────────────────────────
@@ -147,6 +147,7 @@ const FETCH_TIMEOUT_MS = 15_000
const MAX_RETRIES = 2
const RETRY_DELAY_MS = 1000
const MAX_PAGINATION_PAGES = 100
const DEFAULT_PAGE_SIZE = 500
// API Helper
@@ -467,6 +468,7 @@ export async function getAccountTransactions(
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (continuationKey) params.set('continuation_key', continuationKey)
params.set('limit', String(DEFAULT_PAGE_SIZE))
const queryString = params.toString()
const endpoint = `/accounts/${accountUid}/transactions${queryString ? `?${queryString}` : ''}`
@@ -542,6 +544,7 @@ export async function getAllTransactionsWithRaw(
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (continuationKey) params.set('continuation_key', continuationKey)
params.set('limit', String(DEFAULT_PAGE_SIZE))
const queryString = params.toString()
const endpoint = `/accounts/${accountUid}/transactions${queryString ? `?${queryString}` : ''}`
+16
View File
@@ -3,6 +3,8 @@ import type { BokioResourceConfig, RateLimitConfig } from '../types';
import {
mapBokioToSalesInvoice,
mapBokioToCustomer,
mapBokioToSupplier,
mapBokioToSupplierInvoice,
mapBokioToJournal,
mapBokioToAccountingAccount,
mapBokioToCompanyInformation,
@@ -26,6 +28,20 @@ export const BOKIO_RESOURCE_CONFIGS: Partial<Record<ResourceType, BokioResourceC
mapper: mapBokioToCustomer,
paginated: true,
},
[ResourceType.Suppliers]: {
listEndpoint: '/suppliers',
detailEndpoint: '/suppliers/{id}',
idField: 'id',
mapper: mapBokioToSupplier,
paginated: true,
},
[ResourceType.SupplierInvoices]: {
listEndpoint: '/supplier-invoices',
detailEndpoint: '/supplier-invoices/{id}',
idField: 'id',
mapper: mapBokioToSupplierInvoice,
paginated: true,
},
[ResourceType.Journals]: {
listEndpoint: '/journal-entries',
detailEndpoint: '/journal-entries/{id}',
+106
View File
@@ -2,6 +2,8 @@ import type {
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
CustomerDto,
SupplierDto,
SupplierInvoiceDto, SupplierInvoiceLineDto,
JournalDto, AccountingEntryDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
@@ -195,6 +197,110 @@ export function mapBokioToAccountingAccount(raw: Record<string, unknown>): Accou
};
}
/**
* Map Bokio Supplier to SupplierDto.
*
* Bokio Supplier fields:
* - id, name, orgNumber, vatNumber, paymentTerms
* - address: { line1, line2, city, postalCode, country }
* - contactsDetails: [{ email, phone, name }]
* - bankAccount, bankgiro, plusgiro
*/
export function mapBokioToSupplier(raw: Record<string, unknown>): SupplierDto {
const name = (raw['name'] as string) ?? '';
const orgNumber = raw['orgNumber'] as string | undefined;
const address = raw['address'] as Record<string, unknown> | undefined;
const contacts = (raw['contactsDetails'] as Record<string, unknown>[] | undefined) ?? [];
const firstContact = contacts[0];
const party = buildParty(name, orgNumber, address);
if (firstContact) {
party.contact = {
email: firstContact['email'] as string | undefined,
telephone: firstContact['phone'] as string | undefined,
name: firstContact['name'] as string | undefined,
};
}
return {
id: String(raw['id'] ?? ''),
supplierNumber: String(raw['id'] ?? ''),
party,
active: true,
vatNumber: raw['vatNumber'] as string | undefined,
bankAccount: raw['bankAccount'] as string | undefined,
bankGiro: raw['bankgiro'] as string | undefined,
plusGiro: raw['plusgiro'] as string | undefined,
defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
_raw: raw,
};
}
/**
* Map Bokio Supplier Invoice to SupplierInvoiceDto.
*
* Bokio Supplier Invoice fields:
* - id, invoiceNumber, status (draft|published|paid|overdue|cancelled)
* - invoiceDate, dueDate, currency, totalAmount, totalTax, paidAmount
* - supplierRef: { id, name }, lineItems: [{ id, description, quantity, unitPrice, taxRate, unitType }]
* - ocrNumber
*/
export function mapBokioToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['currency'] as string) ?? 'SEK';
const totalAmount = (raw['totalAmount'] as number) ?? 0;
const totalTax = (raw['totalTax'] as number) ?? 0;
const paidAmount = (raw['paidAmount'] as number) ?? 0;
const balance = totalAmount - paidAmount;
const supplierRef = raw['supplierRef'] as Record<string, unknown> | undefined;
const rawLines = (raw['lineItems'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SupplierInvoiceLineDto[] = rawLines.map((line, idx) => {
const unitPrice = line['unitPrice'] as number | undefined;
const quantity = line['quantity'] as number | undefined;
const lineTotal = unitPrice != null && quantity != null ? unitPrice * quantity : 0;
return {
id: String(line['id'] ?? idx + 1),
description: line['description'] as string | undefined,
quantity,
unitCode: line['unitType'] as string | undefined,
unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineTotal, currency),
taxPercent: line['taxRate'] as number | undefined,
};
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalAmount - totalTax, currency),
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
const paymentStatus: PaymentStatusDto = {
paid: paidAmount >= totalAmount && totalAmount > 0,
balance: amount(balance, currency),
};
return {
id: String(raw['id'] ?? ''),
invoiceNumber: String(raw['invoiceNumber'] ?? raw['id'] ?? ''),
issueDate: (raw['invoiceDate'] as string) ?? '',
dueDate: raw['dueDate'] as string | undefined,
currencyCode: currency,
status: deriveInvoiceStatus(raw),
supplier: buildParty(
(supplierRef?.['name'] as string) ?? '',
),
buyer: buildParty(''),
lines,
legalMonetaryTotal,
paymentStatus,
ocrNumber: raw['ocrNumber'] as string | undefined,
_raw: raw,
};
}
/**
* Map Bokio Company to CompanyInformationDto.
*
@@ -0,0 +1,135 @@
-- =============================================================================
-- Add missing DELETE RLS policies
-- =============================================================================
-- The multi-tenant migration (20260330130000) dropped all existing policies
-- but only recreated DELETE policies for company_members and api_keys.
-- This migration adds the missing DELETE policies for all tables that need them.
-- =============================================================================
-- Direct company_id tables
CREATE POLICY "customers_delete" ON public.customers
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "suppliers_delete" ON public.suppliers
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoices_delete" ON public.invoices
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_reminders_delete" ON public.invoice_reminders
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_payments_delete" ON public.invoice_payments
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoices_delete" ON public.supplier_invoices
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoice_payments_delete" ON public.supplier_invoice_payments
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "transactions_delete" ON public.transactions
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_connections_delete" ON public.bank_connections
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_file_imports_delete" ON public.bank_file_imports
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "receipts_delete" ON public.receipts
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "company_settings_delete" ON public.company_settings
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chart_of_accounts_delete" ON public.chart_of_accounts
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "fiscal_periods_delete" ON public.fiscal_periods
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "journal_entries_delete" ON public.journal_entries
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "mapping_rules_delete" ON public.mapping_rules
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "categorization_templates_delete" ON public.categorization_templates
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "deadlines_delete" ON public.deadlines
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "cost_centers_delete" ON public.cost_centers
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "projects_delete" ON public.projects
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "calendar_feeds_delete" ON public.calendar_feeds
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "extension_data_delete" ON public.extension_data
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "skatteverket_tokens_delete" ON public.skatteverket_tokens
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "document_attachments_delete" ON public.document_attachments
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_inbox_items_delete" ON public.invoice_inbox_items
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_imports_delete" ON public.sie_imports
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_account_mappings_delete" ON public.sie_account_mappings
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_sessions_delete" ON public.chat_sessions
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_messages_delete" ON public.chat_messages
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
-- Conditional tables (may not exist in all environments)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'salary_payments') THEN
EXECUTE 'CREATE POLICY "salary_payments_delete" ON public.salary_payments FOR DELETE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'mileage_entries') THEN
EXECUTE 'CREATE POLICY "mileage_entries_delete" ON public.mileage_entries FOR DELETE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'account_balances') THEN
EXECUTE 'CREATE POLICY "account_balances_delete" ON public.account_balances FOR DELETE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
-- Sub-tables using parent join (same pattern as their SELECT/INSERT/UPDATE policies)
CREATE POLICY "invoice_items_delete" ON public.invoice_items
FOR DELETE USING (
invoice_id IN (SELECT id FROM public.invoices WHERE company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "journal_entry_lines_delete" ON public.journal_entry_lines
FOR DELETE USING (
journal_entry_id IN (SELECT id FROM public.journal_entries WHERE company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "receipt_line_items_delete" ON public.receipt_line_items
FOR DELETE USING (
receipt_id IN (SELECT id FROM public.receipts WHERE company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "supplier_invoice_items_delete" ON public.supplier_invoice_items
FOR DELETE USING (
supplier_invoice_id IN (SELECT id FROM public.supplier_invoices WHERE company_id IN (SELECT public.user_company_ids()))
);