Bug/open banking flow (#854)
* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects We never sent auth_method to Enable Banking, so it fell back to the ASPSP's visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate* PSUs the redirect flow does not support Mobile BankID, so authorization failed right after the user approved in the BankID app. Mobile BankID at Handelsbanken is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses when requested explicitly. Resolve the bank's preferred auth method before /auth: query the ASPSP's auth_methods and pick the DECOUPLED (Mobile BankID) method when present, otherwise leave auth_method unset so banks that already work are untouched. The method name is read dynamically per psu_type, so it is robust across sandbox/production naming. - api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods field name (was available_auth_methods, never populated), add getPreferredAuthMethod(), thread optional authMethod through startAuthorization - index: resolve authMethod in /connect and pass it on both fresh + reconnect - tests: cover method selection and request-body shaping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(invoice-inbox): clean up bulk-selection toolbar UI Redesign the selection toolbar shown when inbox items are checked: one solid primary "Bokför valda" button with outlined secondary actions ("Fråga assistenten", "Ta bort") and a plain selection count. Removes the redundant "Avmarkera" button (users uncheck the still-visible box), fixes label clipping, and gives the toolbar more breathing room. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(entitlements): bypass paywall in local development Add isPaywallBypassed() so all gated capabilities are testable locally without a subscription. Fires only on NODE_ENV=development (npm run dev) or an explicit DISABLE_PAYWALL=true escape hatch — production builds run under NODE_ENV=production and the entitlement suite runs under 'test', so both keep exercising the real gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): implement categorize core for bank transaction categorization - Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations. - Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing. - Implemented fiscal period validation and duplicate booking detection. - Enhanced logging and error handling for transaction categorization. feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata - Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken. - Outputs metadata for business and personal PSU types, including default authentication methods. fix(migrations): increase statement timeout for SIE bulk delete operations - Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports. feat(migrations): add bulk book inbox items to pending operations - Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`. - Supports bulk booking of matched inbox items against bank transactions. test(pg): add tests for replace_period_opening_balance_link RPC - Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow. - Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries. * fix(sie-export): update journal entries and lines handling in SIE export tests * fix(migrations): resolve version collision on 20260629160000 The SIE bulk-delete statement_timeout migration shared version 20260629160000 with journal_entries_list_series_filter (merged from main via #798/#823), causing a schema_migrations_pkey duplicate key error on apply. Rename the branch's migration to 20260629160100. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compliance): resolve compliance-swarm + review findings - opening-balance/correct: compensating rollback for the non-atomic storno+rebook so a mid-sequence failure never leaves two posted OB entries (ASVS V2.3); durable audit event on every failure path (V16); reference the original verifikationsnummer in the corrected entry per BFL 5 kap 5§; document that requireWrite already enforces write-role + membership (V8.2.1 was a false positive) - reports sources routes: validate the cursor date component as ISO (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2), applied to both the VAT-declaration and trial-balance routes - AgentSessionList: await the rename PATCH, revert the optimistic title and toast on failure (ASVS V4.5) - bank booking: exclude same-batch siblings from the booking-time duplicate guard so bulk-booking distinct same-(date,amount) transactions no longer false-positives; pre-existing duplicate detection is preserved - BulkBookInboxDialog: drop the unsafe currency-based reverse_charge default, add an omvänd skattskyldighet advisory, and type VAT options to the backend VatTreatment union - OpeningBalanceRowEditor: hold onChange in a ref (synced in effect, not during render) so an unstable callback can't cause a render loop Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2da9c71eb3
commit
f63d3e3100
+87
-1
@@ -37,7 +37,9 @@ function buildSupabase(
|
||||
}
|
||||
return chain
|
||||
}
|
||||
// journal_entry_lines
|
||||
// journal_entry_lines — terminates on `.range()` (fetchAllRows), which
|
||||
// resolves to the line result. `data.length < PAGE_SIZE` so a single
|
||||
// page is fetched.
|
||||
const chain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
@@ -47,6 +49,7 @@ function buildSupabase(
|
||||
order: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
or: vi.fn().mockReturnThis(),
|
||||
range: vi.fn().mockResolvedValue(linesResult),
|
||||
then: (resolve: (v: unknown) => void) => resolve(linesResult),
|
||||
}
|
||||
return chain
|
||||
@@ -94,6 +97,24 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () =>
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 when the cursor date component is not a structural ISO date', async () => {
|
||||
// Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a
|
||||
// malformed date component must still be rejected structurally.
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildSupabase(
|
||||
{ id: 'user-1' },
|
||||
{ account_number: '1930', account_name: 'Företagskonto' },
|
||||
{ data: [], error: null }
|
||||
) as never
|
||||
)
|
||||
const req = createMockRequest(
|
||||
'/api/reports/trial-balance/account/1930/sources',
|
||||
{ searchParams: { fiscal_period_id: 'period-1', cursor: 'notadate|5' } }
|
||||
)
|
||||
const res = await GET(req, createMockRouteParams({ accountNumber: '1930' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('happy path: returns mapped lines for an account', async () => {
|
||||
const linesData = [
|
||||
{
|
||||
@@ -293,4 +314,69 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () =>
|
||||
expect(body.data.lines[0].journal_entry_id).toBe('je-low') // voucher 5 first
|
||||
expect(body.data.lines[1].journal_entry_id).toBe('je-high') // voucher 20 second
|
||||
})
|
||||
|
||||
it('paginates a >500-line account deterministically regardless of DB return order', async () => {
|
||||
// Regression: with no stable parent ORDER BY, a raw `.limit(500)` returned
|
||||
// an arbitrary subset that varied between identical requests — the
|
||||
// "different rows on every reload" bug for high-volume accounts. We now
|
||||
// fetch the full set and sort/slice in JS, so the first page is always the
|
||||
// 500 chronologically-earliest lines.
|
||||
const total = 600
|
||||
const ordered = Array.from({ length: total }, (_, i) => {
|
||||
const day = String((i % 28) + 1).padStart(2, '0')
|
||||
return {
|
||||
debit_amount: i + 1,
|
||||
credit_amount: 0,
|
||||
journal_entry_id: `je-${String(i).padStart(4, '0')}`,
|
||||
journal_entries: {
|
||||
id: `je-${String(i).padStart(4, '0')}`,
|
||||
voucher_number: i + 1, // unique, monotonic with intended order
|
||||
voucher_series: 'A',
|
||||
entry_date: `2026-${String((i % 12) + 1).padStart(2, '0')}-${day}`,
|
||||
description: `Row ${i}`,
|
||||
status: 'posted',
|
||||
company_id: 'company-1',
|
||||
fiscal_period_id: 'period-1',
|
||||
},
|
||||
}
|
||||
})
|
||||
// Shuffle deterministically so the DB "return order" is not the sorted one.
|
||||
const shuffled = [...ordered].sort((a, b) =>
|
||||
a.journal_entry_id < b.journal_entry_id ? 1 : -1
|
||||
)
|
||||
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildSupabase(
|
||||
{ id: 'user-1' },
|
||||
{ account_number: '3001', account_name: 'Försäljning' },
|
||||
{ data: shuffled, error: null }
|
||||
) as never
|
||||
)
|
||||
|
||||
const req = createMockRequest(
|
||||
'/api/reports/trial-balance/account/3001/sources',
|
||||
{ searchParams: { fiscal_period_id: 'period-1' } }
|
||||
)
|
||||
const res = await GET(req, createMockRouteParams({ accountNumber: '3001' }))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { lines: Array<{ voucher_number: number; date: string }>; next_cursor: string | null }
|
||||
}
|
||||
|
||||
// First page is exactly PAGE_LIMIT rows, fully sorted (date ASC, then
|
||||
// voucher_number ASC — numeric, not lexicographic).
|
||||
expect(body.data.lines).toHaveLength(500)
|
||||
const lines = body.data.lines
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const prev = lines[i - 1]
|
||||
const cur = lines[i]
|
||||
const ordered =
|
||||
prev.date < cur.date ||
|
||||
(prev.date === cur.date && prev.voucher_number <= cur.voucher_number)
|
||||
expect(ordered).toBe(true)
|
||||
}
|
||||
// More rows remain → a cursor is returned pointing at the last delivered row.
|
||||
expect(body.data.next_cursor).toBe(`${lines[499].date}|${lines[499].voucher_number}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { ReportSourceLine } from '@/lib/reports/source-lines'
|
||||
|
||||
/**
|
||||
@@ -55,60 +56,66 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
|
||||
// Pull all lines on this account in this period. We rely on the same
|
||||
// join+filter pattern as `generateTrialBalance`. Pagination is server-side
|
||||
// via cursor so even an account with tens of thousands of rows stays cheap.
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry_id,
|
||||
journal_entries!inner(
|
||||
id,
|
||||
voucher_number,
|
||||
voucher_series,
|
||||
entry_date,
|
||||
description,
|
||||
status,
|
||||
company_id,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.eq('account_number', accountNumber)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.limit(PAGE_LIMIT + 1)
|
||||
|
||||
// Parse the optional cursor up front (format: <iso-date>|<voucher_number>).
|
||||
// Pagination is applied in JS after a full, deterministically-ordered fetch.
|
||||
let cursorDate: string | null = null
|
||||
let cursorVoucherNum = 0
|
||||
if (cursor) {
|
||||
// Cursor format: <iso-date>|<voucher_number>
|
||||
const [cursorDate, cursorVoucher] = cursor.split('|')
|
||||
const cursorVoucherNum = parseInt(cursorVoucher, 10)
|
||||
if (!cursorDate || isNaN(cursorVoucherNum)) {
|
||||
const [cd, cv] = cursor.split('|')
|
||||
cursorVoucherNum = parseInt(cv, 10)
|
||||
// The cursor is applied in JS (string compare); structurally validating the
|
||||
// date component here is defense-in-depth against malformed/injection cursors.
|
||||
if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) {
|
||||
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
|
||||
}
|
||||
// Filter for rows strictly after the cursor (date>cur OR same date & voucher>cur).
|
||||
// Supabase doesn't expose tuple compare, so use an `or()` clause.
|
||||
query = query.or(
|
||||
`entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
|
||||
{ foreignTable: 'journal_entries' }
|
||||
)
|
||||
cursorDate = cd
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
// Pull ALL lines on this account in this period, then sort + paginate in JS.
|
||||
//
|
||||
// Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST
|
||||
// sorts the *embedded* resource's rows, not the parent result set, so it
|
||||
// cannot give us a chronological parent order. Without a stable parent order
|
||||
// a raw `.limit()` returns an arbitrary subset that varies between identical
|
||||
// requests — which surfaced as the trial-balance drill-down showing
|
||||
// "different rows on every reload" for high-volume accounts. We instead page
|
||||
// on the line PK (`id`) for a stable total order (see fetch-all.ts) and do
|
||||
// the chronological sort here, mirroring `generateGeneralLedger`.
|
||||
const rows = await fetchAllRows<{
|
||||
id: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
journal_entries: any
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
id,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry_id,
|
||||
journal_entries!inner(
|
||||
id,
|
||||
voucher_number,
|
||||
voucher_series,
|
||||
entry_date,
|
||||
description,
|
||||
status,
|
||||
company_id,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.eq('account_number', accountNumber)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to), { dedupeBy: (r) => r.id })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = (data || []) as any[]
|
||||
|
||||
// Map all rows then sort in JS (date ASC, voucher_number ASC).
|
||||
// .order({ foreignTable }) in Supabase sorts the embedded resource's rows,
|
||||
// not the parent result set, so we cannot rely on DB ordering here.
|
||||
// This mirrors the sort in generateGeneralLedger.
|
||||
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
|
||||
// a final deterministic tiebreak for lines sharing a date and voucher number
|
||||
// across series).
|
||||
const allMapped: ReportSourceLine[] = rows.map((row) => ({
|
||||
journal_entry_id: row.journal_entries.id,
|
||||
voucher_number: row.journal_entries.voucher_number,
|
||||
@@ -120,14 +127,26 @@ export async function GET(
|
||||
}))
|
||||
allMapped.sort((a, b) => {
|
||||
const dateComp = a.date.localeCompare(b.date)
|
||||
return dateComp !== 0 ? dateComp : a.voucher_number - b.voucher_number
|
||||
if (dateComp !== 0) return dateComp
|
||||
if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number
|
||||
return a.journal_entry_id.localeCompare(b.journal_entry_id)
|
||||
})
|
||||
const lines = allMapped.slice(0, PAGE_LIMIT)
|
||||
|
||||
// If we got more than PAGE_LIMIT rows back, the next cursor points at the
|
||||
// last delivered row so the next call resumes from after it.
|
||||
// Apply the cursor in JS: keep rows strictly after (date, voucher_number).
|
||||
const afterCursor = cursorDate
|
||||
? allMapped.filter(
|
||||
(l) =>
|
||||
l.date > cursorDate! ||
|
||||
(l.date === cursorDate! && l.voucher_number > cursorVoucherNum)
|
||||
)
|
||||
: allMapped
|
||||
|
||||
const lines = afterCursor.slice(0, PAGE_LIMIT)
|
||||
|
||||
// If more rows remain beyond this page, point the next cursor at the last
|
||||
// delivered row so the next call resumes from after it.
|
||||
let next_cursor: string | null = null
|
||||
if (rows.length > PAGE_LIMIT && lines.length > 0) {
|
||||
if (afterCursor.length > PAGE_LIMIT && lines.length > 0) {
|
||||
const last = lines[lines.length - 1]
|
||||
next_cursor = `${last.date}|${last.voucher_number}`
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ function buildSupabase(
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
or: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
// journal_entry_lines terminates on `.range()` (fetchAllRows).
|
||||
range: vi.fn().mockResolvedValue(linesResult),
|
||||
then: (resolve: (v: unknown) => void) => resolve(linesResult),
|
||||
})),
|
||||
}
|
||||
@@ -117,4 +119,95 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
|
||||
expect(body.data.lines[0].voucher_number).toBe(12)
|
||||
expect(body.data.lines[0].credit).toBe(250)
|
||||
})
|
||||
|
||||
it('returns 400 when the cursor date component is not a structural ISO date', async () => {
|
||||
// Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a
|
||||
// malformed date component must still be rejected structurally.
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never
|
||||
)
|
||||
const req = createMockRequest(
|
||||
'/api/reports/vat-declaration/ruta/10/sources',
|
||||
{
|
||||
searchParams: {
|
||||
periodType: 'monthly',
|
||||
year: '2026',
|
||||
period: '5',
|
||||
cursor: 'notadate|5',
|
||||
},
|
||||
}
|
||||
)
|
||||
const res = await GET(req, createMockRouteParams({ ruta: '10' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('sorts lines by entry_date ASC then voucher_number ASC regardless of DB return order', async () => {
|
||||
// Regression: this endpoint relied on `.order({ foreignTable })`, which
|
||||
// sorts the embedded resource — not the parent — so lines came back in
|
||||
// arbitrary order and the drill-down showed "different rows on reload".
|
||||
const linesData = [
|
||||
{
|
||||
account_number: '2611',
|
||||
debit_amount: 0,
|
||||
credit_amount: 500,
|
||||
journal_entries: {
|
||||
id: 'je-late',
|
||||
voucher_number: 30,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-05-20',
|
||||
description: 'Late',
|
||||
status: 'posted',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
account_number: '2611',
|
||||
debit_amount: 0,
|
||||
credit_amount: 100,
|
||||
journal_entries: {
|
||||
id: 'je-early',
|
||||
voucher_number: 4,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-05-02',
|
||||
description: 'Early',
|
||||
status: 'posted',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
account_number: '2611',
|
||||
debit_amount: 0,
|
||||
credit_amount: 250,
|
||||
journal_entries: {
|
||||
id: 'je-mid',
|
||||
voucher_number: 18,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-05-11',
|
||||
description: 'Mid',
|
||||
status: 'posted',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
},
|
||||
]
|
||||
mockCreateClient.mockResolvedValue(
|
||||
buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never
|
||||
)
|
||||
|
||||
const req = createMockRequest(
|
||||
'/api/reports/vat-declaration/ruta/10/sources',
|
||||
{ searchParams: { periodType: 'monthly', year: '2026', period: '5' } }
|
||||
)
|
||||
const res = await GET(req, createMockRouteParams({ ruta: '10' }))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { lines: Array<{ journal_entry_id: string }> }
|
||||
}
|
||||
|
||||
expect(body.data.lines.map((l) => l.journal_entry_id)).toEqual([
|
||||
'je-early',
|
||||
'je-mid',
|
||||
'je-late',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
ACCOUNT_RUTA,
|
||||
calculatePeriodDates,
|
||||
@@ -93,66 +94,94 @@ export async function GET(
|
||||
end = dates.end
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
account_number,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entries!inner(
|
||||
id,
|
||||
voucher_number,
|
||||
voucher_series,
|
||||
entry_date,
|
||||
description,
|
||||
status,
|
||||
company_id
|
||||
)
|
||||
`)
|
||||
.in('account_number', accountsForRuta)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.gte('journal_entries.entry_date', start)
|
||||
.lte('journal_entries.entry_date', end)
|
||||
.order('entry_date', { foreignTable: 'journal_entries', ascending: true })
|
||||
.order('voucher_number', { foreignTable: 'journal_entries', ascending: true })
|
||||
.limit(PAGE_LIMIT + 1)
|
||||
|
||||
// Parse the optional cursor up front (format: <iso-date>|<voucher_number>).
|
||||
// Pagination is applied in JS after a full, deterministically-ordered fetch.
|
||||
let cursorDate: string | null = null
|
||||
let cursorVoucherNum = 0
|
||||
if (cursor) {
|
||||
const [cursorDate, cursorVoucher] = cursor.split('|')
|
||||
const cursorVoucherNum = parseInt(cursorVoucher, 10)
|
||||
if (!cursorDate || isNaN(cursorVoucherNum)) {
|
||||
const [cd, cv] = cursor.split('|')
|
||||
cursorVoucherNum = parseInt(cv, 10)
|
||||
// The cursor is applied in JS (string compare); structurally validating the
|
||||
// date component here is defense-in-depth against malformed/injection cursors.
|
||||
if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) {
|
||||
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
|
||||
}
|
||||
query = query.or(
|
||||
`entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
|
||||
{ foreignTable: 'journal_entries' }
|
||||
)
|
||||
cursorDate = cd
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
// Pull ALL contributing lines, then sort + paginate in JS.
|
||||
//
|
||||
// Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST
|
||||
// sorts the *embedded* resource's rows, not the parent result set, so it
|
||||
// cannot give us a chronological parent order. Without a stable parent order
|
||||
// a raw `.limit()` returns an arbitrary subset that varies between identical
|
||||
// requests, making the drill-down show "different rows on every reload". We
|
||||
// page on the line PK (`id`) for a stable total order (see fetch-all.ts) and
|
||||
// do the chronological sort here, mirroring `generateGeneralLedger` and the
|
||||
// trial-balance sources route.
|
||||
const rows = await fetchAllRows<{
|
||||
id: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
journal_entries: any
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
id,
|
||||
account_number,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entries!inner(
|
||||
id,
|
||||
voucher_number,
|
||||
voucher_series,
|
||||
entry_date,
|
||||
description,
|
||||
status,
|
||||
company_id
|
||||
)
|
||||
`)
|
||||
.in('account_number', accountsForRuta)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.gte('journal_entries.entry_date', start)
|
||||
.lte('journal_entries.entry_date', end)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to), { dedupeBy: (r) => r.id })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
|
||||
// a final deterministic tiebreak).
|
||||
const allMapped: ReportSourceLine[] = rows.map((row) => ({
|
||||
journal_entry_id: row.journal_entries.id,
|
||||
voucher_number: row.journal_entries.voucher_number,
|
||||
voucher_series: row.journal_entries.voucher_series || 'A',
|
||||
date: row.journal_entries.entry_date,
|
||||
description: row.journal_entries.description || '',
|
||||
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
|
||||
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
|
||||
}))
|
||||
allMapped.sort((a, b) => {
|
||||
const dateComp = a.date.localeCompare(b.date)
|
||||
if (dateComp !== 0) return dateComp
|
||||
if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number
|
||||
return a.journal_entry_id.localeCompare(b.journal_entry_id)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = (data || []) as any[]
|
||||
// Apply the cursor in JS: keep rows strictly after (date, voucher_number).
|
||||
const afterCursor = cursorDate
|
||||
? allMapped.filter(
|
||||
(l) =>
|
||||
l.date > cursorDate! ||
|
||||
(l.date === cursorDate! && l.voucher_number > cursorVoucherNum)
|
||||
)
|
||||
: allMapped
|
||||
|
||||
const lines: ReportSourceLine[] = rows
|
||||
.slice(0, PAGE_LIMIT)
|
||||
.map((row) => ({
|
||||
journal_entry_id: row.journal_entries.id,
|
||||
voucher_number: row.journal_entries.voucher_number,
|
||||
voucher_series: row.journal_entries.voucher_series || 'A',
|
||||
date: row.journal_entries.entry_date,
|
||||
description: row.journal_entries.description || '',
|
||||
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
|
||||
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
|
||||
}))
|
||||
const lines = afterCursor.slice(0, PAGE_LIMIT)
|
||||
|
||||
let next_cursor: string | null = null
|
||||
if (rows.length > PAGE_LIMIT && lines.length > 0) {
|
||||
if (afterCursor.length > PAGE_LIMIT && lines.length > 0) {
|
||||
const last = lines[lines.length - 1]
|
||||
next_cursor = `${last.date}|${last.voucher_number}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user