d840257c0c
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login Add chatgpt.com/connector/oauth/* (per-instance) and the legacy chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth redirect allowlist so ChatGPT MCP connectors can register and authorize. Fix the login page dropping the ?next= destination: an OAuth-initiated visit that required login previously ended on the dashboard and the connection flow silently died. Login now resumes to the sanitized next path (hard navigation, since the consent page is route-handler HTML), carries it through the MFA step-up as returnTo, and /mfa/verify hard-navigates for /api/ destinations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): dedup incoming feed rows against booked hand-entered twins Users who bookkeep via MCP/chat first and connect their bank afterwards got the same movement twice: the synced row's external_id lives in a different namespace, the free-form manual title never text-bridges the bank's raw string, and the cross-channel mirror deliberately excluded manual/mcp rows. Extend the mirror with a booked-hand-entered track: an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count- symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be booked (staged rows never consume an import), currencies must not contradict (bucket key is date+ore only), the cash-account guard applies to the count exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming count so an already-stored row cannot inflate it. Consumption stamps the batch cash_account_id onto an account-unbound hand row, so one hand row can never consume feed rows on other accounts in later syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit) Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style: strike lines inside a posted verifikat with replacements in the same voucher, and correct description/entry_date without an andringsverifikat. Envelope: posted entries, open unlocked periods, company lock date, same-period date moves, structural/FX/doc-linked lines excluded, and a reconciliation guard preserving per-account net on bank/reskontra sides of externally linked entries. Every rattelse writes an immutable who/when row (journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and struck originals render struck-through in the verifikat; list rows and the detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the swedish-accounting-compliance skill are amended to state the two-track rule. Staging carries the DDL; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB - Manual journal entry: saldo column now shows before -> after computed from the typed debit/credit amounts (direction feedback while booking) - Resultatrapport: a narrowed date range now compares against the same window shifted one year back (#862), merged across fiscal periods for brutet rakenskapsar; P&L rows report window activity instead of rolled-forward YTD closing - Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab, settings > assistant), sidebar entry unaffected; collapsed sessions keep their reopen handle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): sync balance transactions as a bank feed on 1686 Import the connected Stripe balance into the transactions inbox, opt-in per connection (transaction_sync_enabled on stripe_connections): - Balance transactions map to feed rows with the two-row gross+fee split and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on created, bound to a provisioned "Stripe-saldo" cash account on 1686 so booking settles against the clearing account by construction. - Double-booking protection: settled payment-link charges import pre-linked to their settlement entry; payout rows import pre-linked to the payout entry; processPayoutPaidEvent claims the payout's fee rows at booking time (linkPayoutFeedRows, idempotent from both directions). - Cursor last_balance_txn_synced_at with 24h overlap; first run backfills 90 days floored at the day after the company lock date. - Nightly cron /api/extensions/stripe/transactions/cron (03:30), transaction-sync toggle route, "Synka nu" covers both feeds, settings panel toggle with last-synced/backfill note, sv+en strings. - Migration 20260723200000 (applied to staging). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match-to-voucher on unbooked history rows Unbooked transactions with is_business already set (e.g. left behind when a voucher was removed without a full uncategorize) land in the history list instead of the inbox, where the match-against-existing-voucher action did not exist, leaving them with no path back to voucher matching. Add the same menu item to the history list for unbooked rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transactions): enhance ownership checks and error handling in journal entry routes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
591 lines
24 KiB
TypeScript
591 lines
24 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getPool, withUserContext } from '@/tests/pg/setup'
|
|
import {
|
|
seedCompany,
|
|
insertAuthUser,
|
|
insertCompanyMember,
|
|
insertDraftJournalEntry,
|
|
} from '@/tests/pg/fixtures'
|
|
|
|
// Migration 20260723210000_verifikat_inline_rattelse.sql: the founder-approved
|
|
// inline rättelse of posted verifikat (BFL 5 kap 5 § / 9 §).
|
|
//
|
|
// The mandatory suite:
|
|
// 1. GUC-less UPDATE of description/entry_date on a posted entry stays blocked
|
|
// 2. GUC-less DELETE of a posted line stays blocked
|
|
// 3. under the metadata GUC, any non-description/date column change still raises
|
|
// 4. both RPCs are blocked in closed/locked periods and behind the lock date
|
|
// 5. the effective line set must balance to the öre and keep >= 2 lines
|
|
// 6. every rättelse writes an immutable journal_entry_rattelse_log row
|
|
// 7. the log itself is WORM
|
|
// 8. role gates (viewer/stranger), cross-tenant reach, structural source types
|
|
|
|
async function insertPostedEntry(params: {
|
|
companyId: string
|
|
userId: string
|
|
fiscalPeriodId: string
|
|
entryDate?: string
|
|
voucherNumber?: number
|
|
sourceType?: string
|
|
description?: string
|
|
}): Promise<{ entryId: string; debitLineId: string; creditLineId: string }> {
|
|
const entryId = await insertDraftJournalEntry({
|
|
userId: params.userId,
|
|
companyId: params.companyId,
|
|
fiscalPeriodId: params.fiscalPeriodId,
|
|
sourceType: params.sourceType ?? 'manual',
|
|
status: 'draft',
|
|
voucherNumber: params.voucherNumber ?? 1,
|
|
entryDate: params.entryDate,
|
|
})
|
|
if (params.description) {
|
|
await getPool().query(`UPDATE public.journal_entries SET description = $2 WHERE id = $1`, [
|
|
entryId,
|
|
params.description,
|
|
])
|
|
}
|
|
const { rows: debitRows } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
|
VALUES ($1, '5010', 1000, 0, 1)
|
|
RETURNING id`,
|
|
[entryId],
|
|
)
|
|
const { rows: creditRows } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
|
VALUES ($1, '1930', 0, 1000, 2)
|
|
RETURNING id`,
|
|
[entryId],
|
|
)
|
|
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
|
|
return { entryId, debitLineId: debitRows[0].id, creditLineId: creditRows[0].id }
|
|
}
|
|
|
|
async function insertChartAccount(companyId: string, userId: string, accountNumber: string): Promise<void> {
|
|
await getPool().query(
|
|
`INSERT INTO public.chart_of_accounts
|
|
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance)
|
|
VALUES ($1, $2, $3, 'Testkonto ' || $3, left($3, 1)::int, 'expense', 'debit')
|
|
ON CONFLICT DO NOTHING`,
|
|
[userId, companyId, accountNumber],
|
|
)
|
|
}
|
|
|
|
async function callMetadata(
|
|
companyId: string,
|
|
entryId: string,
|
|
description: string | null,
|
|
entryDate: string | null,
|
|
actor: string,
|
|
) {
|
|
return getPool().query<{ result: { changed: boolean; log_id: string | null } }>(
|
|
`SELECT public.correct_entry_metadata($1::uuid, $2::uuid, $3, $4::date, $5::uuid) AS result`,
|
|
[companyId, entryId, description, entryDate, actor],
|
|
)
|
|
}
|
|
|
|
async function callStrike(
|
|
companyId: string,
|
|
entryId: string,
|
|
strikeIds: string[],
|
|
newLines: unknown[],
|
|
actor: string,
|
|
) {
|
|
return getPool().query<{ result: { struck_count: number; added_count: number; log_id: string } }>(
|
|
`SELECT public.correct_entry_lines_inline($1::uuid, $2::uuid, $3::uuid[], $4::jsonb, $5::uuid) AS result`,
|
|
[companyId, entryId, strikeIds, JSON.stringify(newLines), actor],
|
|
)
|
|
}
|
|
|
|
async function periodBounds(fiscalPeriodId: string): Promise<{ start: string; end: string }> {
|
|
const { rows } = await getPool().query<{ period_start: string; period_end: string }>(
|
|
`SELECT period_start::text, period_end::text FROM public.fiscal_periods WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
)
|
|
return { start: rows[0].period_start, end: rows[0].period_end }
|
|
}
|
|
|
|
describe('inline rättelse: metadata (correct_entry_metadata)', () => {
|
|
it('still blocks a GUC-less description/date UPDATE on a posted entry', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
await expect(
|
|
getPool().query(`UPDATE public.journal_entries SET description = 'hacked' WHERE id = $1`, [entryId]),
|
|
).rejects.toThrow(/immutable/)
|
|
})
|
|
|
|
it('corrects description + same-period date, and logs old/new with the actor', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const bounds = await periodBounds(fiscalPeriodId)
|
|
const { entryId } = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId,
|
|
entryDate: bounds.start, description: 'Felstavat teext',
|
|
})
|
|
|
|
const res = await callMetadata(companyId, entryId, 'Rättad text', bounds.end, userId)
|
|
expect(res.rows[0].result.changed).toBe(true)
|
|
expect(res.rows[0].result.log_id).toBeTruthy()
|
|
|
|
const { rows: entry } = await getPool().query(
|
|
`SELECT description, entry_date::text, status FROM public.journal_entries WHERE id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(entry[0].description).toBe('Rättad text')
|
|
expect(entry[0].entry_date).toBe(bounds.end)
|
|
expect(entry[0].status).toBe('posted')
|
|
|
|
const { rows: log } = await getPool().query(
|
|
`SELECT rattelse_type, old_description, new_description, old_entry_date::text, new_entry_date::text, actor
|
|
FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(log).toHaveLength(1)
|
|
expect(log[0].rattelse_type).toBe('metadata')
|
|
expect(log[0].old_description).toBe('Felstavat teext')
|
|
expect(log[0].new_description).toBe('Rättad text')
|
|
expect(log[0].old_entry_date).toBe(bounds.start)
|
|
expect(log[0].new_entry_date).toBe(bounds.end)
|
|
expect(log[0].actor).toBe(userId)
|
|
})
|
|
|
|
it('is an idempotent no-op (no log row) when nothing changes', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId } = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId, description: 'Samma text',
|
|
})
|
|
|
|
const res = await callMetadata(companyId, entryId, 'Samma text', null, userId)
|
|
expect(res.rows[0].result.changed).toBe(false)
|
|
|
|
const { rows } = await getPool().query(
|
|
`SELECT 1 FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(rows).toHaveLength(0)
|
|
})
|
|
|
|
it('rejects a date outside the fiscal period', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const bounds = await periodBounds(fiscalPeriodId)
|
|
const { entryId } = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId, entryDate: bounds.start,
|
|
})
|
|
const outside = new Date(new Date(bounds.end).getTime() + 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.slice(0, 10)
|
|
|
|
await expect(callMetadata(companyId, entryId, null, outside, userId)).rejects.toThrow(
|
|
/inom samma bokföringsperiod/,
|
|
)
|
|
})
|
|
|
|
it('rejects all metadata edits on storno entries', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId } = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId, sourceType: 'storno', voucherNumber: 8,
|
|
})
|
|
|
|
await expect(callMetadata(companyId, entryId, 'Omdöpt storno', null, userId)).rejects.toThrow(
|
|
/Stornoverifikat kan inte rättas/,
|
|
)
|
|
})
|
|
|
|
it('rejects date changes on opening_balance/year_end/vat_settlement entries', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const bounds = await periodBounds(fiscalPeriodId)
|
|
const { entryId } = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId,
|
|
entryDate: bounds.start, sourceType: 'year_end', voucherNumber: 7,
|
|
})
|
|
|
|
await expect(callMetadata(companyId, entryId, null, bounds.end, userId)).rejects.toThrow(
|
|
/kan inte ändras/,
|
|
)
|
|
// ...but the description alone is still correctable.
|
|
const res = await callMetadata(companyId, entryId, 'Bokslut, rättad text', null, userId)
|
|
expect(res.rows[0].result.changed).toBe(true)
|
|
})
|
|
|
|
it('rejects metadata rättelse in closed and locked periods, and behind the lock date', async () => {
|
|
const closed = await seedCompany()
|
|
const closedEntry = await insertPostedEntry({
|
|
companyId: closed.companyId, userId: closed.userId, fiscalPeriodId: closed.fiscalPeriodId,
|
|
})
|
|
await getPool().query(
|
|
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
|
|
[closed.fiscalPeriodId],
|
|
)
|
|
await expect(
|
|
callMetadata(closed.companyId, closedEntry.entryId, 'Ny text', null, closed.userId),
|
|
).rejects.toThrow(/stängd eller låst/)
|
|
|
|
const locked = await seedCompany()
|
|
const lockedEntry = await insertPostedEntry({
|
|
companyId: locked.companyId, userId: locked.userId, fiscalPeriodId: locked.fiscalPeriodId,
|
|
})
|
|
await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [
|
|
locked.fiscalPeriodId,
|
|
])
|
|
await expect(
|
|
callMetadata(locked.companyId, lockedEntry.entryId, 'Ny text', null, locked.userId),
|
|
).rejects.toThrow(/stängd eller låst/)
|
|
|
|
const lockDated = await seedCompany()
|
|
const lockDatedBounds = await periodBounds(lockDated.fiscalPeriodId)
|
|
const lockDatedEntry = await insertPostedEntry({
|
|
companyId: lockDated.companyId, userId: lockDated.userId,
|
|
fiscalPeriodId: lockDated.fiscalPeriodId, entryDate: lockDatedBounds.start,
|
|
})
|
|
await getPool().query(
|
|
`INSERT INTO public.company_settings (user_id, company_id, bookkeeping_locked_through)
|
|
VALUES ($1, $2, $3::date)
|
|
ON CONFLICT (company_id) DO UPDATE SET bookkeeping_locked_through = $3::date`,
|
|
[lockDated.userId, lockDated.companyId, lockDatedBounds.end],
|
|
)
|
|
await expect(
|
|
callMetadata(lockDated.companyId, lockDatedEntry.entryId, 'Ny text', null, lockDated.userId),
|
|
).rejects.toThrow(/låst t\.o\.m/)
|
|
})
|
|
|
|
it('rejects viewers, strangers and drafts', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
const viewerId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
|
|
await expect(callMetadata(companyId, entryId, 'Som viewer', null, viewerId)).rejects.toThrow(
|
|
/skrivbehörighet/,
|
|
)
|
|
await expect(callMetadata(companyId, entryId, 'Som främling', null, randomUUID())).rejects.toThrow(
|
|
/skrivbehörighet/,
|
|
)
|
|
|
|
const draftId = await insertDraftJournalEntry({
|
|
userId, companyId, fiscalPeriodId, status: 'draft', voucherNumber: 99,
|
|
})
|
|
await expect(callMetadata(companyId, draftId, 'Utkast', null, userId)).rejects.toThrow(
|
|
/bokförda verifikat/,
|
|
)
|
|
})
|
|
|
|
it('ignores a spoofed p_user_id for JWT callers (viewer cannot act as the owner)', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
const viewerId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
|
|
|
|
// Authenticated JWT context as the viewer, passing the OWNER's id as
|
|
// p_user_id: the RPC must pin the actor to auth.uid() and refuse.
|
|
await withUserContext(viewerId, async (client) => {
|
|
await expect(
|
|
client.query(
|
|
`SELECT public.correct_entry_metadata($1::uuid, $2::uuid, 'Spoofad text', NULL, $3::uuid)`,
|
|
[companyId, entryId, userId],
|
|
),
|
|
).rejects.toThrow(/skrivbehörighet/)
|
|
})
|
|
})
|
|
|
|
it('never admits a smuggled non-metadata change under the GUC', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
const client = await getPool().connect()
|
|
try {
|
|
await client.query('BEGIN')
|
|
await client.query(`SELECT set_config('gnubok.allow_metadata_rattelse', 'true', true)`)
|
|
await expect(
|
|
client.query(
|
|
`UPDATE public.journal_entries SET description = 'ny text', voucher_number = 4711 WHERE id = $1`,
|
|
[entryId],
|
|
),
|
|
).rejects.toThrow(/immutable/)
|
|
await client.query('ROLLBACK')
|
|
} finally {
|
|
client.release()
|
|
}
|
|
})
|
|
|
|
it("cannot reach another company's entries", async () => {
|
|
const a = await seedCompany()
|
|
const b = await seedCompany()
|
|
const { entryId } = await insertPostedEntry({
|
|
companyId: a.companyId, userId: a.userId, fiscalPeriodId: a.fiscalPeriodId,
|
|
})
|
|
|
|
await expect(callMetadata(b.companyId, entryId, 'Cross-tenant', null, b.userId)).rejects.toThrow(
|
|
/hittades inte/,
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('inline rättelse: lines (correct_entry_lines_inline)', () => {
|
|
it('still blocks a GUC-less DELETE of a posted line', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
await expect(
|
|
getPool().query(`DELETE FROM public.journal_entry_lines WHERE id = $1`, [debitLineId]),
|
|
).rejects.toThrow(/Cannot DELETE lines of a posted journal entry/)
|
|
})
|
|
|
|
it('strikes a line and adds a balanced replacement in the same verifikat (happy path)', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '5420')
|
|
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
const res = await callStrike(
|
|
companyId, entryId, [debitLineId],
|
|
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0, line_description: 'Programvara' }],
|
|
userId,
|
|
)
|
|
expect(res.rows[0].result.struck_count).toBe(1)
|
|
expect(res.rows[0].result.added_count).toBe(1)
|
|
|
|
// The struck line is gone from the effective verifikat; the replacement
|
|
// exists with a resolved account_id and a sort_order after the survivors.
|
|
const { rows: lines } = await getPool().query(
|
|
`SELECT account_number, debit_amount::numeric, credit_amount::numeric, account_id, sort_order
|
|
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY sort_order`,
|
|
[entryId],
|
|
)
|
|
expect(lines).toHaveLength(2)
|
|
expect(lines.map((l) => l.account_number)).toEqual(['1930', '5420'])
|
|
expect(lines[1].account_id).toBeTruthy()
|
|
expect(Number(lines[1].debit_amount)).toBe(1000)
|
|
|
|
// Entry still balances and is still posted.
|
|
const { rows: sums } = await getPool().query(
|
|
`SELECT sum(debit_amount)::numeric AS d, sum(credit_amount)::numeric AS c
|
|
FROM public.journal_entry_lines WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(Number(sums[0].d)).toBe(1000)
|
|
expect(Number(sums[0].c)).toBe(1000)
|
|
|
|
// Immutable log row carries the full struck snapshot + the added lines.
|
|
const { rows: log } = await getPool().query(
|
|
`SELECT rattelse_type, struck_lines, added_lines, actor
|
|
FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(log).toHaveLength(1)
|
|
expect(log[0].rattelse_type).toBe('lines')
|
|
expect(log[0].struck_lines).toHaveLength(1)
|
|
expect(log[0].struck_lines[0].account_number).toBe('5010')
|
|
expect(Number(log[0].struck_lines[0].debit_amount)).toBe(1000)
|
|
expect(log[0].added_lines).toHaveLength(1)
|
|
expect(log[0].added_lines[0].account_number).toBe('5420')
|
|
expect(log[0].actor).toBe(userId)
|
|
})
|
|
|
|
it('rejects an unbalanced rättelse and rolls back atomically', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '5420')
|
|
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
await expect(
|
|
callStrike(
|
|
companyId, entryId, [debitLineId],
|
|
[{ account_number: '5420', debit_amount: 900, credit_amount: 0 }],
|
|
userId,
|
|
),
|
|
).rejects.toThrow(/balanserar inte/)
|
|
|
|
// Nothing changed, nothing logged.
|
|
const { rows: lines } = await getPool().query(
|
|
`SELECT count(*)::int AS n FROM public.journal_entry_lines WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(lines[0].n).toBe(2)
|
|
const { rows: log } = await getPool().query(
|
|
`SELECT 1 FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(log).toHaveLength(0)
|
|
})
|
|
|
|
it('rejects a rättelse that leaves fewer than two lines or zeroes the verifikat', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId, debitLineId, creditLineId } = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId,
|
|
})
|
|
|
|
await expect(callStrike(companyId, entryId, [debitLineId], [], userId)).rejects.toThrow(
|
|
/minst två rader/,
|
|
)
|
|
await expect(
|
|
callStrike(companyId, entryId, [debitLineId, creditLineId], [], userId),
|
|
).rejects.toThrow(/minst två rader/)
|
|
})
|
|
|
|
it('rejects an empty rättelse and a strike + identical re-add', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '5010')
|
|
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
await expect(callStrike(companyId, entryId, [], [], userId)).rejects.toThrow(/minst en rad/)
|
|
await expect(
|
|
callStrike(
|
|
companyId, entryId, [debitLineId],
|
|
[{ account_number: '5010', debit_amount: 1000, credit_amount: 0 }],
|
|
userId,
|
|
),
|
|
).rejects.toThrow(/ändrar ingenting/)
|
|
})
|
|
|
|
it("rejects strike ids from another entry and accounts missing from the chart", async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const first = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
const second = await insertPostedEntry({ companyId, userId, fiscalPeriodId, voucherNumber: 2 })
|
|
|
|
await expect(
|
|
callStrike(companyId, first.entryId, [second.debitLineId], [], userId),
|
|
).rejects.toThrow(/hör inte till verifikationen/)
|
|
|
|
await expect(
|
|
callStrike(
|
|
companyId, first.entryId, [first.debitLineId],
|
|
[{ account_number: '9999', debit_amount: 1000, credit_amount: 0 }],
|
|
userId,
|
|
),
|
|
).rejects.toThrow(/finns inte i kontoplanen/)
|
|
})
|
|
|
|
it('rejects line rättelse on structural source types and outside open periods', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const yearEnd = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId, sourceType: 'year_end', voucherNumber: 3,
|
|
})
|
|
await expect(
|
|
callStrike(companyId, yearEnd.entryId, [yearEnd.debitLineId], [], userId),
|
|
).rejects.toThrow(/kan inte rättas radvis/)
|
|
|
|
const locked = await seedCompany()
|
|
const lockedEntry = await insertPostedEntry({
|
|
companyId: locked.companyId, userId: locked.userId, fiscalPeriodId: locked.fiscalPeriodId,
|
|
})
|
|
await getPool().query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [
|
|
locked.fiscalPeriodId,
|
|
])
|
|
await expect(
|
|
callStrike(locked.companyId, lockedEntry.entryId, [lockedEntry.debitLineId], [], locked.userId),
|
|
).rejects.toThrow(/stängd eller låst/)
|
|
})
|
|
|
|
it('blocks striking foreign-currency lines and doc-attached lines', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '5420')
|
|
const entryId = await insertDraftJournalEntry({
|
|
userId, companyId, fiscalPeriodId, sourceType: 'manual', status: 'draft', voucherNumber: 11,
|
|
})
|
|
const { rows: fxRows } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order, currency, amount_in_currency, exchange_rate)
|
|
VALUES ($1, '5010', 1000, 0, 1, 'EUR', 90, 11.11) RETURNING id`,
|
|
[entryId],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
|
VALUES ($1, '1930', 0, 1000, 2)`,
|
|
[entryId],
|
|
)
|
|
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
|
|
|
|
await expect(
|
|
callStrike(companyId, entryId, [fxRows[0].id],
|
|
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }], userId),
|
|
).rejects.toThrow(/utländsk valuta/)
|
|
|
|
await getPool().query(
|
|
`INSERT INTO public.document_attachments
|
|
(user_id, company_id, journal_entry_id, journal_entry_line_id, storage_path, file_name, sha256_hash)
|
|
VALUES ($1, $2, $3, $4, 'test/underlag.pdf', 'kvitto.pdf', repeat('a', 64))`,
|
|
[userId, companyId, entryId, fxRows[0].id],
|
|
)
|
|
await expect(
|
|
callStrike(companyId, entryId, [fxRows[0].id],
|
|
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }], userId),
|
|
).rejects.toThrow(/utländsk valuta|kopplat underlag/)
|
|
})
|
|
|
|
it('protects the bank side of transaction-linked entries but allows contra-side fixes', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '5420')
|
|
await insertChartAccount(companyId, userId, '1930')
|
|
const { entryId, debitLineId, creditLineId } = await insertPostedEntry({
|
|
companyId, userId, fiscalPeriodId, sourceType: 'bank_transaction',
|
|
})
|
|
await getPool().query(
|
|
`INSERT INTO public.transactions (user_id, company_id, date, description, amount, journal_entry_id, is_business)
|
|
VALUES ($1, $2, '2026-02-10', 'Bank tx', -1000, $3, true)`,
|
|
[userId, companyId, entryId],
|
|
)
|
|
|
|
// Changing the 1930 net is refused: the bank feed amount is immutable.
|
|
await expect(
|
|
callStrike(companyId, entryId, [creditLineId],
|
|
[
|
|
{ account_number: '1930', debit_amount: 0, credit_amount: 900 },
|
|
{ account_number: '5420', debit_amount: 0, credit_amount: 100 },
|
|
], userId),
|
|
).rejects.toThrow(/kopplad till en banktransaktion/)
|
|
|
|
// The contra side (wrong expense account) is exactly the reconciliation
|
|
// use case and stays correctable.
|
|
const res = await callStrike(companyId, entryId, [debitLineId],
|
|
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }], userId)
|
|
expect(res.rows[0].result.struck_count).toBe(1)
|
|
|
|
// A net-preserving strike+re-add on the bank line (description fix) is
|
|
// allowed, and counts as a real change thanks to the description-aware
|
|
// no-op comparison.
|
|
const res2 = await callStrike(companyId, entryId, [creditLineId],
|
|
[{ account_number: '1930', debit_amount: 0, credit_amount: 1000, line_description: 'Rättad text' }], userId)
|
|
expect(res2.rows[0].result.struck_count).toBe(1)
|
|
})
|
|
|
|
it('keeps the journal_entry_rattelse_log immutable', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '5420')
|
|
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
await callStrike(
|
|
companyId, entryId, [debitLineId],
|
|
[{ account_number: '5420', debit_amount: 1000, credit_amount: 0 }],
|
|
userId,
|
|
)
|
|
|
|
const { rows } = await getPool().query<{ id: string }>(
|
|
`SELECT id FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
await expect(
|
|
getPool().query(`UPDATE public.journal_entry_rattelse_log SET actor = NULL WHERE id = $1`, [
|
|
rows[0].id,
|
|
]),
|
|
).rejects.toThrow(/oföränderlig/)
|
|
await expect(
|
|
getPool().query(`DELETE FROM public.journal_entry_rattelse_log WHERE id = $1`, [rows[0].id]),
|
|
).rejects.toThrow(/oföränderlig/)
|
|
})
|
|
|
|
it('leaves the gnubok.allow_delete bulk-delete path unaffected', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
const { entryId, debitLineId } = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
|
|
|
const client = await getPool().connect()
|
|
try {
|
|
await client.query('BEGIN')
|
|
await client.query(`SELECT set_config('gnubok.allow_delete', 'true', true)`)
|
|
await client.query(`DELETE FROM public.journal_entry_lines WHERE id = $1`, [debitLineId])
|
|
await client.query(`DELETE FROM public.journal_entries WHERE id = $1`, [entryId])
|
|
await client.query('ROLLBACK')
|
|
} finally {
|
|
client.release()
|
|
}
|
|
})
|
|
})
|