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:
co-authored by
Claude Fable 5
parent
be9d630347
commit
d840257c0c
@@ -4,7 +4,7 @@ vi.mock('../trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateResultatrapport } from '../resultatrapport'
|
||||
import { generateResultatrapport, shiftDateOneYearBack } from '../resultatrapport'
|
||||
import { generateTrialBalance } from '../trial-balance'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
@@ -16,7 +16,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
|
||||
return {
|
||||
const row: TrialBalanceRow = {
|
||||
account_number: '3001',
|
||||
account_name: 'Test',
|
||||
account_class: 3,
|
||||
@@ -28,6 +28,14 @@ function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
|
||||
closing_credit: 0,
|
||||
...overrides,
|
||||
}
|
||||
// Full-period P&L reality: no opening balance, so window activity equals
|
||||
// closing. Tests specify closing_*; mirror into period_* unless the test
|
||||
// sets period activity explicitly.
|
||||
if (row.period_debit === 0 && row.period_credit === 0) {
|
||||
row.period_debit = row.closing_debit
|
||||
row.period_credit = row.closing_credit
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function tb(rows: TrialBalanceRow[]) {
|
||||
@@ -279,4 +287,184 @@ describe('generateResultatrapport', () => {
|
||||
generateResultatrapport(q.supabase as any, 'company-1', 'missing')
|
||||
).rejects.toThrow('Fiscal period not found')
|
||||
})
|
||||
|
||||
it('compares a date range against the same window shifted one year back', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
|
||||
error: null,
|
||||
})
|
||||
// Fiscal periods covering the shifted window 2025-01-01..2025-03-31.
|
||||
q.enqueue({
|
||||
data: [{ id: 'period-0', period_start: '2025-01-01', period_end: '2025-12-31' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 90000 })])
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 60000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-01-01',
|
||||
toDate: '2026-03-31',
|
||||
})
|
||||
|
||||
expect(report.groups[0].rows[0].current_period).toBe(90000)
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(60000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-03-31' })
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0', {
|
||||
fromDate: '2025-01-01',
|
||||
toDate: '2025-03-31',
|
||||
})
|
||||
})
|
||||
|
||||
it('merges the shifted window across two fiscal periods (brutet räkenskapsår)', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
// Current period: brutet räkenskapsår Jul 2025 - Jun 2026; window is the
|
||||
// calendar Q1 2026, so the shifted window Jan-Mar 2025 spans FY 24/25
|
||||
// only. Use a window that straddles instead: Jun-Jul 2026 shifted to
|
||||
// Jun-Jul 2025, split across FY 24/25 (ends Jun 30) and FY 25/26.
|
||||
q.enqueue({
|
||||
data: { period_start: '2025-07-01', period_end: '2026-06-30', previous_period_id: 'period-0' },
|
||||
error: null,
|
||||
})
|
||||
q.enqueue({
|
||||
data: [
|
||||
{ id: 'period-old', period_start: '2024-07-01', period_end: '2025-06-30' },
|
||||
{ id: 'period-1', period_start: '2025-07-01', period_end: '2026-06-30' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance
|
||||
// Current window
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 50000 })])
|
||||
)
|
||||
// Prior part 1: 2025-06-01..2025-06-30 in period-old
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 10000 })])
|
||||
)
|
||||
// Prior part 2: 2025-07-01..2025-07-31 in period-1 (current period is a
|
||||
// legitimate source when the shifted window reaches into it)
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 15000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-06-01',
|
||||
toDate: '2026-07-31',
|
||||
})
|
||||
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(25000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-06-01', end: '2025-07-31' })
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-old', {
|
||||
fromDate: '2025-06-01',
|
||||
toDate: '2025-06-30',
|
||||
})
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(3, expect.anything(), 'company-1', 'period-1', {
|
||||
fromDate: '2025-07-01',
|
||||
toDate: '2025-07-31',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the comparison when the shifted window would overlap the current one', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
// 18-month fiscal period with a 14-month window: shifting back one year
|
||||
// overlaps the window itself, so no comparison is possible.
|
||||
q.enqueue({
|
||||
data: { period_start: '2025-01-01', period_end: '2026-06-30', previous_period_id: null },
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2025-01-01',
|
||||
toDate: '2026-02-28',
|
||||
})
|
||||
|
||||
expect(report.prior_period).toBeNull()
|
||||
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still drops the comparison for dimension-filtered ranges', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
mockTrialBalance.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-01-01',
|
||||
toDate: '2026-03-31',
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
expect(report.prior_period).toBeNull()
|
||||
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports window activity, not rolled-forward YTD closing', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
|
||||
error: null,
|
||||
})
|
||||
q.enqueue({ data: [], error: null }) // no fiscal period covers the shifted window
|
||||
|
||||
// A June window: trial balance rolls Jan-May (60 000) into opening, so
|
||||
// closing shows 100 000 YTD while the window's own activity is 40 000.
|
||||
mockTrialBalance.mockResolvedValueOnce(
|
||||
tb([
|
||||
makeRow({
|
||||
account_number: '3001',
|
||||
account_class: 3,
|
||||
opening_credit: 60000,
|
||||
period_credit: 40000,
|
||||
closing_credit: 100000,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
|
||||
fromDate: '2026-06-01',
|
||||
toDate: '2026-06-30',
|
||||
})
|
||||
|
||||
expect(report.groups[0].rows[0].current_period).toBe(40000)
|
||||
expect(report.net_result_current).toBe(40000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shiftDateOneYearBack', () => {
|
||||
it('shifts a plain date one year back', () => {
|
||||
expect(shiftDateOneYearBack('2026-03-15')).toBe('2025-03-15')
|
||||
expect(shiftDateOneYearBack('2026-01-01')).toBe('2025-01-01')
|
||||
expect(shiftDateOneYearBack('2026-12-31')).toBe('2025-12-31')
|
||||
})
|
||||
|
||||
it('clamps leap day to the last day of February', () => {
|
||||
expect(shiftDateOneYearBack('2028-02-29')).toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('keeps Feb 29 when the target year is also a leap year divisible correctly', () => {
|
||||
// 2001 -> 2000 is a leap year (divisible by 400)
|
||||
expect(shiftDateOneYearBack('2001-02-28')).toBe('2000-02-28')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -845,6 +845,10 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
{ name: 'account_dimension_rules', file: 'account_dimension_rules.json' },
|
||||
// Compliance records
|
||||
{ name: 'voucher_gap_explanations', file: 'voucher_gap_explanations.json', orderBy: 'created_at' },
|
||||
// Inline rättelse trail (BFL 5 kap 5 § / 9 §): holds the struck original
|
||||
// lines and the old description/date, i.e. the preserved side of every
|
||||
// in-verifikat rättelse — räkenskapsinformation, not an operation log.
|
||||
{ name: 'journal_entry_rattelse_log', file: 'journal_entry_rattelse_log.json', orderBy: 'created_at' },
|
||||
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
|
||||
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
|
||||
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
|
||||
|
||||
@@ -60,17 +60,17 @@ export async function generateResultatrapport(
|
||||
})
|
||||
const currentRows = filterPnl(currentTb.rows)
|
||||
|
||||
// Prior-period comparison stays full-year. A narrower current window
|
||||
// compared against a full prior year would be misleading; until we ship a
|
||||
// proper "same window, prior year" comparison the cleanest move is to
|
||||
// drop the prior column entirely when the user narrows the range.
|
||||
// Same rule for a dimension filter: project codes are time-limited under
|
||||
// K2/K3 (registry start/end dates), so "this code last year" may be a
|
||||
// different project entirely: drop the column rather than compare
|
||||
// Prior-period comparison. Full period: the previous fiscal period.
|
||||
// Narrowed range: the same window shifted one year back (#862), so
|
||||
// "Hittills i år" compares against the same dates last year.
|
||||
// Dimension filter: no comparison at all; project codes are time-limited
|
||||
// under K2/K3 (registry start/end dates), so "this code last year" may be
|
||||
// a different project entirely: drop the column rather than compare
|
||||
// unrelated activity (#862 review).
|
||||
let priorRows: TrialBalanceRow[] = []
|
||||
let priorPeriodInfo: { start: string; end: string } | null = null
|
||||
const isFullPeriod = !options?.fromDate && !options?.toDate && !options?.dimensions
|
||||
const hasRange = Boolean(options?.fromDate || options?.toDate)
|
||||
const isFullPeriod = !hasRange && !options?.dimensions
|
||||
if (isFullPeriod) {
|
||||
// Prefer the explicit continuity chain; fall back to the period that ends
|
||||
// immediately before this one. The fallback keeps the comparison working
|
||||
@@ -102,6 +102,51 @@ export async function generateResultatrapport(
|
||||
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
|
||||
}
|
||||
}
|
||||
} else if (hasRange && !options?.dimensions) {
|
||||
// Same window, prior year. Shift the window back one year (leap-day
|
||||
// clamped) and read activity from whichever fiscal period(s) cover the
|
||||
// shifted dates: brutet räkenskapsår can split a calendar window across
|
||||
// two periods, so merge per account. The current period itself is a
|
||||
// valid source (a long first fiscal year can contain both windows).
|
||||
const shiftedFrom = shiftDateOneYearBack(effectiveFromDate)
|
||||
const shiftedTo = shiftDateOneYearBack(effectiveToDate)
|
||||
// A window longer than a year would overlap itself when shifted back;
|
||||
// that comparison is meaningless, so leave the column empty.
|
||||
if (shiftedTo < effectiveFromDate) {
|
||||
const { data: candidatePeriods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', shiftedTo)
|
||||
.gte('period_end', shiftedFrom)
|
||||
.order('period_start', { ascending: true })
|
||||
|
||||
const merged = new Map<string, TrialBalanceRow>()
|
||||
let coveredAny = false
|
||||
for (const p of candidatePeriods ?? []) {
|
||||
const from = shiftedFrom > p.period_start ? shiftedFrom : p.period_start
|
||||
const to = shiftedTo < p.period_end ? shiftedTo : p.period_end
|
||||
if (from > to) continue
|
||||
const tbPart = await generateTrialBalance(supabase, companyId, p.id, {
|
||||
fromDate: from,
|
||||
toDate: to,
|
||||
})
|
||||
coveredAny = true
|
||||
for (const row of filterPnl(tbPart.rows)) {
|
||||
const existing = merged.get(row.account_number)
|
||||
if (!existing) {
|
||||
merged.set(row.account_number, { ...row })
|
||||
} else {
|
||||
existing.period_debit = round2(existing.period_debit + row.period_debit)
|
||||
existing.period_credit = round2(existing.period_credit + row.period_credit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (coveredAny) {
|
||||
priorRows = [...merged.values()]
|
||||
priorPeriodInfo = { start: shiftedFrom, end: shiftedTo }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priorByAccount = new Map<string, TrialBalanceRow>()
|
||||
@@ -135,9 +180,28 @@ function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
|
||||
* (class 4-7) have debit. We render every line as `credit - debit` so that
|
||||
* revenue is positive, expenses are negative, and a positive net result
|
||||
* means profit. This matches how Fortnox and Visma present a Resultatrapport.
|
||||
*
|
||||
* Window activity (`period_*`), not `closing_*`: when fromDate > period_start
|
||||
* the trial balance rolls pre-window activity into the opening columns, so
|
||||
* `closing_*` on a P&L account would silently report year-to-date amounts in
|
||||
* a month/quarter window. In the full-period case P&L accounts carry no
|
||||
* opening balance, so period_* equals closing_* and nothing changes there.
|
||||
*/
|
||||
function signedAmount(row: TrialBalanceRow): number {
|
||||
return row.closing_credit - row.closing_debit
|
||||
return row.period_credit - row.period_debit
|
||||
}
|
||||
|
||||
/**
|
||||
* `2026-03-15` -> `2025-03-15`; leap day clamps to the target month's last
|
||||
* day (`2028-02-29` -> `2027-02-28`). String math on the ISO parts: no Date
|
||||
* object, no timezone edge.
|
||||
*/
|
||||
export function shiftDateOneYearBack(isoDate: string): string {
|
||||
const [y, m, d] = isoDate.split('-').map(Number)
|
||||
const year = y - 1
|
||||
const daysInMonth = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
const day = Math.min(d, daysInMonth[m - 1])
|
||||
return `${year}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function sumNet(rows: TrialBalanceRow[]): number {
|
||||
|
||||
Reference in New Issue
Block a user