refactor: consolidate extension system to general-only with manifest-driven architecture
- Remove all sector-specific extensions (construction, ecommerce, export, hotel, restaurant, tech) — only general-purpose extensions remain - Move NE-bilaga and SRU export from extensions to core reports (lib/reports/) - Move moms-box-mapping from extensions/export/shared to lib/vat/ - Replace per-extension API routes with catch-all dispatcher (app/api/extensions/ext/[...path]/route.ts) - Add manifest.json for each extension with metadata, env vars, and deps - Add api-routes.ts pattern for extension-defined API endpoints - Add code generation scripts (generate-extension-registry, create-extension) - Add extensions.config.json for opt-in extension loading - Add extensions.schema.json for config validation - Add email service interface with noop default (lib/email/service.ts) - Add CI workflow (core-build.yml) to verify core builds with zero extensions - Add migration 045: expand account_type CHECK for untaxed_reserves - Update CLAUDE.md with comprehensive extension system documentation - Update all report engines and bookkeeping services for new imports - Clean up extensions.schema.json to only list existing extensions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
39e407644d
commit
03b569d708
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { AuditLogEntry, AuditAction } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -22,10 +22,10 @@ export interface AuditLogFilters {
|
||||
* Get paginated audit log entries for a user
|
||||
*/
|
||||
export async function getAuditLog(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
filters: AuditLogFilters = {}
|
||||
): Promise<{ data: AuditLogEntry[]; count: number }> {
|
||||
const supabase = await createClient()
|
||||
const page = filters.page ?? 1
|
||||
const pageSize = filters.pageSize ?? 50
|
||||
const offset = (page - 1) * pageSize
|
||||
@@ -69,11 +69,11 @@ export async function getAuditLog(
|
||||
* Get full history of a single record (all mutations)
|
||||
*/
|
||||
export async function getEntityHistory(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
tableName: string,
|
||||
recordId: string
|
||||
): Promise<AuditLogEntry[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('audit_log')
|
||||
@@ -95,10 +95,10 @@ export async function getEntityHistory(
|
||||
* original → storno (reversal) → corrected entry
|
||||
*/
|
||||
export async function getCorrectionChain(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
journalEntryId: string
|
||||
): Promise<AuditLogEntry[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// First, find the entry and its linked entries
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
|
||||
@@ -29,10 +29,6 @@ function makeClient() {
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { lockPeriod, closePeriod, createNextPeriod } from '../period-service'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -55,7 +51,8 @@ describe('lockPeriod', () => {
|
||||
const handler = vi.fn()
|
||||
eventBus.on('period.locked', handler)
|
||||
|
||||
const result = await lockPeriod('user-1', 'fp-1')
|
||||
const supabase = makeClient()
|
||||
const result = await lockPeriod(supabase as never, 'user-1', 'fp-1')
|
||||
|
||||
expect(result.locked_at).toBeTruthy()
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
@@ -70,7 +67,8 @@ describe('lockPeriod', () => {
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
await expect(lockPeriod('user-1', 'fp-1')).rejects.toThrow('already locked')
|
||||
const supabase = makeClient()
|
||||
await expect(lockPeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow('already locked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,7 +87,8 @@ describe('closePeriod', () => {
|
||||
{ data: closedPeriod, error: null },
|
||||
]
|
||||
|
||||
const result = await closePeriod('user-1', 'fp-1')
|
||||
const supabase = makeClient()
|
||||
const result = await closePeriod(supabase as never, 'user-1', 'fp-1')
|
||||
expect(result.is_closed).toBe(true)
|
||||
})
|
||||
|
||||
@@ -103,7 +102,8 @@ describe('closePeriod', () => {
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow('must be locked')
|
||||
const supabase = makeClient()
|
||||
await expect(closePeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow('must be locked')
|
||||
})
|
||||
|
||||
it('rejects if no closing_entry_id', async () => {
|
||||
@@ -116,7 +116,8 @@ describe('closePeriod', () => {
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow(
|
||||
const supabase = makeClient()
|
||||
await expect(closePeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow(
|
||||
'Year-end closing must be executed'
|
||||
)
|
||||
})
|
||||
@@ -144,7 +145,8 @@ describe('createNextPeriod', () => {
|
||||
{ data: nextPeriod, error: null }, // insert
|
||||
]
|
||||
|
||||
const result = await createNextPeriod('user-1', 'fp-2024')
|
||||
const supabase = makeClient()
|
||||
const result = await createNextPeriod(supabase as never, 'user-1', 'fp-2024')
|
||||
expect(result.period_start).toBe('2025-01-01')
|
||||
expect(result.period_end).toBe('2025-12-31')
|
||||
expect(result.previous_period_id).toBe('fp-2024')
|
||||
@@ -171,7 +173,8 @@ describe('createNextPeriod', () => {
|
||||
{ data: nextPeriod, error: null },
|
||||
]
|
||||
|
||||
const result = await createNextPeriod('user-1', 'fp-2024')
|
||||
const supabase = makeClient()
|
||||
const result = await createNextPeriod(supabase as never, 'user-1', 'fp-2024')
|
||||
expect(result.period_start).toBe('2024-07-01')
|
||||
expect(result.period_end).toBe('2025-06-30')
|
||||
})
|
||||
|
||||
@@ -26,10 +26,6 @@ function makeClient() {
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
validateBalance: vi.fn().mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 }),
|
||||
getNextVoucherNumber: vi.fn(async () => ++resultIdx), // just increment
|
||||
@@ -100,14 +96,16 @@ describe('correctEntry', () => {
|
||||
|
||||
it('creates reversal with swapped debit/credit lines', async () => {
|
||||
setupResults()
|
||||
const result = await correctEntry('user-1', 'orig-1', correctedLines)
|
||||
const supabase = makeClient()
|
||||
const result = await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
|
||||
expect(result.reversal).toBeDefined()
|
||||
expect(result.reversal.reverses_id).toBe('orig-1')
|
||||
})
|
||||
|
||||
it('links original ↔ reversal ↔ corrected via IDs', async () => {
|
||||
setupResults()
|
||||
const result = await correctEntry('user-1', 'orig-1', correctedLines)
|
||||
const supabase = makeClient()
|
||||
const result = await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
|
||||
expect(result.reversal.id).toBe('reversal-1')
|
||||
expect(result.corrected.id).toBe('corrected-1')
|
||||
expect(result.corrected.correction_of_id).toBe('orig-1')
|
||||
@@ -120,8 +118,9 @@ describe('correctEntry', () => {
|
||||
totalCredit: 1000,
|
||||
})
|
||||
|
||||
const supabase = makeClient()
|
||||
await expect(
|
||||
correctEntry('user-1', 'orig-1', [
|
||||
correctEntry(supabase as never, 'user-1', 'orig-1', [
|
||||
{ account_number: '5420', debit_amount: 1200, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
|
||||
])
|
||||
@@ -134,7 +133,8 @@ describe('correctEntry', () => {
|
||||
const handler = vi.fn()
|
||||
eventBus.on('journal_entry.corrected', handler)
|
||||
|
||||
await correctEntry('user-1', 'orig-1', correctedLines)
|
||||
const supabase = makeClient()
|
||||
await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
|
||||
@@ -27,10 +27,6 @@ function makeClient() {
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
@@ -80,7 +76,8 @@ describe('validateYearEndReadiness', () => {
|
||||
totalCredit: 0,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness('user-1', 'fp-1')
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.errors.some((e: string) => e.includes('draft'))).toBe(true)
|
||||
})
|
||||
@@ -101,7 +98,8 @@ describe('validateYearEndReadiness', () => {
|
||||
totalCredit: 9500,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness('user-1', 'fp-1')
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.trialBalanceBalanced).toBe(false)
|
||||
expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
|
||||
@@ -110,17 +108,15 @@ describe('validateYearEndReadiness', () => {
|
||||
it('warns on voucher gaps', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
// Override makeClient to return gaps from rpc
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
// Create a client with custom rpc that returns gap data
|
||||
const builder = makeBuilder()
|
||||
const client = {
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => builder),
|
||||
rpc: vi.fn().mockResolvedValue({
|
||||
data: [{ gap_start: 5, gap_end: 7 }],
|
||||
error: null,
|
||||
}),
|
||||
}
|
||||
vi.mocked(createClient).mockResolvedValue(client as never)
|
||||
|
||||
resultIdx = 0
|
||||
results = [
|
||||
@@ -136,7 +132,7 @@ describe('validateYearEndReadiness', () => {
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness('user-1', 'fp-1')
|
||||
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
|
||||
expect(result.warnings.some((w: string) => w.includes('gap'))).toBe(true)
|
||||
expect(result.voucherGaps).toHaveLength(1)
|
||||
})
|
||||
@@ -164,7 +160,8 @@ describe('previewYearEndClosing', () => {
|
||||
totalCredit: 500000,
|
||||
} as never)
|
||||
|
||||
const preview = await previewYearEndClosing('user-1', 'fp-1')
|
||||
const supabase = makeClient()
|
||||
const preview = await previewYearEndClosing(supabase as never, 'user-1', 'fp-1')
|
||||
|
||||
expect(preview.netResult).toBe(150000)
|
||||
expect(preview.closingAccount).toBe('2099')
|
||||
@@ -189,7 +186,8 @@ describe('previewYearEndClosing', () => {
|
||||
totalCredit: 100000,
|
||||
} as never)
|
||||
|
||||
const preview = await previewYearEndClosing('user-1', 'fp-1')
|
||||
const supabase = makeClient()
|
||||
const preview = await previewYearEndClosing(supabase as never, 'user-1', 'fp-1')
|
||||
|
||||
expect(preview.closingAccount).toBe('2010')
|
||||
expect(preview.closingAccountName).toBe('Eget kapital')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import type { FiscalPeriod, PeriodStatus } from '@/types'
|
||||
@@ -8,10 +8,10 @@ import type { FiscalPeriod, PeriodStatus } from '@/types'
|
||||
* Requires: period exists, belongs to user, not already locked/closed.
|
||||
*/
|
||||
export async function lockPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch period
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
@@ -60,10 +60,10 @@ export async function lockPeriod(
|
||||
* Requires: period is locked AND closing_entry_id is set (year-end must run first).
|
||||
*/
|
||||
export async function closePeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -112,10 +112,10 @@ export async function closePeriod(
|
||||
* Sets previous_period_id for chain validation.
|
||||
*/
|
||||
export async function createNextPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
currentPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -198,10 +198,10 @@ export async function createNextPeriod(
|
||||
* Get status summary for a fiscal period.
|
||||
*/
|
||||
export async function getPeriodStatus(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<PeriodStatus> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type {
|
||||
CreateJournalEntryLineInput,
|
||||
@@ -23,6 +23,7 @@ import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
|
||||
* Returns: { reversal, corrected } - the two new entries created
|
||||
*/
|
||||
export async function correctEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
originalEntryId: string,
|
||||
correctedLines: CreateJournalEntryLineInput[]
|
||||
@@ -35,8 +36,6 @@ export async function correctEntry(
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch original entry with lines
|
||||
const { data: original, error: fetchError } = await supabase
|
||||
.from('journal_entries')
|
||||
@@ -57,6 +56,7 @@ export async function correctEntry(
|
||||
|
||||
// ===== Step 1: Create storno (reversal) entry =====
|
||||
const reversalVoucherNumber = await getNextVoucherNumber(
|
||||
supabase,
|
||||
userId,
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
@@ -146,6 +146,7 @@ export async function correctEntry(
|
||||
|
||||
try {
|
||||
const correctedVoucherNumber = await getNextVoucherNumber(
|
||||
supabase,
|
||||
userId,
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
@@ -19,10 +19,10 @@ import type {
|
||||
* Returns blocking errors and informational warnings.
|
||||
*/
|
||||
export async function validateYearEndReadiness(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<YearEndValidation> {
|
||||
const supabase = await createClient()
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function validateYearEndReadiness(
|
||||
}
|
||||
|
||||
// Check: trial balance is balanced
|
||||
const trialBalance = await generateTrialBalance(userId, fiscalPeriodId)
|
||||
const trialBalance = await generateTrialBalance(supabase, userId, fiscalPeriodId)
|
||||
const trialBalanceBalanced = trialBalance.isBalanced
|
||||
|
||||
if (!trialBalanceBalanced) {
|
||||
@@ -120,10 +120,10 @@ export async function validateYearEndReadiness(
|
||||
* Shows the net result, closing account, and the journal entry lines that would be created.
|
||||
*/
|
||||
export async function previewYearEndClosing(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<YearEndPreview> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get entity type to determine closing account
|
||||
const { data: settings } = await supabase
|
||||
@@ -140,11 +140,11 @@ export async function previewYearEndClosing(
|
||||
: 'Årets resultat'
|
||||
|
||||
// Get income statement for net result
|
||||
const incomeStatement = await generateIncomeStatement(userId, fiscalPeriodId)
|
||||
const incomeStatement = await generateIncomeStatement(supabase, userId, fiscalPeriodId)
|
||||
const netResult = incomeStatement.net_result
|
||||
|
||||
// Get trial balance for individual account balances in class 3-8
|
||||
const { rows } = await generateTrialBalance(userId, fiscalPeriodId)
|
||||
const { rows } = await generateTrialBalance(supabase, userId, fiscalPeriodId)
|
||||
const resultAccounts = rows.filter(
|
||||
(r) => r.account_class >= 3 && r.account_class <= 8
|
||||
)
|
||||
@@ -233,17 +233,16 @@ export async function previewYearEndClosing(
|
||||
* 7. Generate opening balances in next period
|
||||
*/
|
||||
export async function executeYearEndClosing(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<YearEndResult> {
|
||||
// 1. Validate readiness
|
||||
const validation = await validateYearEndReadiness(userId, fiscalPeriodId)
|
||||
const validation = await validateYearEndReadiness(supabase, userId, fiscalPeriodId)
|
||||
if (!validation.ready) {
|
||||
throw new Error(`Year-end closing not ready: ${validation.errors.join('; ')}`)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch the period for dates
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -257,14 +256,14 @@ export async function executeYearEndClosing(
|
||||
}
|
||||
|
||||
// 2. Get closing preview
|
||||
const preview = await previewYearEndClosing(userId, fiscalPeriodId)
|
||||
const preview = await previewYearEndClosing(supabase, userId, fiscalPeriodId)
|
||||
|
||||
if (preview.closingLines.length === 0) {
|
||||
throw new Error('No result accounts to close — period has no activity')
|
||||
}
|
||||
|
||||
// 3. Create closing entry via the journal engine
|
||||
const closingEntry = await createJournalEntry(userId, {
|
||||
const closingEntry = await createJournalEntry(supabase, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: period.period_end,
|
||||
description: `Årsbokslut ${period.name}`,
|
||||
@@ -285,16 +284,17 @@ export async function executeYearEndClosing(
|
||||
}
|
||||
|
||||
// 5. Lock the period
|
||||
await lockPeriod(userId, fiscalPeriodId)
|
||||
await lockPeriod(supabase, userId, fiscalPeriodId)
|
||||
|
||||
// 6. Close the period
|
||||
await closePeriod(userId, fiscalPeriodId)
|
||||
await closePeriod(supabase, userId, fiscalPeriodId)
|
||||
|
||||
// 7. Create next period
|
||||
const nextPeriod = await createNextPeriod(userId, fiscalPeriodId)
|
||||
const nextPeriod = await createNextPeriod(supabase, userId, fiscalPeriodId)
|
||||
|
||||
// 8. Generate opening balances in next period
|
||||
const openingBalanceEntry = await generateOpeningBalances(
|
||||
supabase,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
nextPeriod.id
|
||||
@@ -330,11 +330,11 @@ export async function executeYearEndClosing(
|
||||
* The entry must be balanced (total debit openings = total credit openings).
|
||||
*/
|
||||
export async function generateOpeningBalances(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
closedPeriodId: string,
|
||||
nextPeriodId: string
|
||||
): Promise<JournalEntry> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get next period for the entry date
|
||||
const { data: nextPeriod } = await supabase
|
||||
@@ -349,7 +349,7 @@ export async function generateOpeningBalances(
|
||||
}
|
||||
|
||||
// Get trial balance of closed period (includes the closing entry)
|
||||
const { rows } = await generateTrialBalance(userId, closedPeriodId)
|
||||
const { rows } = await generateTrialBalance(supabase, userId, closedPeriodId)
|
||||
|
||||
// Filter to balance sheet accounts (class 1-2) with non-zero closing balance
|
||||
const balanceSheetAccounts = rows.filter(
|
||||
@@ -397,7 +397,7 @@ export async function generateOpeningBalances(
|
||||
}
|
||||
|
||||
// Create opening balance entry in next period
|
||||
const openingEntry = await createJournalEntry(userId, {
|
||||
const openingEntry = await createJournalEntry(supabase, userId, {
|
||||
fiscal_period_id: nextPeriodId,
|
||||
entry_date: nextPeriod.period_start,
|
||||
description: `Ingående balans ${nextPeriod.name}`,
|
||||
|
||||
@@ -43,13 +43,12 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep createServiceClient mock since ensureDocumentsBucket still uses it
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
createServiceClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -57,8 +56,6 @@ beforeEach(() => {
|
||||
_resetBucketVerified()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
// Reset the mock to use default makeClient
|
||||
vi.mocked(createClient).mockImplementation(async () => makeClient() as never)
|
||||
})
|
||||
|
||||
describe('uploadDocument', () => {
|
||||
@@ -76,8 +73,9 @@ describe('uploadDocument', () => {
|
||||
const handler = vi.fn()
|
||||
eventBus.on('document.uploaded', handler)
|
||||
|
||||
const supabase = makeClient()
|
||||
const buffer = new TextEncoder().encode('test content').buffer
|
||||
const result = await uploadDocument('user-1', {
|
||||
const result = await uploadDocument(supabase as never, 'user-1', {
|
||||
name: 'test.pdf',
|
||||
buffer: buffer as ArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
@@ -115,8 +113,9 @@ describe('createNewVersion', () => {
|
||||
{ data: newVersion, error: null }, // insert new version
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
const buffer = new TextEncoder().encode('new content').buffer
|
||||
const result = await createNewVersion('user-1', 'doc-1', {
|
||||
const result = await createNewVersion(supabase as never, 'user-1', 'doc-1', {
|
||||
name: 'test-v2.pdf',
|
||||
buffer: buffer as ArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
@@ -140,17 +139,15 @@ describe('verifyIntegrity', () => {
|
||||
{ data: { storage_path: 'docs/test.pdf', sha256_hash: expectedHash }, error: null },
|
||||
]
|
||||
|
||||
// Override createClient to provide matching download content
|
||||
vi.mocked(createClient).mockImplementation(async () =>
|
||||
makeClient({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob([content]),
|
||||
error: null,
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
// Create a client with matching download content
|
||||
const supabase = makeClient({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob([content]),
|
||||
error: null,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await verifyIntegrity('user-1', 'doc-1')
|
||||
const result = await verifyIntegrity(supabase as never, 'user-1', 'doc-1')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.storedHash).toBe(expectedHash)
|
||||
expect(result.computedHash).toBe(expectedHash)
|
||||
@@ -161,16 +158,14 @@ describe('verifyIntegrity', () => {
|
||||
{ data: { storage_path: 'docs/test.pdf', sha256_hash: 'stored-hash-abc' }, error: null },
|
||||
]
|
||||
|
||||
vi.mocked(createClient).mockImplementation(async () =>
|
||||
makeClient({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob(['different content']),
|
||||
error: null,
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
const supabase = makeClient({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob(['different content']),
|
||||
error: null,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await verifyIntegrity('user-1', 'doc-1')
|
||||
const result = await verifyIntegrity(supabase as never, 'user-1', 'doc-1')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.storedHash).toBe('stored-hash-abc')
|
||||
expect(result.computedHash).not.toBe('stored-hash-abc')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
|
||||
|
||||
@@ -50,6 +51,7 @@ export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
|
||||
* Upload a document and create a record with SHA-256 integrity hash
|
||||
*/
|
||||
export async function uploadDocument(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type?: string },
|
||||
metadata: {
|
||||
@@ -59,7 +61,6 @@ export async function uploadDocument(
|
||||
} = {}
|
||||
): Promise<DocumentAttachment> {
|
||||
await ensureDocumentsBucket()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
@@ -126,12 +127,12 @@ export async function uploadDocument(
|
||||
* - Single transaction (insert new + mark old superseded)
|
||||
*/
|
||||
export async function createNewVersion(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
originalId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type?: string }
|
||||
): Promise<DocumentAttachment> {
|
||||
await ensureDocumentsBucket()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
@@ -186,12 +187,12 @@ export async function createNewVersion(
|
||||
* Link an existing document to a journal entry
|
||||
*/
|
||||
export async function linkToJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
documentId: string,
|
||||
journalEntryId: string,
|
||||
journalEntryLineId?: string
|
||||
): Promise<DocumentAttachment> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
@@ -215,10 +216,10 @@ export async function linkToJournalEntry(
|
||||
* Verify document integrity by re-hashing and comparing
|
||||
*/
|
||||
export async function verifyIntegrity(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
documentId: string
|
||||
): Promise<{ valid: boolean; storedHash: string; computedHash: string }> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch document record
|
||||
const { data: doc, error: docError } = await supabase
|
||||
|
||||
@@ -26,10 +26,6 @@ function makeClient() {
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { getTaxCodeByCode, calculateMomsFromTaxCodes } from '../tax-code-service'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -50,7 +46,8 @@ describe('getTaxCodeByCode', () => {
|
||||
|
||||
results = [{ data: userCode, error: null }]
|
||||
|
||||
const result = await getTaxCodeByCode('user-1', 'MP1')
|
||||
const supabase = makeClient()
|
||||
const result = await getTaxCodeByCode(supabase as never, 'user-1', 'MP1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.user_id).toBe('user-1')
|
||||
expect(result!.description).toBe('Custom 25% moms')
|
||||
@@ -59,7 +56,8 @@ describe('getTaxCodeByCode', () => {
|
||||
it('returns null when code does not exist', async () => {
|
||||
results = [{ data: null, error: { code: 'PGRST116' } }]
|
||||
|
||||
const result = await getTaxCodeByCode('user-1', 'NONEXISTENT')
|
||||
const supabase = makeClient()
|
||||
const result = await getTaxCodeByCode(supabase as never, 'user-1', 'NONEXISTENT')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -96,7 +94,8 @@ describe('calculateMomsFromTaxCodes', () => {
|
||||
{ data: [mp1, ip1], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateMomsFromTaxCodes('user-1', '2024-01-01', '2024-12-31')
|
||||
const supabase = makeClient()
|
||||
const result = await calculateMomsFromTaxCodes(supabase as never, 'user-1', '2024-01-01', '2024-12-31')
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// Results should be sorted by box
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { TaxCode } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -12,8 +12,7 @@ import type { TaxCode } from '@/types'
|
||||
/**
|
||||
* Get all active tax codes for a user (including system codes)
|
||||
*/
|
||||
export async function getTaxCodes(userId: string): Promise<TaxCode[]> {
|
||||
const supabase = await createClient()
|
||||
export async function getTaxCodes(supabase: SupabaseClient, userId: string): Promise<TaxCode[]> {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tax_codes')
|
||||
@@ -32,10 +31,10 @@ export async function getTaxCodes(userId: string): Promise<TaxCode[]> {
|
||||
* Get a single tax code by code string
|
||||
*/
|
||||
export async function getTaxCodeByCode(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
code: string
|
||||
): Promise<TaxCode | null> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Prefer user-specific code over system code
|
||||
const { data, error } = await supabase
|
||||
@@ -72,11 +71,11 @@ export interface MomsBoxResult {
|
||||
* category-based VAT calculation.
|
||||
*/
|
||||
export async function calculateMomsFromTaxCodes(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): Promise<MomsBoxResult[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch journal entry lines with tax_code in the period
|
||||
const { data: lines, error: linesError } = await supabase
|
||||
@@ -104,7 +103,7 @@ export async function calculateMomsFromTaxCodes(
|
||||
}
|
||||
|
||||
// Fetch all tax codes for lookup
|
||||
const taxCodes = await getTaxCodes(userId)
|
||||
const taxCodes = await getTaxCodes(supabase, userId)
|
||||
const taxCodeMap = new Map<string, TaxCode>()
|
||||
for (const tc of taxCodes) {
|
||||
// User codes take precedence over system codes
|
||||
@@ -154,8 +153,7 @@ export async function calculateMomsFromTaxCodes(
|
||||
/**
|
||||
* Seed tax codes for a user by calling the database function
|
||||
*/
|
||||
export async function seedTaxCodes(userId: string): Promise<void> {
|
||||
const supabase = await createClient()
|
||||
export async function seedTaxCodes(supabase: SupabaseClient, userId: string): Promise<void> {
|
||||
|
||||
const { error } = await supabase.rpc('seed_tax_codes_for_user', {
|
||||
p_user_id: userId,
|
||||
|
||||
Reference in New Issue
Block a user