Add/stripe connect transactions (#1139)

* 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>
This commit is contained in:
Mattsson
2026-07-24 01:16:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent be9d630347
commit d840257c0c
55 changed files with 23207 additions and 125 deletions
@@ -0,0 +1,148 @@
/**
* Tests for POST /api/bookkeeping/journal-entries/[id]/correct-metadata
* (metadata rättelse of a posted verifikat via the audited RPC).
*
* Covers: 401, validation 400 (empty body / blank description / bad date),
* rule-violation 409 passthrough (Swedish RPC messages verbatim), tenant
* guard 403, unexpected RPC failure 500, and the happy path.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const rpcMock = vi.fn()
;(supabase as { rpc?: unknown }).rpc = rpcMock
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
const params = () => createMockRouteParams({ id: 'entry-1' })
function makeRequest(body: unknown) {
return createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct-metadata', {
method: 'POST',
body,
})
}
describe('POST /api/bookkeeping/journal-entries/[id]/correct-metadata', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
expect(response.status).toBe(401)
expect(rpcMock).not.toHaveBeenCalled()
})
it.each([
['empty body', {}],
['blank description', { description: ' ' }],
['bad date', { entry_date: 'inte-ett-datum' }],
])('rejects invalid body (%s) with 400', async (_label, body) => {
const response = await POST(makeRequest(body), params())
expect(response.status).toBe(400)
expect(rpcMock).not.toHaveBeenCalled()
})
it('passes rule violations through as 409 with the Swedish message', async () => {
rpcMock.mockResolvedValue({
data: null,
error: { code: 'P0001', message: 'Perioden är stängd eller låst — använd rättelseverifikat (storno).' },
})
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
const { body } = await parseJsonResponse<{ error: string }>(response)
expect(response.status).toBe(409)
expect(body.error).toContain('låst')
})
it('maps the tenant guard (42501) to 403', async () => {
rpcMock.mockResolvedValue({
data: null,
error: { code: '42501', message: 'unauthorized: caller is not a member of company company-1' },
})
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
expect(response.status).toBe(403)
})
it('returns 500 on unexpected RPC failure', async () => {
rpcMock.mockResolvedValue({ data: null, error: { code: '57P01', message: 'connection refused' } })
const response = await POST(makeRequest({ description: 'Rättad text' }), params())
expect(response.status).toBe(500)
})
it('corrects description and date via the RPC with the caller as actor (happy path)', async () => {
rpcMock.mockResolvedValue({
data: {
changed: true,
log_id: 'log-1',
old_description: 'Gamal text',
new_description: 'Rättad text',
old_entry_date: '2026-07-01',
new_entry_date: '2026-07-05',
},
error: null,
})
const response = await POST(
makeRequest({ description: 'Rättad text', entry_date: '2026-07-05' }),
params(),
)
const { body } = await parseJsonResponse<{ data: { changed: boolean; log_id: string } }>(response)
expect(response.status).toBe(200)
expect(body.data.changed).toBe(true)
expect(rpcMock).toHaveBeenCalledWith('correct_entry_metadata', {
p_company_id: 'company-1',
p_entry_id: 'entry-1',
p_description: 'Rättad text',
p_entry_date: '2026-07-05',
p_user_id: 'user-1',
})
})
it('sends null for omitted fields (description-only edit)', async () => {
rpcMock.mockResolvedValue({ data: { changed: true, log_id: 'log-2' }, error: null })
const response = await POST(makeRequest({ description: 'Bara texten' }), params())
expect(response.status).toBe(200)
expect(rpcMock).toHaveBeenCalledWith(
'correct_entry_metadata',
expect.objectContaining({ p_description: 'Bara texten', p_entry_date: null }),
)
})
})
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CorrectEntryMetadataSchema } from '@/lib/api/schemas'
import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/bookkeeping/journal-entries/[id]/correct-metadata
*
* Metadata rättelse (BFL 5 kap 9 §): correct the description and/or the
* entry date (within the same fiscal period) of a POSTED verifikat, without
* a rättelseverifikation. The correct_entry_metadata RPC enforces everything
* (posted status, open/unlocked period, company lock date, same-period date,
* writer role) and writes the immutable journal_entry_rattelse_log row
* before the carve-out UPDATE. Cross-period date moves stay on the
* recordate (storno) flow.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.correct_metadata',
async (request, { supabase, companyId, user, log }, { params }) => {
const { id } = await params
const validation = await validateBody(request, CorrectEntryMetadataSchema)
if (!validation.success) return validation.response
const { description, entry_date } = validation.data
const { data, error } = await supabase.rpc('correct_entry_metadata', {
p_company_id: companyId,
p_entry_id: id,
p_description: description ?? null,
p_entry_date: entry_date ?? null,
p_user_id: user.id,
})
if (error) {
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
// Swedish messages: surface verbatim as 409. Tenant guard raises 42501.
if (error.code === 'P0001') {
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
}
if (error.code === '42501') {
return NextResponse.json({ error: getErrorMessage(error) }, { status: 403 })
}
log.error('correct_entry_metadata failed', new Error(error.message), { entryId: id })
return NextResponse.json({ error: 'Kunde inte rätta verifikationen' }, { status: 500 })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
@@ -0,0 +1,114 @@
/**
* Tests for GET /api/bookkeeping/journal-entries/[id]/rattelse-log
* (the immutable inline rättelse history, BFL 5 kap 5 § / 9 §).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET } from '../route'
const params = () => createMockRouteParams({ id: 'entry-1' })
const makeGet = () =>
createMockRequest('/api/bookkeeping/journal-entries/entry-1/rattelse-log', { method: 'GET' })
describe('GET /api/bookkeeping/journal-entries/[id]/rattelse-log', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(makeGet(), params())
expect(response.status).toBe(401)
})
it('returns 404 when the entry belongs to another company', async () => {
enqueue({ data: null, error: null }) // ownership check finds nothing
const response = await GET(makeGet(), params())
const { body } = await parseJsonResponse<{ error: string }>(response)
expect(response.status).toBe(404)
expect(body.error).toContain('hittades inte')
})
it('returns the rättelse rows newest first', async () => {
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
enqueue({
data: [
{
id: 'log-2',
rattelse_type: 'lines',
old_description: null,
new_description: null,
old_entry_date: null,
new_entry_date: null,
struck_lines: [
{ id: 'line-1', account_number: '5410', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 1 },
],
added_lines: [
{ id: 'line-9', account_number: '5420', debit_amount: 500, credit_amount: 0, line_description: null, sort_order: 3 },
],
actor: 'user-1',
created_at: '2026-07-23T12:00:00Z',
},
{
id: 'log-1',
rattelse_type: 'metadata',
old_description: 'Gamal text',
new_description: 'Rättad text',
old_entry_date: '2026-07-01',
new_entry_date: '2026-07-01',
struck_lines: null,
added_lines: null,
actor: 'user-1',
created_at: '2026-07-22T12:00:00Z',
},
],
error: null,
})
const response = await GET(makeGet(), params())
const { body } = await parseJsonResponse<{ data: { id: string; rattelse_type: string }[] }>(response)
expect(response.status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.data[0].id).toBe('log-2')
expect(body.data[0].rattelse_type).toBe('lines')
})
it('returns 500 with a Swedish message when the query fails', async () => {
enqueue({ data: { id: 'entry-1' }, error: null }) // ownership check
enqueue({ data: null, error: { message: 'boom' } })
const response = await GET(makeGet(), params())
const { body } = await parseJsonResponse<{ error: string }>(response)
expect(response.status).toBe(500)
expect(body.error).toContain('historik')
})
})
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* GET /api/bookkeeping/journal-entries/[id]/rattelse-log
*
* The entry's inline rättelse history (BFL 5 kap 5 § / 9 §): the immutable
* who/when trail behind every metadata edit and line strike, newest first.
* Struck lines render with strikethrough in the verifikat detail view from
* the struck_lines snapshots here.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.rattelse_log',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
// Ownership gate: 404 for entries outside the caller's company, so the
// empty-log response cannot be used to probe entry existence cross-tenant.
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (entryError) {
return NextResponse.json({ error: 'Kunde inte hämta rättelsehistorik' }, { status: 500 })
}
if (!entry) {
return NextResponse.json({ error: 'Verifikatet hittades inte' }, { status: 404 })
}
const { data, error } = await supabase
.from('journal_entry_rattelse_log')
.select('id, rattelse_type, old_description, new_description, old_entry_date, new_entry_date, struck_lines, added_lines, actor, created_at')
.eq('company_id', companyId)
.eq('journal_entry_id', id)
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: 'Kunde inte hämta rättelsehistorik' }, { status: 500 })
}
return NextResponse.json({ data: data ?? [] })
},
)
@@ -0,0 +1,171 @@
/**
* Tests for POST /api/bookkeeping/journal-entries/[id]/strike-lines
* (inline line rättelse of a posted verifikat via the audited RPC).
*
* Covers: 401, validation 400 (empty rättelse / bad account / bad uuid),
* rule-violation 409 passthrough (Swedish RPC messages verbatim), tenant
* guard 403, unexpected RPC failure 500, the happy path (incl. BAS account
* backfill before the RPC) and the strike-only path.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const rpcMock = vi.fn()
;(supabase as { rpc?: unknown }).rpc = rpcMock
const backfillMock = vi.fn().mockResolvedValue([])
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: (...args: unknown[]) => backfillMock(...args),
}))
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
const LINE_ID = '11111111-1111-4111-8111-111111111111'
const params = () => createMockRouteParams({ id: 'entry-1' })
function makeRequest(body: unknown) {
return createMockRequest('/api/bookkeeping/journal-entries/entry-1/strike-lines', {
method: 'POST',
body,
})
}
describe('POST /api/bookkeeping/journal-entries/[id]/strike-lines', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
backfillMock.mockResolvedValue([])
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
expect(response.status).toBe(401)
expect(rpcMock).not.toHaveBeenCalled()
})
it.each([
['empty rättelse', { strike_line_ids: [], lines: [] }],
['bad line id', { strike_line_ids: ['not-a-uuid'] }],
['bad account number', { lines: [{ account_number: '19', debit_amount: 100 }] }],
['negative amount', { lines: [{ account_number: '1930', debit_amount: -5 }] }],
])('rejects invalid body (%s) with 400', async (_label, body) => {
const response = await POST(makeRequest(body), params())
expect(response.status).toBe(400)
expect(rpcMock).not.toHaveBeenCalled()
})
it('passes rule violations through as 409 with the Swedish message', async () => {
rpcMock.mockResolvedValue({
data: null,
error: { code: 'P0001', message: 'Verifikationen balanserar inte efter rättelsen (debet 100.00, kredit 0.00).' },
})
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
const { body } = await parseJsonResponse<{ error: string }>(response)
expect(response.status).toBe(409)
expect(body.error).toContain('balanserar')
})
it('maps the tenant guard (42501) to 403', async () => {
rpcMock.mockResolvedValue({
data: null,
error: { code: '42501', message: 'unauthorized: caller is not a member of company company-1' },
})
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
expect(response.status).toBe(403)
})
it('returns 500 on unexpected RPC failure', async () => {
rpcMock.mockResolvedValue({ data: null, error: { code: '57P01', message: 'connection refused' } })
const response = await POST(makeRequest({ strike_line_ids: [LINE_ID] }), params())
expect(response.status).toBe(500)
})
it('strikes and replaces via the RPC, backfilling BAS accounts first (happy path)', async () => {
rpcMock.mockResolvedValue({
data: { log_id: 'log-1', struck_count: 1, added_count: 1, total_debit: 500, total_credit: 500 },
error: null,
})
const response = await POST(
makeRequest({
strike_line_ids: [LINE_ID],
lines: [{ account_number: '5420', debit_amount: 500, line_description: 'Programvara' }],
}),
params(),
)
const { body } = await parseJsonResponse<{ data: { struck_count: number; added_count: number } }>(response)
expect(response.status).toBe(200)
expect(body.data.struck_count).toBe(1)
expect(body.data.added_count).toBe(1)
expect(backfillMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', ['5420'])
expect(rpcMock).toHaveBeenCalledWith('correct_entry_lines_inline', {
p_company_id: 'company-1',
p_entry_id: 'entry-1',
p_strike_line_ids: [LINE_ID],
p_new_lines: [
{
account_number: '5420',
debit_amount: 500,
credit_amount: 0,
line_description: 'Programvara',
dimensions: {},
},
],
p_user_id: 'user-1',
})
})
it('accepts a strike-only rättelse without new lines (skips backfill)', async () => {
rpcMock.mockResolvedValue({
data: { log_id: 'log-2', struck_count: 2, added_count: 0, total_debit: 100, total_credit: 100 },
error: null,
})
const response = await POST(
makeRequest({ strike_line_ids: [LINE_ID, '22222222-2222-4222-8222-222222222222'] }),
params(),
)
expect(response.status).toBe(200)
expect(backfillMock).not.toHaveBeenCalled()
expect(rpcMock).toHaveBeenCalledWith(
'correct_entry_lines_inline',
expect.objectContaining({ p_new_lines: [] }),
)
})
})
@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { StrikeLinesSchema } from '@/lib/api/schemas'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
/**
* POST /api/bookkeeping/journal-entries/[id]/strike-lines
*
* Inline line rättelse (BFL 5 kap 5 §): strike lines inside a POSTED
* verifikat and add replacement lines in the same verifikat, without a
* rättelseverifikation. The correct_entry_lines_inline RPC enforces the full
* envelope (posted status, open/unlocked period, company lock date, effective
* balance to the öre, ≥2 remaining lines, writer role) and snapshots the
* struck originals to the immutable journal_entry_rattelse_log. Past a
* lock/close, the storno correction flow remains the only path.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.strike_lines',
async (request, { supabase, companyId, user, log }, { params }) => {
const { id } = await params
const validation = await validateBody(request, StrikeLinesSchema)
if (!validation.success) return validation.response
const { strike_line_ids, lines } = validation.data
// Seed standard BAS accounts the replacement lines reference but the
// company chart lacks (same courtesy as the engine/storno flow); unknown
// numbers stay missing and fail the RPC's chart check with a clear error.
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
if (accountNumbers.length > 0) {
await backfillStandardBASAccounts(supabase, companyId, user.id, accountNumbers)
}
const { data, error } = await supabase.rpc('correct_entry_lines_inline', {
p_company_id: companyId,
p_entry_id: id,
p_strike_line_ids: strike_line_ids,
p_new_lines: lines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
dimensions: l.dimensions ?? {},
})),
p_user_id: user.id,
})
if (error) {
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
// Swedish messages: surface verbatim as 409. Tenant guard raises 42501.
if (error.code === 'P0001') {
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
}
if (error.code === '42501') {
return NextResponse.json({ error: getErrorMessage(error) }, { status: 403 })
}
// Defensive: the RPC pre-checks document links, but if the RESTRICT FK
// still fires (racing attachment), surface the same guidance.
if (error.code === '23503') {
return NextResponse.json(
{ error: 'En rad som ska strykas har ett kopplat underlag — använd rättelseverifikat (storno).' },
{ status: 409 },
)
}
log.error('correct_entry_lines_inline failed', new Error(error.message), { entryId: id })
return NextResponse.json({ error: 'Kunde inte rätta verifikationen' }, { status: 500 })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
@@ -0,0 +1,83 @@
/**
* Tests for GET /api/bookkeeping/journal-entries/rattelse-flags
* (which entries carry inline rättelser, for the list "Rättad" marker).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { GET } from '../route'
const makeGet = (ids: string) =>
createMockRequest(`/api/bookkeeping/journal-entries/rattelse-flags?ids=${ids}`, { method: 'GET' })
describe('GET /api/bookkeeping/journal-entries/rattelse-flags', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(makeGet('a'), undefined as never)
expect(response.status).toBe(401)
})
it('returns an empty list without querying when no ids are given', async () => {
const response = await GET(makeGet(''), undefined as never)
const { body } = await parseJsonResponse<{ data: string[] }>(response)
expect(response.status).toBe(200)
expect(body.data).toEqual([])
})
it('rejects more than 200 ids with 400', async () => {
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`).join(',')
const response = await GET(makeGet(ids), undefined as never)
expect(response.status).toBe(400)
})
it('returns the distinct entry ids that have rättelser', async () => {
enqueue({
data: [
{ journal_entry_id: 'entry-1' },
{ journal_entry_id: 'entry-1' },
{ journal_entry_id: 'entry-3' },
],
error: null,
})
const response = await GET(makeGet('entry-1,entry-2,entry-3'), undefined as never)
const { body } = await parseJsonResponse<{ data: string[] }>(response)
expect(response.status).toBe(200)
expect(body.data.sort()).toEqual(['entry-1', 'entry-3'])
})
it('returns 500 with a Swedish message when the query fails', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const response = await GET(makeGet('entry-1'), undefined as never)
expect(response.status).toBe(500)
})
})
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* GET /api/bookkeeping/journal-entries/rattelse-flags?ids=a,b,c
*
* Which of the given entries have inline rättelser (rows in
* journal_entry_rattelse_log). Drives the "Rättad" marker in the voucher
* list so a rättelse is discoverable without opening the verifikat
* (BFL 5 kap 5 §: the correction must be easy to become aware of).
* Capped at 200 ids per request (one list page).
*/
export const GET = withRouteContext(
'bookkeeping.journal_entries.rattelse_flags',
async (request, { supabase, companyId }) => {
const idsParam = new URL(request.url).searchParams.get('ids') ?? ''
const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean)
if (ids.length === 0) {
return NextResponse.json({ data: [] })
}
if (ids.length > 200) {
return NextResponse.json({ error: 'Högst 200 verifikat per anrop.' }, { status: 400 })
}
const { data, error } = await supabase
.from('journal_entry_rattelse_log')
.select('journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', ids)
if (error) {
return NextResponse.json({ error: 'Kunde inte hämta rättelsemarkeringar' }, { status: 500 })
}
return NextResponse.json({ data: [...new Set((data ?? []).map((r) => r.journal_entry_id))] })
},
)
@@ -0,0 +1,167 @@
/**
* Tests for the nightly Stripe balance-transaction sync cron: auth, config
* short-circuit, per-connection processing with capability gating, and
* isolated per-connection failures.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const verifyCronSecret = vi.fn<(request: Request) => Response | null>(() => null)
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: (request: Request) => verifyCronSecret(request),
}))
const connectionsResult: { data: unknown; error: unknown } = { data: null, error: null }
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(() => ({
from: vi.fn(() => {
const chain: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'order', 'limit']) {
chain[method] = vi.fn(() => chain)
}
chain.then = (resolve: (v: unknown) => unknown) =>
Promise.resolve(connectionsResult).then(resolve)
return chain
}),
})),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
hasCapability: vi.fn(),
}))
vi.mock('@/extensions/general/stripe/lib/transaction-sync', () => ({
syncStripeBalanceTransactions: vi.fn(),
}))
import { hasCapability } from '@/lib/entitlements/has-capability'
import { syncStripeBalanceTransactions } from '@/extensions/general/stripe/lib/transaction-sync'
import { GET } from '../route'
function cronRequest(): Request {
return new Request('http://localhost:3000/api/extensions/stripe/transactions/cron')
}
function makeConnection(id: string, companyId: string) {
return {
id,
company_id: companyId,
user_id: 'user-1',
stripe_account_id: `acct_${id}`,
status: 'active',
transaction_sync_enabled: true,
last_balance_txn_synced_at: null,
}
}
beforeEach(() => {
vi.clearAllMocks()
verifyCronSecret.mockReturnValue(null)
connectionsResult.data = null
connectionsResult.error = null
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://example.supabase.co')
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-key')
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123')
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', 'ca_123')
vi.mocked(hasCapability).mockResolvedValue(true)
vi.mocked(syncStripeBalanceTransactions).mockResolvedValue({
fetched: 3,
imported: 2,
duplicates: 1,
linked: 1,
errors: 0,
})
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('GET /api/extensions/stripe/transactions/cron', () => {
it('rejects requests without a valid cron secret', async () => {
verifyCronSecret.mockReturnValue(
new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }),
)
const response = await GET(cronRequest())
expect(response.status).toBe(401)
expect(vi.mocked(syncStripeBalanceTransactions)).not.toHaveBeenCalled()
})
it('no-ops when Stripe Connect is not configured', async () => {
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', '')
const response = await GET(cronRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(json).toEqual({ message: 'Stripe Connect not configured', processed: 0 })
})
it('no-ops when no connection has transaction sync enabled', async () => {
connectionsResult.data = []
const response = await GET(cronRequest())
const json = await response.json()
expect(json.processed).toBe(0)
expect(vi.mocked(syncStripeBalanceTransactions)).not.toHaveBeenCalled()
})
it('syncs entitled connections and aggregates totals', async () => {
connectionsResult.data = [
makeConnection('conn-1', 'company-1'),
makeConnection('conn-2', 'company-2'),
]
const response = await GET(cronRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(vi.mocked(syncStripeBalanceTransactions)).toHaveBeenCalledTimes(2)
expect(json.processed).toBe(2)
expect(json.imported).toBe(4)
expect(json.linked).toBe(2)
expect(json.results).toEqual([
{ connectionId: 'conn-1', imported: 2, duplicates: 1, linked: 1, status: 'synced' },
{ connectionId: 'conn-2', imported: 2, duplicates: 1, linked: 1, status: 'synced' },
])
})
it('skips connections whose company lacks the stripe_payments capability', async () => {
connectionsResult.data = [
makeConnection('conn-1', 'company-1'),
makeConnection('conn-2', 'company-2'),
]
vi.mocked(hasCapability).mockImplementation(async (_sb, companyId) =>
companyId !== 'company-1',
)
const response = await GET(cronRequest())
const json = await response.json()
expect(vi.mocked(syncStripeBalanceTransactions)).toHaveBeenCalledTimes(1)
expect(json.processed).toBe(1)
expect(json.results[0].connectionId).toBe('conn-2')
})
it('isolates a failing connection and keeps processing the rest', async () => {
connectionsResult.data = [
makeConnection('conn-1', 'company-1'),
makeConnection('conn-2', 'company-2'),
]
vi.mocked(syncStripeBalanceTransactions)
.mockRejectedValueOnce(new Error('stripe boom'))
.mockResolvedValueOnce({ fetched: 1, imported: 1, duplicates: 0, linked: 0, errors: 0 })
const response = await GET(cronRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(json.processed).toBe(2)
expect(json.results).toEqual([
{ connectionId: 'conn-1', imported: 0, duplicates: 0, linked: 0, status: 'error' },
{ connectionId: 'conn-2', imported: 1, duplicates: 0, linked: 0, status: 'synced' },
])
})
})
@@ -0,0 +1,130 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { syncStripeBalanceTransactions } from '@/extensions/general/stripe/lib/transaction-sync'
import type { StripeConnection } from '@/extensions/general/stripe/types'
export const maxDuration = 300
/**
* GET /api/extensions/stripe/transactions/cron
* Nightly balance-transaction sync for connections that opted in
* (transaction_sync_enabled): imports the connected Stripe balance into the
* transactions inbox as a bank-style feed on the 1686 cash account.
*
* Read-only against Stripe, and it never posts to the journal: rows land
* unbooked (or pre-linked to entries the deterministic settle/payout flows
* already created); booking stays a human decision. Idempotent via the
* (company_id, external_id) unique index, so overlapping windows and re-runs
* are no-ops. Unlike the 15-minute event sync, this does not emit events, so
* no ensureInitialized() is needed.
*/
export const GET = withCronContext('cron.stripe_transaction_sync', async (_request, ctx) => {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Missing Supabase configuration' },
})
}
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_CONNECT_CLIENT_ID) {
return NextResponse.json({ message: 'Stripe Connect not configured', processed: 0 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const { data: connections, error: connError } = await supabase
.from('stripe_connections')
.select('*')
.eq('status', 'active')
.eq('transaction_sync_enabled', true)
.order('last_balance_txn_synced_at', { ascending: true, nullsFirst: true })
.limit(50)
if (connError) {
ctx.log.error('failed to fetch stripe connections', connError, {
message: connError.message,
code: connError.code,
})
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
}
if (!connections || connections.length === 0) {
return NextResponse.json({ message: 'No connections with transaction sync enabled', processed: 0 })
}
const startTime = Date.now()
const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration
// Shared with syncStripeBalanceTransactions: it stops between ingest chunks
// and persists its cursor, so a truncated connection resumes next night.
const deadlineMs = startTime + TIME_BUDGET_MS
const results: Array<{
connectionId: string
imported: number
duplicates: number
linked: number
status: 'synced' | 'revoked' | 'error'
}> = []
for (const connection of connections as StripeConnection[]) {
if (Date.now() >= deadlineMs) {
ctx.log.info('time budget reached', { processedSoFar: results.length })
break
}
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.stripe_payments))) {
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
continue
}
try {
const summary = await syncStripeBalanceTransactions(supabase, connection, ctx.log, deadlineMs)
if (summary.deadlineReached) {
ctx.log.info('connection stopped early on time budget; remaining rows resume next run', {
connectionId: connection.id,
})
}
results.push({
connectionId: connection.id,
imported: summary.imported,
duplicates: summary.duplicates,
linked: summary.linked,
status: summary.revoked ? 'revoked' : 'synced',
})
} catch (error) {
ctx.log.error('stripe transaction sync failed for connection', error as Error, {
connectionId: connection.id,
companyId: connection.company_id,
})
results.push({
connectionId: connection.id,
imported: 0,
duplicates: 0,
linked: 0,
status: 'error',
})
}
}
const totals = results.reduce(
(acc, r) => ({
imported: acc.imported + r.imported,
linked: acc.linked + r.linked,
}),
{ imported: 0, linked: 0 },
)
ctx.log.info('stripe transaction sync summary', {
processed: results.length,
totalImported: totals.imported,
totalLinked: totals.linked,
failed: results.filter((r) => r.status === 'error').length,
})
return NextResponse.json({ processed: results.length, ...totals, results })
})
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
import { GET, PATCH } from '../route'
beforeEach(() => {
vi.clearAllMocks()
})
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
supabase: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
function authedForGet(row: { hide_assistant_fab: boolean } | null) {
const maybeSingle = vi.fn().mockResolvedValue({ data: row, error: null })
const supabase = {
from: vi.fn(() => ({
select: vi.fn(() => ({
eq: vi.fn(() => ({ maybeSingle })),
})),
})),
}
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
return { supabase }
}
function authedForPatch(upsertError: { message: string } | null = null) {
const upsert = vi.fn().mockResolvedValue({ error: upsertError })
const supabase = { from: vi.fn(() => ({ upsert })) }
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
return { upsert }
}
function patchRequest(body: unknown) {
return new Request('http://localhost/api/user/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('GET /api/user/preferences', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await GET()
expect(res.status).toBe(401)
})
it('returns the stored preference', async () => {
authedForGet({ hide_assistant_fab: true })
const res = await GET()
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ hide_assistant_fab: true })
})
it('defaults to false when no preferences row exists', async () => {
authedForGet(null)
const res = await GET()
const { body } = await parseJsonResponse<{ data: unknown }>(res)
expect(body.data).toEqual({ hide_assistant_fab: false })
})
})
describe('PATCH /api/user/preferences', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
expect(res.status).toBe(401)
})
it('rejects an invalid body with 400', async () => {
authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: 'yes' }))
expect(res.status).toBe(400)
})
it('rejects unknown keys with 400', async () => {
authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: true, locale: 'en' }))
expect(res.status).toBe(400)
})
it('upserts the preference for the authenticated user', async () => {
const { upsert } = authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ hide_assistant_fab: true })
expect(upsert).toHaveBeenCalledWith(
{ user_id: 'user-1', hide_assistant_fab: true },
{ onConflict: 'user_id' }
)
})
it('returns 500 when the upsert fails', async () => {
authedForPatch({ message: 'boom' })
const res = await PATCH(patchRequest({ hide_assistant_fab: false }))
expect(res.status).toBe(500)
})
})
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
// User-level UI preferences (not company-scoped), stored on user_preferences.
// Mirrors the /api/user/locale pattern: requireAuth directly because these
// must work even when the user has no active company.
const BodySchema = z
.object({
hide_assistant_fab: z.boolean(),
})
.strict()
export async function GET() {
const { user, supabase, error } = await requireAuth()
if (error) return error
const { data } = await supabase
.from('user_preferences')
.select('hide_assistant_fab')
.eq('user_id', user.id)
.maybeSingle()
return NextResponse.json({
data: { hide_assistant_fab: data?.hide_assistant_fab ?? false },
})
}
export async function PATCH(request: Request) {
const { user, supabase, error } = await requireAuth()
if (error) return error
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const parsed = BodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid preferences' }, { status: 400 })
}
const { error: upsertError } = await supabase
.from('user_preferences')
.upsert(
{ user_id: user.id, hide_assistant_fab: parsed.data.hide_assistant_fab },
{ onConflict: 'user_id' }
)
if (upsertError) {
return NextResponse.json({ error: 'Could not save preference' }, { status: 500 })
}
return NextResponse.json({ data: parsed.data })
}