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:
Mattsson
2026-07-01 18:13:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2da9c71eb3
commit f63d3e3100
83 changed files with 6769 additions and 1360 deletions
@@ -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}`
}