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:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}` : ''}`
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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()))
|
||||
);
|
||||
Reference in New Issue
Block a user