chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
18cbc4c30a
commit
f266c386f3
@@ -37,9 +37,9 @@ import type { InboxChannelContext, InvoiceExtractionResult } from '@/types'
|
||||
|
||||
/**
|
||||
* Confidence floor for surfacing an unmatched item as a probable underlag.
|
||||
* Deliberately above core-receipt-matcher's MIN_MATCH_CONFIDENCE (0.4): that
|
||||
* floor governs a picker where a human reads a ranked list and judges, whereas
|
||||
* a candidate named here is read by an agent that will reason from it. A wrong
|
||||
* Deliberately stricter than the receipt picker, which ranks candidates without
|
||||
* a hard floor: there a human reads a ranked list and judges, whereas a
|
||||
* candidate named here is read by an agent that will reason from it. A wrong
|
||||
* receipt on the wrong transaction is a mis-booking, so this surface trades
|
||||
* recall for precision and leaves the rest to the picker.
|
||||
*/
|
||||
|
||||
@@ -40,8 +40,3 @@ const INTENTS: AgentIntent<any, any>[] = [
|
||||
export function getIntent(id: string): AgentIntent<any, any> | undefined {
|
||||
return INTENTS.find((i) => i.id === id)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function listIntents(): AgentIntent<any, any>[] {
|
||||
return INTENTS
|
||||
}
|
||||
|
||||
+2
-18
@@ -1,29 +1,18 @@
|
||||
import { getAiStatus, readAiConfig, type ResolvedAiConfig } from './config'
|
||||
import { readAiConfig, type ResolvedAiConfig } from './config'
|
||||
import { createAnthropicFamilyService } from './services/anthropic-family'
|
||||
import { createOpenAICompatibleService } from './services/openai-compatible'
|
||||
import type { AiService } from './types'
|
||||
|
||||
export type {
|
||||
AiCapabilities,
|
||||
AiChatTurn,
|
||||
AiDocumentInput,
|
||||
AiImageMediaType,
|
||||
AiPdfMode,
|
||||
AiProviderKind,
|
||||
AiService,
|
||||
AiToolDef,
|
||||
AiStatus,
|
||||
AiTier,
|
||||
AiUsage,
|
||||
ExtractFromDocumentRequest,
|
||||
ExtractFromDocumentResult,
|
||||
ExtractionSkipReason,
|
||||
GenerateStructuredRequest,
|
||||
GenerateStructuredResult,
|
||||
GenerateTextRequest,
|
||||
GenerateTextResult,
|
||||
} from './types'
|
||||
export { getAiStatus, readAiConfig, resolveTierModel } from './config'
|
||||
export { getAiStatus, readAiConfig } from './config'
|
||||
export { extractJsonObject } from './json'
|
||||
|
||||
// One service per resolved configuration. Keyed on the non-secret parts of
|
||||
@@ -61,8 +50,3 @@ export function getAiService(): AiService {
|
||||
cached = { key, service }
|
||||
return service
|
||||
}
|
||||
|
||||
/** Tests only: drop the cached service so the next call re-reads the environment. */
|
||||
export function resetAiServiceForTests(): void {
|
||||
cached = null
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,5 +1,7 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
import type { AiProviderKind } from './types'
|
||||
|
||||
|
||||
/**
|
||||
* Which backend AI traffic goes to.
|
||||
@@ -20,7 +22,8 @@ import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
*
|
||||
* See https://github.com/erp-mafia/accounted/issues/1406.
|
||||
*/
|
||||
export type AiProvider = 'bedrock' | 'anthropic' | 'openai-compatible'
|
||||
export type AiProvider = AiProviderKind
|
||||
|
||||
|
||||
/**
|
||||
* Thrown by createAiClient() when the resolved backend has no Anthropic
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { createTokenRateLimiter } from '../token-rate-limit'
|
||||
|
||||
describe('createTokenRateLimiter', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('allows up to max requests per window, then refuses', () => {
|
||||
const limiter = createTokenRateLimiter({ max: 3, windowMs: 60_000 })
|
||||
expect(limiter.allow('a')).toBe(true)
|
||||
expect(limiter.allow('a')).toBe(true)
|
||||
expect(limiter.allow('a')).toBe(true)
|
||||
expect(limiter.allow('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('keys the budget per token', () => {
|
||||
const limiter = createTokenRateLimiter({ max: 1, windowMs: 60_000 })
|
||||
expect(limiter.allow('a')).toBe(true)
|
||||
expect(limiter.allow('b')).toBe(true)
|
||||
expect(limiter.allow('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('resets the budget once the window has passed', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
const limiter = createTokenRateLimiter({ max: 1, windowMs: 60_000 })
|
||||
expect(limiter.allow('a')).toBe(true)
|
||||
expect(limiter.allow('a')).toBe(false)
|
||||
vi.setSystemTime(new Date('2026-01-01T00:01:00.001Z'))
|
||||
expect(limiter.allow('a')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps separate limiters isolated', () => {
|
||||
const one = createTokenRateLimiter({ max: 1, windowMs: 60_000 })
|
||||
const two = createTokenRateLimiter({ max: 1, windowMs: 60_000 })
|
||||
expect(one.allow('a')).toBe(true)
|
||||
expect(two.allow('a')).toBe(true)
|
||||
expect(one.allow('a')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { NextResponse } from 'next/server'
|
||||
|
||||
export const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' } as const
|
||||
|
||||
export function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
|
||||
@@ -221,18 +221,10 @@ export const SupplierTypeSchema = z.enum([
|
||||
'non_eu_business',
|
||||
])
|
||||
|
||||
export const InvoiceStatusSchema = z.enum([
|
||||
'draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited',
|
||||
])
|
||||
|
||||
export const InvoiceDocumentTypeSchema = z.enum([
|
||||
'invoice', 'proforma', 'delivery_note',
|
||||
])
|
||||
|
||||
export const SupplierInvoiceStatusSchema = z.enum([
|
||||
'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited',
|
||||
])
|
||||
|
||||
export const VatTreatmentSchema = z.enum([
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
|
||||
])
|
||||
@@ -1775,7 +1767,6 @@ export const BulkBookInboxSchema = z.object({
|
||||
// staged-params reason as the fields above.
|
||||
dimensions: DimensionsBagSchema.nullish().transform((v) => v ?? undefined),
|
||||
})
|
||||
export type BulkBookInboxInput = z.infer<typeof BulkBookInboxSchema>
|
||||
|
||||
export const MatchInvoiceSchema = z
|
||||
.object({
|
||||
@@ -2531,12 +2522,6 @@ export const VatDeclarationQuerySchema = z.object({
|
||||
period: z.coerce.number().int().min(1).max(12),
|
||||
})
|
||||
|
||||
export const ReportPeriodQuerySchema = z.object({
|
||||
fiscal_period_id: uuid.optional(),
|
||||
year: z.coerce.number().int().min(2000).max(2100).optional(),
|
||||
month: z.coerce.number().int().min(1).max(12).optional(),
|
||||
})
|
||||
|
||||
export const AccountBalancesQuerySchema = z.object({
|
||||
accounts: z
|
||||
.string()
|
||||
@@ -2814,7 +2799,6 @@ export const EmploymentTypeSchema = z.enum(['employee', 'company_owner', 'board_
|
||||
export const SalaryTypeSchema = z.enum(['monthly', 'hourly'])
|
||||
export const FSkattStatusSchema = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'])
|
||||
export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln', 'none', 'semesterersattning'])
|
||||
export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
|
||||
|
||||
export const SalaryLineItemTypeSchema = z.enum([
|
||||
'monthly_salary', 'hourly_salary',
|
||||
@@ -3409,90 +3393,6 @@ export const BatchUpsertWorkedDaysSchema = z
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// AI agent flow schemas
|
||||
// ============================================================
|
||||
|
||||
const BookingProposalLineSchema = z.object({
|
||||
account_number: accountNumber,
|
||||
debit_amount: nonNegativeAmount,
|
||||
credit_amount: nonNegativeAmount,
|
||||
description: z.string().min(1).max(500),
|
||||
})
|
||||
|
||||
const BookingProposalCounterpartyTemplateSchema = z.object({
|
||||
counterparty_name: z.string().min(1).max(200),
|
||||
debit_account: accountNumber,
|
||||
credit_account: accountNumber,
|
||||
vat_treatment: VatTreatmentSchema.nullable(),
|
||||
category: TransactionCategorySchema.nullable(),
|
||||
})
|
||||
|
||||
// Edit payload: the user's edited version of a booking proposal. Used in
|
||||
// the /accept endpoint when the user adjusted accounts/VAT before approving.
|
||||
export const EditBookingProposalSchema = z.object({
|
||||
lines: z.array(BookingProposalLineSchema).min(2),
|
||||
vat_treatment: VatTreatmentSchema.nullable(),
|
||||
default_private: z.boolean(),
|
||||
counterparty_template_proposal: BookingProposalCounterpartyTemplateSchema.nullable(),
|
||||
fiscal_period_id: uuid,
|
||||
entry_date: isoDate,
|
||||
description: z.string().min(1).max(500),
|
||||
})
|
||||
|
||||
// For match proposals, editing just means picking a different transaction.
|
||||
export const EditMatchProposalSchema = z.object({
|
||||
matched_transaction_id: uuid,
|
||||
})
|
||||
|
||||
export const AcceptProposalSchema = z.object({
|
||||
version: z.number().int().nonnegative(),
|
||||
edits: z.union([EditBookingProposalSchema, EditMatchProposalSchema]).optional(),
|
||||
})
|
||||
|
||||
// Change the matched transaction on a pending match proposal without
|
||||
// accepting it. Source tells us whether the user picked one of the AI's
|
||||
// own alternatives, an AI-regenerated suggestion, or a manually-chosen
|
||||
// transaction: kept on edit_diff for learning signal.
|
||||
export const ChangeMatchProposalSchema = z.object({
|
||||
version: z.number().int().nonnegative(),
|
||||
matched_transaction_id: uuid,
|
||||
source: z.enum(['user_alternative', 'user_manual', 'ai_regenerated']),
|
||||
})
|
||||
|
||||
export const RejectProposalSchema = z.object({
|
||||
version: z.number().int().nonnegative(),
|
||||
reason: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export const BatchAcceptSchema = z.object({
|
||||
proposal_ids: z.array(uuid).min(1).max(50),
|
||||
})
|
||||
|
||||
export const ResolveRequestSchema = z.object({
|
||||
response: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const StartBackfillSchema = z.object({}).strict()
|
||||
|
||||
export const RememberLearningSchema = z.object({
|
||||
proposal_id: uuid,
|
||||
counterparty_name: z.string().min(1).max(200),
|
||||
debit_account: accountNumber,
|
||||
credit_account: accountNumber,
|
||||
vat_treatment: VatTreatmentSchema.nullable(),
|
||||
category: TransactionCategorySchema.nullable(),
|
||||
})
|
||||
|
||||
export const ListProposalsQuerySchema = z.object({
|
||||
status: z
|
||||
.enum(['pending', 'accepted', 'rejected', 'skipped', 'invalidated'])
|
||||
.optional(),
|
||||
step_type: z.enum(['match', 'booking']).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
export const AttachDocumentSchema = z.object({
|
||||
document_id: uuid,
|
||||
})
|
||||
@@ -3518,71 +3418,6 @@ export const UnderlagImportPreviewSchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Shift-premium rules (OB-tillägg och övertid)
|
||||
// ============================================================
|
||||
|
||||
export const ShiftPremiumItemTypeSchema = z.enum([
|
||||
'overtime_50',
|
||||
'overtime_100',
|
||||
'ob_weekday_evening',
|
||||
'ob_weekend',
|
||||
'ob_night',
|
||||
'ob_holiday',
|
||||
])
|
||||
|
||||
const dayOfWeekArray = z
|
||||
.array(z.number().int().min(1).max(7))
|
||||
.min(1, 'Välj minst en veckodag')
|
||||
.max(7, 'Högst sju veckodagar tillåtna')
|
||||
|
||||
export const CreateShiftPremiumRuleSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
applies_to_all_employees: z.boolean().default(true),
|
||||
applies_to_employee_ids: z.array(uuid).default([]),
|
||||
day_of_week: dayOfWeekArray,
|
||||
start_time: timeString,
|
||||
end_time: timeString,
|
||||
premium_percent: z.number().min(0).max(500),
|
||||
item_type: ShiftPremiumItemTypeSchema,
|
||||
priority: z.number().int().min(0).max(1000).default(0),
|
||||
is_active: z.boolean().default(true),
|
||||
})
|
||||
.refine(
|
||||
(data) => data.applies_to_all_employees || data.applies_to_employee_ids.length > 0,
|
||||
{
|
||||
message: 'Välj minst en anställd när regeln inte gäller alla',
|
||||
path: ['applies_to_employee_ids'],
|
||||
},
|
||||
)
|
||||
|
||||
export const UpdateShiftPremiumRuleSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
applies_to_all_employees: z.boolean().optional(),
|
||||
applies_to_employee_ids: z.array(uuid).optional(),
|
||||
day_of_week: dayOfWeekArray.optional(),
|
||||
start_time: timeString.optional(),
|
||||
end_time: timeString.optional(),
|
||||
premium_percent: z.number().min(0).max(500).optional(),
|
||||
item_type: ShiftPremiumItemTypeSchema.optional(),
|
||||
priority: z.number().int().min(0).max(1000).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.applies_to_all_employees === false && data.applies_to_employee_ids !== undefined) {
|
||||
return data.applies_to_employee_ids.length > 0
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Välj minst en anställd när regeln inte gäller alla',
|
||||
path: ['applies_to_employee_ids'],
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-employee override on a salary run (advanced mode).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* In-memory per-token rate limiter for the public token-authenticated routes
|
||||
* (calendar feed, payslip PDF). The token IS the authentication on those
|
||||
* routes, so the limit is keyed by token, not by user.
|
||||
*
|
||||
* Process-local by design: a multi-instance deploy limits per instance, which
|
||||
* is acceptable for these low-value endpoints. Each route creates its own
|
||||
* limiter so the maps stay isolated.
|
||||
*/
|
||||
export function createTokenRateLimiter(options: { max: number; windowMs: number; cleanupEveryMs?: number }) {
|
||||
const { max, windowMs, cleanupEveryMs = 5 * 60_000 } = options
|
||||
const entries = new Map<string, { count: number; resetAt: number }>()
|
||||
let lastCleanup = Date.now()
|
||||
|
||||
// Periodic sweep of expired entries so the map cannot grow without bound.
|
||||
function cleanup() {
|
||||
const now = Date.now()
|
||||
if (now - lastCleanup < cleanupEveryMs) return
|
||||
lastCleanup = now
|
||||
for (const [key, value] of entries) {
|
||||
if (now > value.resetAt) entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Count one request for `token`; false when the window's budget is spent. */
|
||||
allow(token: string): boolean {
|
||||
cleanup()
|
||||
const nowMs = Date.now()
|
||||
const entry = entries.get(token)
|
||||
if (entry && nowMs < entry.resetAt) {
|
||||
if (entry.count >= max) return false
|
||||
entry.count++
|
||||
} else {
|
||||
entries.set(token, { count: 1, resetAt: nowMs + windowMs })
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* JSON body reader for v1 routes.
|
||||
*
|
||||
* Every v1 write route parses its body the same way: read JSON, and on a
|
||||
* malformed body answer with the VALIDATION_ERROR envelope pointing at
|
||||
* `body`. This helper is that block, so routes do:
|
||||
*
|
||||
* const raw = await readV1JsonBody(request, ctx)
|
||||
* if (!raw.ok) return raw.response
|
||||
* const parsed = Schema.safeParse(raw.body)
|
||||
*/
|
||||
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { v1ErrorResponseFromCode, type V1ValidationContext } from './errors'
|
||||
|
||||
export type V1JsonBodyResult =
|
||||
| { ok: true; body: unknown }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
export async function readV1JsonBody(
|
||||
request: Request,
|
||||
ctx: V1ValidationContext,
|
||||
): Promise<V1JsonBodyResult> {
|
||||
try {
|
||||
return { ok: true, body: await request.json() }
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
response: await v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,32 +47,6 @@ export interface DryRunPreviewBase<T> {
|
||||
preview: T
|
||||
}
|
||||
|
||||
export interface DryRunPreviewStaged<T> extends DryRunPreviewBase<T> {
|
||||
/** `pending_operations.id`. Use with POST /v1/operations/{id}:commit. */
|
||||
staged_operation_id: string
|
||||
/**
|
||||
* Journal lines this write WOULD produce on commit. Absent for
|
||||
* non-financial writes. Each item: `{ account, debit, credit, description? }`.
|
||||
*/
|
||||
journal_lines?: Array<{
|
||||
account: string
|
||||
debit: number
|
||||
credit: number
|
||||
description?: string
|
||||
}>
|
||||
/**
|
||||
* The voucher number that WOULD be assigned on commit. Present only
|
||||
* when the write produces a posted journal entry. Voucher numbers are
|
||||
* sequential, so this is a *projection*: the actual number could differ
|
||||
* by one or two if another committer beat the agent to the next number.
|
||||
*/
|
||||
voucher_number_assigned_on_commit?: string
|
||||
/** Effect on account balances. Absent for non-financial writes. */
|
||||
account_deltas?: Array<{ account: string; delta: number }>
|
||||
}
|
||||
|
||||
export type DryRunPreview<T> = DryRunPreviewBase<T> | DryRunPreviewStaged<T>
|
||||
|
||||
interface DryRunResponseOptions {
|
||||
requestId: string
|
||||
log: Logger
|
||||
@@ -90,37 +64,3 @@ export function dryRunPreview<T>(preview: T, opts: DryRunResponseOptions): NextR
|
||||
opts.log.info('dry-run preview returned', { stage: 'validation-only' })
|
||||
return ok(body, { requestId: opts.requestId, dryRun: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a 200 OK dry-run response for a staged preview (financial writes).
|
||||
*
|
||||
* Phase 2 PR-B-1 does not yet exercise this path; the helper is in place so
|
||||
* Phase 2 PR-B-2 (invoice writes) and later phases (journal entries,
|
||||
* year-end, etc.) reuse it without redefining the shape.
|
||||
*/
|
||||
export function dryRunStaged<T>(
|
||||
data: {
|
||||
preview: T
|
||||
stagedOperationId: string
|
||||
journalLines?: DryRunPreviewStaged<T>['journal_lines']
|
||||
voucherNumberAssignedOnCommit?: string
|
||||
accountDeltas?: DryRunPreviewStaged<T>['account_deltas']
|
||||
},
|
||||
opts: DryRunResponseOptions,
|
||||
): NextResponse {
|
||||
const body: DryRunPreviewStaged<T> = {
|
||||
dry_run: true,
|
||||
preview: data.preview,
|
||||
staged_operation_id: data.stagedOperationId,
|
||||
...(data.journalLines ? { journal_lines: data.journalLines } : {}),
|
||||
...(data.voucherNumberAssignedOnCommit
|
||||
? { voucher_number_assigned_on_commit: data.voucherNumberAssignedOnCommit }
|
||||
: {}),
|
||||
...(data.accountDeltas ? { account_deltas: data.accountDeltas } : {}),
|
||||
}
|
||||
opts.log.info('dry-run preview returned', {
|
||||
stage: 'staged',
|
||||
stagedOperationId: data.stagedOperationId,
|
||||
})
|
||||
return ok(body, { requestId: opts.requestId, dryRun: true })
|
||||
}
|
||||
|
||||
+36
-5
@@ -28,7 +28,6 @@ import {
|
||||
errorResponse as legacyErrorResponse,
|
||||
errorResponseFromCode as legacyErrorResponseFromCode,
|
||||
} from '@/lib/errors/get-structured-error'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
|
||||
|
||||
@@ -177,10 +176,42 @@ export async function v1ErrorResponseFromCode(
|
||||
return rewriteEnvelope(legacy, ctx)
|
||||
}
|
||||
|
||||
/** Minimal slice of the v1 route context the validation helpers need. */
|
||||
export interface V1ValidationContext {
|
||||
requestId: string
|
||||
log: Logger
|
||||
}
|
||||
|
||||
/** Structural view of a ZodError: enough to render the v1 issues list. */
|
||||
export interface V1ZodErrorLike {
|
||||
issues: ReadonlyArray<{ path: ReadonlyArray<PropertyKey>; message: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: does this code map to a registered entry? Used by callers that
|
||||
* want to validate a code before throwing it (e.g. registry-driven dispatch).
|
||||
* Map Zod issues into the `{ field, message }` list the v1 envelope carries
|
||||
* under `details.issues`.
|
||||
*/
|
||||
export function isRegisteredV1Code(code: string): boolean {
|
||||
return getErrorEntry(code) !== undefined
|
||||
export function v1ZodIssues(err: V1ZodErrorLike): Array<{ field: string; message: string }> {
|
||||
return err.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* VALIDATION_ERROR envelope for a failed Zod parse:
|
||||
*
|
||||
* const parsed = Schema.safeParse(rawBody)
|
||||
* if (!parsed.success) return v1ValidationError(ctx, parsed.error)
|
||||
*/
|
||||
export async function v1ValidationError(
|
||||
ctx: V1ValidationContext,
|
||||
err: V1ZodErrorLike,
|
||||
): Promise<NextResponse> {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: v1ZodIssues(err),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* pagination hints, not security tokens.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { UUID_RE as UUID } from '@/lib/invariants/uuid'
|
||||
|
||||
export const DEFAULT_LIMIT = 50
|
||||
export const MAX_LIMIT = 100
|
||||
@@ -47,14 +47,6 @@ export function parsePaginationParams(url: URL): PaginationParams {
|
||||
return { limit, cursor: cursor && cursor.length > 0 ? cursor : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for query-param validation when a route already uses Zod for inputs.
|
||||
*/
|
||||
export const PaginationQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Default (created_at, id) keyset cursor
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -82,7 +74,6 @@ export function encodeDefaultCursor(row: { created_at: string; id: string } | nu
|
||||
// PostgREST would likely reject these, but validating here keeps the failure
|
||||
// mode predictable (stale cursor → "start over") rather than 400-ing.
|
||||
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Decode a cursor produced by encodeDefaultCursor. Returns null when the
|
||||
|
||||
@@ -205,9 +205,6 @@ export function listEndpoints(): EndpointDefinition[] {
|
||||
return Array.from(ENDPOINTS.values())
|
||||
}
|
||||
|
||||
export function getEndpoint(method: HttpMethod, path: string): EndpointDefinition | undefined {
|
||||
return ENDPOINTS.get(`${method} ${path}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the registered endpoint for a CONCRETE request path (e.g.
|
||||
@@ -510,10 +507,3 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only escape hatch. Clears the registry: used in unit tests so a test
|
||||
* that registers a fake endpoint doesn't leak into the next test.
|
||||
*/
|
||||
export function _resetRegistryForTests(): void {
|
||||
ENDPOINTS.clear()
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import { parseReportDateRange, type DateRange } from '@/lib/reports/date-range'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from './errors'
|
||||
|
||||
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
||||
import { UUID_RE } from '@/lib/invariants/uuid'
|
||||
|
||||
export interface FiscalPeriodRow {
|
||||
id: string
|
||||
|
||||
@@ -25,7 +25,7 @@ export {
|
||||
TOOL_SCOPE_MAP,
|
||||
TOOL_COUNT_BY_SCOPE,
|
||||
} from './scope-catalog'
|
||||
export type { ApiKeyScope, ScopeGroup } from './scope-catalog'
|
||||
export type { ApiKeyScope } from './scope-catalog'
|
||||
import { API_KEY_SCOPES, DEFAULT_SCOPES, type ApiKeyScope } from './scope-catalog'
|
||||
|
||||
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
* fail open as "disabled" rather than render a broken widget.
|
||||
*/
|
||||
|
||||
export const TURNSTILE_SITE_KEY_ENV = 'NEXT_PUBLIC_TURNSTILE_SITE_KEY'
|
||||
|
||||
const UNCONFIGURED_SITE_KEYS = new Set([
|
||||
'',
|
||||
'__NEXT_PUBLIC_TURNSTILE_SITE_KEY__',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { EntityType } from '@/types'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { parseInvoiceDateRange } from './date-range-parser'
|
||||
|
||||
export type PeriodiseringSource = 'invoice' | 'supplier_invoice'
|
||||
export type PeriodiseringConfidence = 'high' | 'medium' | 'low'
|
||||
|
||||
/** The entity types the materiality wording distinguishes between. Mirrors
|
||||
* `EntityType` in `@/types` without importing app types into lib/. */
|
||||
export type PeriodiseringEntityType = 'enskild_firma' | 'aktiebolag'
|
||||
/** The entity types the materiality wording distinguishes between. */
|
||||
export type PeriodiseringEntityType = EntityType
|
||||
|
||||
/**
|
||||
* Materiality floor for auto-detected periodiseringar, in SEK.
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { AccrualProposal } from './types'
|
||||
import {
|
||||
proposeManualAccrued,
|
||||
proposeManualPrepaid,
|
||||
proposeRevenueDeferral,
|
||||
proposeAccruedInterest,
|
||||
proposeAccruedUtility,
|
||||
} from './accrual-detector'
|
||||
|
||||
/**
|
||||
* Pre-filled patterns the wizard offers as one-click templates. Each maps
|
||||
* to one of the proposeManual* / proposeAccrued* helpers in
|
||||
* Pre-filled patterns the wizard offers as one-click templates. Each names
|
||||
* the engine side (prepaid / accrued / deferred revenue / ...) that the
|
||||
* wizard maps onto the proposeManual* / proposeAccrued* helpers in
|
||||
* accrual-detector.ts. The wizard renders these as "Lägg till" buttons.
|
||||
*
|
||||
* BAS account choices follow the standard 2026 chart:
|
||||
@@ -17,8 +9,8 @@ import {
|
||||
* - 29xx upplupna kostnader (accrued expenses + förutbetalda intäkter)
|
||||
* - 2970 förutbetalda intäkter (deferred revenue specifically)
|
||||
*
|
||||
* Adding a new template means: 1) extend this array, 2) add a wrapper in
|
||||
* accrual-detector if needed, 3) the wizard picks it up automatically.
|
||||
* Adding a new template means: 1) extend this array, 2) the wizard picks
|
||||
* it up automatically.
|
||||
*/
|
||||
|
||||
export type PeriodiseringTemplateKind =
|
||||
@@ -95,69 +87,3 @@ export const PERIODISERING_TEMPLATES: PeriodiseringTemplate[] = [
|
||||
expense_account: '5020',
|
||||
},
|
||||
]
|
||||
|
||||
export interface TemplateApplyParams {
|
||||
amount: number
|
||||
description: string
|
||||
closingDate: string
|
||||
/** Caller-overridable account numbers. Falls back to template defaults. */
|
||||
prepaidAccount?: string
|
||||
expenseAccount?: string
|
||||
deferredAccount?: string
|
||||
revenueAccount?: string
|
||||
accruedAccount?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AccrualProposal from a template + caller-supplied amount/desc.
|
||||
* Throws if the template kind is unknown or the resulting accounts violate
|
||||
* the 17xx/29xx range checks in the underlying engine functions.
|
||||
*/
|
||||
export function applyTemplate(
|
||||
template: PeriodiseringTemplate,
|
||||
params: TemplateApplyParams,
|
||||
): AccrualProposal | null {
|
||||
const { amount, description, closingDate } = params
|
||||
switch (template.side) {
|
||||
case 'prepaid':
|
||||
return proposeManualPrepaid({
|
||||
amount,
|
||||
description,
|
||||
closingDate,
|
||||
prepaidAccount: params.prepaidAccount ?? template.prepaid_account!,
|
||||
expenseAccount: params.expenseAccount ?? template.expense_account!,
|
||||
})
|
||||
case 'accrued':
|
||||
return proposeManualAccrued({
|
||||
amount,
|
||||
description,
|
||||
closingDate,
|
||||
accruedAccount: params.accruedAccount ?? template.accrued_account!,
|
||||
expenseAccount: params.expenseAccount ?? template.expense_account!,
|
||||
})
|
||||
case 'deferred_revenue':
|
||||
return proposeRevenueDeferral({
|
||||
amount,
|
||||
description,
|
||||
closingDate,
|
||||
deferredAccount: params.deferredAccount ?? template.deferred_account!,
|
||||
revenueAccount: params.revenueAccount ?? template.revenue_account!,
|
||||
})
|
||||
case 'accrued_interest':
|
||||
return proposeAccruedInterest({
|
||||
amount,
|
||||
description,
|
||||
closingDate,
|
||||
accruedAccount: params.accruedAccount ?? template.accrued_account!,
|
||||
expenseAccount: params.expenseAccount ?? template.expense_account!,
|
||||
})
|
||||
case 'accrued_utility':
|
||||
return proposeAccruedUtility({
|
||||
amount,
|
||||
description,
|
||||
closingDate,
|
||||
accruedAccount: params.accruedAccount ?? template.accrued_account!,
|
||||
expenseAccount: params.expenseAccount ?? template.expense_account!,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ArsredovisningData } from './types'
|
||||
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
|
||||
import type { AccountingFramework } from '@/types'
|
||||
|
||||
export const ANNUAL_REPORT_SCHEMA_VERSION = '1.0' as const
|
||||
|
||||
export type AnnualReportFramework = 'k2' | 'k3'
|
||||
export type AnnualReportFilingMode = 'paper' | 'digital'
|
||||
export type AnnualReportFramework = AccountingFramework
|
||||
export type AnnualReportValidationStage = 'draft' | 'signing' | 'filing'
|
||||
|
||||
export type ParentGroupSize = 'none' | 'small' | 'large'
|
||||
@@ -165,12 +165,3 @@ export function emptyAnnualReportProfile(
|
||||
updated_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
export const EMPTY_DISCLOSURE_STATE: AnnualReportDisclosureState = {
|
||||
long_term_debt_over_five_years_confirmed: false,
|
||||
securities_pledged_confirmed: false,
|
||||
contingent_liabilities_confirmed: false,
|
||||
parent_company_confirmed: false,
|
||||
agm_disposition_outcome: null,
|
||||
agm_disposition_decision: null,
|
||||
}
|
||||
|
||||
@@ -413,6 +413,3 @@ export function anyAssetHasComponents(assets: AssetWithComponents[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Re-export so the type-guard signature can be reused by build-data and
|
||||
// tests without exposing the local AssetWithComponents structural type.
|
||||
export { isComponentArray as isK3ComponentArray }
|
||||
|
||||
@@ -128,17 +128,3 @@ export async function markSignatureSigned(
|
||||
}
|
||||
return data as SignatureRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every signature request for the period is signed. The UI uses
|
||||
* this to enable the "Ladda ner fastställd PDF" button and to gate filing.
|
||||
*/
|
||||
export async function isFullySignedOff(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
): Promise<boolean> {
|
||||
const requests = await listSignatureRequests(supabase, companyId, fiscalPeriodId)
|
||||
if (requests.length === 0) return false
|
||||
return requests.every((r) => r.status === 'signed')
|
||||
}
|
||||
|
||||
@@ -968,70 +968,3 @@ function disposalAccounts(category: AssetCategory): { gain: string; loss: string
|
||||
}
|
||||
return { gain: '3973', loss: '7973' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum prior depreciation booked against an asset's 78xx avskrivningskonto
|
||||
* up to and including `asOfDate`. Reads from journal_entry_lines so manually-
|
||||
* posted avskrivningsverifikationer (i.e. not driven by depreciation_schedules)
|
||||
* are also counted: the declining-balance engine needs the most accurate
|
||||
* net book value to compute the next period's charge.
|
||||
*
|
||||
* Only counts posted entries against the asset's `bas_expense_account`
|
||||
* (the 78xx avskrivningskonto), and only debits (since avskrivning = debit
|
||||
* 78xx / credit 12x9). Returns 0 if the asset has never been depreciated.
|
||||
*
|
||||
* Why we look at the expense account rather than the accumulated account:
|
||||
* the expense account is asset-specific by convention (7831 for machinery,
|
||||
* 7832 for equipment, etc.) so we can scope per-asset accurately, whereas
|
||||
* the accumulated account (12x9) may aggregate across assets in the same
|
||||
* category. Limitation: when multiple assets share the same bas_expense_account
|
||||
* we cannot disambiguate at the journal line level. The depreciation_schedules
|
||||
* sum (see `sumPostedDepreciation`) is the safer fallback in that case.
|
||||
* Callers that need exact per-asset accuracy should prefer the schedules sum.
|
||||
*/
|
||||
export async function getAccumulatedDepreciationAsOf(
|
||||
supabase: SupabaseClient,
|
||||
assetId: string,
|
||||
asOfDate: string,
|
||||
): Promise<number> {
|
||||
// 1. Resolve the asset and its expense account first so we can target the
|
||||
// correct 78xx code.
|
||||
const { data: asset, error: assetError } = await supabase
|
||||
.from('assets')
|
||||
.select('bas_expense_account, company_id')
|
||||
.eq('id', assetId)
|
||||
.maybeSingle()
|
||||
|
||||
if (assetError) {
|
||||
throw new Error(`Failed to load asset for accumulated depreciation: ${assetError.message}`)
|
||||
}
|
||||
if (!asset) return 0
|
||||
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
|
||||
let data: Row[]
|
||||
try {
|
||||
data = await fetchEntryLines<Row>({
|
||||
supabase,
|
||||
lineColumns: 'debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', asset.company_id)
|
||||
.eq('status', 'posted')
|
||||
.lte('entry_date', asOfDate),
|
||||
filterLines: (q: EntryLinesQuery) =>
|
||||
q.eq('account_number', asset.bas_expense_account),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to sum accumulated depreciation for asset ${assetId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return data.reduce((sum, row) => {
|
||||
// Expense account: normal balance is debit. Net = debit − credit so
|
||||
// any storno (reversal) is netted out.
|
||||
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* deducted at acquisition.
|
||||
*/
|
||||
|
||||
import type { AssetCategory, VatTreatment } from '@/types'
|
||||
import type { AssetCategory, AssetJamkningDirection, VatTreatment } from '@/types'
|
||||
|
||||
export type JamkningDirection = 'increase' | 'decrease' | 'none' | 'transferred'
|
||||
export type JamkningDirection = AssetJamkningDirection
|
||||
|
||||
export interface JamkningAssessmentInput {
|
||||
acquisitionDate: string
|
||||
|
||||
@@ -5,10 +5,6 @@ import { roundOre } from '@/lib/money'
|
||||
* bokfört värde plus årets anskaffningar minus årets försäljningar. */
|
||||
export const OVERAVSKRIVNING_30_RULE = 0.7
|
||||
|
||||
/** 20-rule (kompletteringsregel, IL 18 kap 17 §): restvärde minst 0 % efter
|
||||
* 5 år (20 % avskrivning per år, raklinje). */
|
||||
export const OVERAVSKRIVNING_20_RULE_YEARS = 5
|
||||
|
||||
export interface Compute30RuleInput {
|
||||
/** IB skattemässigt värde maskiner & inventarier. */
|
||||
openingBookValue: number
|
||||
@@ -20,6 +16,8 @@ export interface Compute30RuleInput {
|
||||
fiscalPeriodMonths?: number
|
||||
}
|
||||
|
||||
/** 20-rule (kompletteringsregel, IL 18 kap 17 §): restvärde minst 0 % efter
|
||||
* 5 år (20 % avskrivning per år, raklinje), hence the five-slot input. */
|
||||
export interface Compute20RuleInput {
|
||||
/** Anskaffningskostnad per anskaffningsår, från innevarande år minus 4 till
|
||||
* innevarande år. Index 0 = innevarande år. */
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import { findSimilarTemplates } from '../template-embeddings'
|
||||
import { findMatchingTemplates } from '../booking-templates'
|
||||
|
||||
describe('template-embeddings facade', () => {
|
||||
describe('findSimilarTemplates', () => {
|
||||
describe('template keyword matching', () => {
|
||||
describe('findMatchingTemplates', () => {
|
||||
it('returns keyword-based matches', async () => {
|
||||
const tx = makeTransaction({ description: 'SPOTIFY', amount: -109 })
|
||||
const results = await findSimilarTemplates(tx)
|
||||
const results = await findMatchingTemplates(tx)
|
||||
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts entityType parameter', async () => {
|
||||
const tx = makeTransaction({ description: 'SPOTIFY', amount: -109 })
|
||||
const results = await findSimilarTemplates(tx, 'enskild_firma')
|
||||
const results = await findMatchingTemplates(tx, 'enskild_firma')
|
||||
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -5,15 +5,9 @@
|
||||
* (account-classifier-client.ts) uses the lazily loaded chart the same way.
|
||||
*/
|
||||
|
||||
export type AccountType =
|
||||
| 'asset'
|
||||
| 'liability'
|
||||
| 'equity'
|
||||
| 'revenue'
|
||||
| 'expense'
|
||||
| 'untaxed_reserves'
|
||||
import type { AccountType, NormalBalance } from '@/types'
|
||||
|
||||
export type NormalBalance = 'debit' | 'credit'
|
||||
export type { AccountType, NormalBalance }
|
||||
|
||||
export interface ClassifiedAccount {
|
||||
account_type: AccountType
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { getBASReference } from './bas-reference'
|
||||
import { classifyAccountHeuristic, type ClassifiedAccount } from './account-classifier-heuristic'
|
||||
|
||||
export type { AccountType, ClassifiedAccount, NormalBalance } from './account-classifier-heuristic'
|
||||
|
||||
|
||||
/**
|
||||
* Map a 4-digit BAS account number to its account_type and normal_balance.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ACCOUNT_CLASS_LABELS } from './bas-labels'
|
||||
import { getBasLoadedByNumber } from './bas-lazy'
|
||||
import type { AccountType } from '@/types'
|
||||
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
|
||||
export type { AccountType }
|
||||
|
||||
export interface AccountDescription {
|
||||
name: string
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { roundOre, sumOre } from '@/lib/money'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { todayIsoUtc } from '@/lib/dates/iso'
|
||||
|
||||
const log = createLogger('bookkeeping.accruals')
|
||||
|
||||
@@ -82,10 +83,6 @@ export interface PostDueResult {
|
||||
type ScheduleRow = AccrualSchedule
|
||||
type InstallmentRow = AccrualScheduleInstallment & { schedule?: ScheduleRow | null }
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -286,7 +283,7 @@ export async function postDueInstallments(
|
||||
today?: string
|
||||
} = {},
|
||||
): Promise<PostDueResult> {
|
||||
const today = options.today ?? todayIso()
|
||||
const today = options.today ?? todayIsoUtc()
|
||||
const result: PostDueResult = { posted: 0, failed: 0, skipped: 0, errors: [] }
|
||||
|
||||
let query = supabase
|
||||
@@ -477,7 +474,7 @@ export async function dissolveScheduleNow(
|
||||
scheduleId: string,
|
||||
options: { today?: string } = {},
|
||||
): Promise<{ journalEntryId: string; amount: number }> {
|
||||
const today = options.today ?? todayIso()
|
||||
const today = options.today ?? todayIsoUtc()
|
||||
|
||||
const { data: scheduleData, error: scheduleError } = await supabase
|
||||
.from('accrual_schedules')
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
import { PACK_CATEGORIES, PACK_ENTITY_TYPES } from '@/lib/packs/schema'
|
||||
|
||||
/**
|
||||
* Request-body schemas shared by the booking-template routes
|
||||
* (/api/settings/booking-templates, its [id] and import siblings).
|
||||
*
|
||||
* The category / entity_type enums mirror `booking_template_library`'s CHECK
|
||||
* constraints via the pack contract. The line schema is deliberately looser
|
||||
* than `PackLineSchema`: user templates may carry both `ratio` and `vat_rate`
|
||||
* on a line, which the pack validator rejects.
|
||||
*/
|
||||
export const BookingTemplateLineSchema = z.object({
|
||||
account: z.string().regex(/^\d{4}$/),
|
||||
label: z.string().min(1),
|
||||
side: z.enum(['debit', 'credit']),
|
||||
type: z.enum(['business', 'vat', 'settlement']),
|
||||
ratio: z.number().min(0).max(10).optional(),
|
||||
vat_rate: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
|
||||
export const BookingTemplateCategorySchema = z.enum(PACK_CATEGORIES)
|
||||
|
||||
export const BookingTemplateEntityTypeSchema = z.enum(PACK_ENTITY_TYPES)
|
||||
@@ -442,14 +442,15 @@ export async function findCounterpartyTemplatesBatch(
|
||||
// ── Build MappingResult ────────────────────────────────────────
|
||||
|
||||
/** Which side of a template the money settles on. */
|
||||
type TemplateDirection = 'expense' | 'income' | 'unknown'
|
||||
export type TemplateDirection = 'expense' | 'income' | 'unknown'
|
||||
|
||||
/**
|
||||
* Learned direction of a legacy (single debit/credit) template: expenses
|
||||
* settle on the credit side (credit bank, debit cost), income settles on the
|
||||
* debit side. 'unknown' when neither or both accounts look like settlement.
|
||||
*/
|
||||
function legacyTemplateDirection(debitAccount: string, creditAccount: string): TemplateDirection {
|
||||
export function legacyTemplateDirection(debitAccount: string, creditAccount: string): TemplateDirection {
|
||||
|
||||
const debitSettles = isSettlementAccount(debitAccount)
|
||||
const creditSettles = isSettlementAccount(creditAccount)
|
||||
if (creditSettles && !debitSettles) return 'expense'
|
||||
@@ -458,7 +459,8 @@ function legacyTemplateDirection(debitAccount: string, creditAccount: string): T
|
||||
}
|
||||
|
||||
/** Learned direction of a multi-line pattern: read off the business sides. */
|
||||
function patternDirection(pattern: LinePatternEntry[]): TemplateDirection {
|
||||
export function patternDirection(pattern: LinePatternEntry[]): TemplateDirection {
|
||||
|
||||
const business = pattern.filter((e) => e.type === 'business')
|
||||
if (business.length === 0) return 'unknown'
|
||||
const debitCount = business.filter((b) => b.side === 'debit').length
|
||||
@@ -999,7 +1001,8 @@ function toDateString(d: Date): string {
|
||||
}
|
||||
|
||||
/** Settlement accounts: bank/cash (19xx), receivables (1510), payables (2440), credit card (2890) */
|
||||
function isSettlementAccount(account: string): boolean {
|
||||
export function isSettlementAccount(account: string): boolean {
|
||||
|
||||
return account.startsWith('19') || account === '1510' || account === '2440' || account === '2890'
|
||||
}
|
||||
|
||||
|
||||
@@ -94,10 +94,6 @@ export class DimensionValidationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function isDimensionValidationError(err: unknown): err is DimensionValidationError {
|
||||
return err instanceof DimensionValidationError
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mandatory dimension enforcement (dimensions PR10)
|
||||
// ============================================================================
|
||||
@@ -132,9 +128,3 @@ export class MandatoryDimensionMissingError extends Error {
|
||||
this.name = 'MandatoryDimensionMissingError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isMandatoryDimensionMissingError(
|
||||
err: unknown,
|
||||
): err is MandatoryDimensionMissingError {
|
||||
return err instanceof MandatoryDimensionMissingError
|
||||
}
|
||||
|
||||
@@ -1031,11 +1031,13 @@ export async function replaceOpeningBalanceEntry(
|
||||
/**
|
||||
* Get the current date in Swedish timezone (Europe/Stockholm).
|
||||
* Avoids UTC date shift when server runs in a different timezone.
|
||||
* Pass `now` to format a specific instant instead of the current time.
|
||||
*/
|
||||
export function getSwedishLocalDate(): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Stockholm' }).format(new Date())
|
||||
export function getSwedishLocalDate(now: Date = new Date()): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Stockholm' }).format(now)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a reversal entry for an existing journal entry
|
||||
* Sets reversed_by_id/reverses_id links for compliance tracking
|
||||
|
||||
@@ -2,28 +2,15 @@ import { NextResponse } from 'next/server'
|
||||
import { DimensionValidationError, MandatoryDimensionMissingError } from './dimension-errors'
|
||||
|
||||
// ============================================================================
|
||||
// Dimension validation error: class lives in ./dimension-errors.ts (pure
|
||||
// module, no next/server) because dimension-resolver.ts is reachable from
|
||||
// client bundles via lib/api/schemas.ts. Re-exported here so this file stays
|
||||
// the single import surface for all typed bookkeeping errors.
|
||||
// Dimension validation errors: the classes live in ./dimension-errors.ts
|
||||
// (pure module, no next/server) because dimension-resolver.ts is reachable
|
||||
// from client bundles via lib/api/schemas.ts. The two error classes are
|
||||
// re-exported here so this file stays the import surface for typed
|
||||
// bookkeeping errors; codes, formatters and issue types are imported from
|
||||
// ./dimension-errors directly.
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
DIMENSION_VALIDATION_FAILED,
|
||||
DimensionValidationError,
|
||||
formatDimensionValidationIssue,
|
||||
formatDimensionValidationIssues,
|
||||
isDimensionValidationError,
|
||||
MANDATORY_DIMENSION_MISSING,
|
||||
MandatoryDimensionMissingError,
|
||||
formatMandatoryDimensionViolation,
|
||||
isMandatoryDimensionMissingError,
|
||||
} from './dimension-errors'
|
||||
export type {
|
||||
DimensionValidationIssue,
|
||||
DimensionValidationReason,
|
||||
MandatoryDimensionViolation,
|
||||
} from './dimension-errors'
|
||||
export { DimensionValidationError, MandatoryDimensionMissingError } from './dimension-errors'
|
||||
|
||||
// ============================================================================
|
||||
// Error codes
|
||||
|
||||
@@ -30,12 +30,7 @@ const log = createLogger('invoice-entries')
|
||||
// the client-side proposal helpers (propose-send-lines, propose-payment-lines)
|
||||
// need only those, and importing them from here dragged the engine, the
|
||||
// account backfill and with it the full BAS chart into the browser bundle.
|
||||
export {
|
||||
INVOICE_FX_RATE_MISSING,
|
||||
InvoiceFxRateMissingError,
|
||||
getOutputVatAccount,
|
||||
getRevenueAccount,
|
||||
} from './invoice-accounts'
|
||||
export { getOutputVatAccount, getRevenueAccount } from './invoice-accounts'
|
||||
import { InvoiceFxRateMissingError, getOutputVatAccount, getRevenueAccount } from './invoice-accounts'
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Transaction } from '@/types'
|
||||
import { findPairableCashAccountByIban } from '@/lib/cash-accounts/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { addDaysIso as addDays } from '@/lib/dates/iso'
|
||||
|
||||
const log = createLogger('own-account-detector')
|
||||
|
||||
@@ -141,9 +142,3 @@ export async function detectOwnAccountTransfer(
|
||||
pairTransactionId: pair?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function addDays(iso: string, days: number): string {
|
||||
const d = new Date(iso + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
||||
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
|
||||
import { buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import {
|
||||
legacyTemplateDirection as legacyDirection,
|
||||
patternDirection,
|
||||
} from '@/lib/bookkeeping/counterparty-templates'
|
||||
|
||||
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { TransactionCategory, VatTreatment, EntityType, LinePatternEntry } from '@/types'
|
||||
|
||||
@@ -115,35 +121,6 @@ export interface ProposalLinesInput {
|
||||
settlementAccount?: string
|
||||
}
|
||||
|
||||
type LearnedDirection = 'expense' | 'income' | 'unknown'
|
||||
|
||||
/**
|
||||
* Settlement-account predicate, mirroring the private isSettlementAccount in
|
||||
* counterparty-templates.ts (bank/cash 19xx, receivables 1510, payables 2440,
|
||||
* credit card 2890). Keep the two in sync.
|
||||
*/
|
||||
function isSettlementAccount(account: string): boolean {
|
||||
return account.startsWith('19') || account === '1510' || account === '2440' || account === '2890'
|
||||
}
|
||||
|
||||
/** Mirrors legacyTemplateDirection in counterparty-templates.ts. */
|
||||
function legacyDirection(debitAccount: string, creditAccount: string): LearnedDirection {
|
||||
const debitSettles = isSettlementAccount(debitAccount)
|
||||
const creditSettles = isSettlementAccount(creditAccount)
|
||||
if (creditSettles && !debitSettles) return 'expense'
|
||||
if (debitSettles && !creditSettles) return 'income'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** Mirrors patternDirection in counterparty-templates.ts. */
|
||||
function patternDirection(pattern: LinePatternEntry[]): LearnedDirection {
|
||||
const business = pattern.filter((e) => e.type === 'business')
|
||||
if (business.length === 0) return 'unknown'
|
||||
const debitCount = business.filter((b) => b.side === 'debit').length
|
||||
if (debitCount === business.length) return 'expense'
|
||||
if (debitCount === 0) return 'income'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a static template's accounts for the company's entity type: the
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import { addDaysIso as addDays } from '@/lib/dates/iso'
|
||||
|
||||
/** Minimal shape needed for the date math: `FiscalPeriod` satisfies it. */
|
||||
type PeriodRange = Pick<FiscalPeriod, 'period_start' | 'period_end'>
|
||||
@@ -23,13 +24,6 @@ export interface SuggestedPeriod {
|
||||
period_end: string
|
||||
}
|
||||
|
||||
/** Add `days` to a YYYY-MM-DD date string, returning a YYYY-MM-DD string. */
|
||||
function addDays(date: string, days: number): string {
|
||||
const d = new Date(date + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* A fiscal-year name: `Räkenskapsår 2025`, or `Räkenskapsår 2024/2025` when it
|
||||
* straddles two calendar years. Swedish by default to match the app's existing
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Template Embeddings: Core Facade
|
||||
*
|
||||
* Provides template matching via keyword-based search.
|
||||
*/
|
||||
|
||||
import {
|
||||
findMatchingTemplates,
|
||||
type TemplateMatch,
|
||||
} from './booking-templates'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* Find similar templates via keyword matching.
|
||||
*/
|
||||
export async function findSimilarTemplates(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType,
|
||||
): Promise<TemplateMatch[]> {
|
||||
return findMatchingTemplates(transaction, entityType)
|
||||
}
|
||||
@@ -26,11 +26,6 @@ interface CacheEntry {
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
|
||||
/** Test hook: drop every cached team brand. */
|
||||
export function clearTeamBrandCache(): void {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve brands for a set of team ids. Returns a map containing ONLY teams
|
||||
* that have a brand; brandless teams are absent (and negatively cached).
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The settings panels' server calls (Stripe, Shopify, WooCommerce), each
|
||||
* classified into exactly one outcome. Never throws: every arm resolves to a
|
||||
* member of the union, so a call site cannot have a silent path by forgetting
|
||||
* a `catch`. One toast sentence per click; the classification lives outside
|
||||
* the component because component logic has no tests in this repo.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
|
||||
/** Deadline for the quick calls (status, toggle, disconnect). */
|
||||
export const PANEL_ACTION_TIMEOUT_MS = 15_000
|
||||
|
||||
export type PanelRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
|
||||
export interface PanelRequestOptions {
|
||||
url: string
|
||||
/** Defaults to POST: most of the panel calls are mutations. */
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
/** JSON request body. Omitted entirely for the routes that ignore it. */
|
||||
body?: unknown
|
||||
/** UI locale, so a server error is reported in the language the user reads. */
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The one sentence for a non-2xx.
|
||||
*
|
||||
* The extension routes refuse with a hand-written Swedish sentence in
|
||||
* `{ error }`, and that sentence is the most specific thing anyone can say:
|
||||
* "Inget anslutet Stripe-konto." tells the user to reconnect, where the status
|
||||
* map's "Resursen kunde inte hittas." tells them nothing. `getErrorMessage`
|
||||
* keeps such a sentence only when it happens to carry one of its Swedish
|
||||
* trigger words, which this route copy does not, so the string is preferred
|
||||
* explicitly.
|
||||
*
|
||||
* `error_en` is honoured first for an English UI because the shared capability
|
||||
* guard emits both (`capabilityBlockedResponse` in
|
||||
* lib/entitlements/has-capability.ts), and `getErrorMessage` only reads
|
||||
* `message_en` inside a structured envelope, not a top-level `error_en`.
|
||||
*
|
||||
* Everything else is `getErrorMessage`'s: a structured envelope, an HTML 502
|
||||
* from the platform, a body that never parsed.
|
||||
*/
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
|
||||
/** Call one of a panel's endpoints and report exactly why it failed. */
|
||||
export async function panelRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = PANEL_ACTION_TIMEOUT_MS,
|
||||
}: PanelRequestOptions): Promise<PanelRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
// Read the body on both arms: the failure arm needs the route's own
|
||||
// sentence, the success arm needs the sync counts. A body that is not JSON
|
||||
// (an HTML error page, an empty 502, a response truncated mid-stream) leaves
|
||||
// null, and neither arm then claims anything it cannot support.
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
* RPC taking uuid[] (see DECISIONS.md).
|
||||
*/
|
||||
|
||||
import { chunk as chunked } from '@/lib/utils'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
@@ -48,12 +49,6 @@ const log = createLogger('byra:kpi-overview')
|
||||
/** Max ids per PostgREST .in() filter (mirrors lib/clients/fetch-client-overview.ts). */
|
||||
const IN_CLAUSE_CHUNK = 150
|
||||
|
||||
function chunked<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
export interface ByraKpiClientRow {
|
||||
companyId: string
|
||||
name: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chunk as chunkIds } from '@/lib/utils'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CashAccount, CashAccountSource, MappingResult } from '@/types'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -994,12 +995,6 @@ export async function resolvePsd2LedgerAccount(
|
||||
/** Max transaction ids per `.in()` filter when rebinding: keeps the request URL short. */
|
||||
const REBIND_ID_CHUNK_SIZE = 100
|
||||
|
||||
function chunkIds(ids: readonly string[], size: number): string[][] {
|
||||
const out: string[][] = []
|
||||
for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebind the MOVABLE transactions of one cash_accounts row onto another.
|
||||
*
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* fetchAllRows guards every query that can exceed PostgREST's 1000-row cap.
|
||||
*/
|
||||
|
||||
import { chunk as chunked } from '@/lib/utils'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
@@ -38,12 +39,6 @@ export interface ClientOverview {
|
||||
/** Max ids per PostgREST .in() filter (mirrors lib/worklist IN_CLAUSE_CHUNK). */
|
||||
const IN_CLAUSE_CHUNK = 150
|
||||
|
||||
function chunked<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
interface ByraMembershipRow {
|
||||
team_id: string
|
||||
role: string
|
||||
|
||||
@@ -25,24 +25,6 @@ export interface LedgerRow {
|
||||
status: 'pending' | 'active' | 'revoked'
|
||||
}
|
||||
|
||||
/** Active connections for one company under one key and service. Enforces the per-company limit. */
|
||||
export async function countActiveConnections(
|
||||
supabase: SupabaseClient,
|
||||
keyId: string,
|
||||
service: ConnectorService,
|
||||
companyRef: string,
|
||||
): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('connector_key_id', keyId)
|
||||
.eq('service', service)
|
||||
.eq('company_ref', companyRef)
|
||||
.eq('status', 'active')
|
||||
if (error) throw new Error(`ledger count failed: ${error.message}`)
|
||||
return count ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending rows count toward quota only while their consent window is open:
|
||||
* the signed connector state expires after 15 minutes, so an abandoned
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ConnectorService } from './ledger'
|
||||
|
||||
/**
|
||||
* Global upstream rate budget for the connector proxy.
|
||||
@@ -15,7 +16,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
* per-minute quota.
|
||||
*/
|
||||
|
||||
export type UpstreamService = 'bank' | 'skatteverket'
|
||||
export type UpstreamService = ConnectorService
|
||||
|
||||
interface Budget {
|
||||
minuteMax: number
|
||||
|
||||
@@ -69,83 +69,3 @@ export async function getAuditLog(
|
||||
count: includeCount ? count ?? 0 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full history of a single record (all mutations)
|
||||
*/
|
||||
export async function getEntityHistory(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
tableName: string,
|
||||
recordId: string
|
||||
): Promise<AuditLogEntry[]> {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('table_name', tableName)
|
||||
.eq('record_id', recordId)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch entity history: ${error.message}`)
|
||||
}
|
||||
|
||||
return (data as AuditLogEntry[]) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace the correction chain for a journal entry:
|
||||
* original → storno (reversal) → corrected entry
|
||||
*/
|
||||
export async function getCorrectionChain(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
journalEntryId: string
|
||||
): Promise<AuditLogEntry[]> {
|
||||
|
||||
// First, find the entry and its linked entries
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, reverses_id, reversed_by_id, correction_of_id')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
throw new Error('Journal entry not found')
|
||||
}
|
||||
|
||||
// Collect all related entry IDs
|
||||
const relatedIds = new Set<string>([entry.id])
|
||||
if (entry.reverses_id) relatedIds.add(entry.reverses_id)
|
||||
if (entry.reversed_by_id) relatedIds.add(entry.reversed_by_id)
|
||||
if (entry.correction_of_id) relatedIds.add(entry.correction_of_id)
|
||||
|
||||
// Also look for entries that reference this one
|
||||
const { data: referencing } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.or(`reverses_id.eq.${journalEntryId},reversed_by_id.eq.${journalEntryId},correction_of_id.eq.${journalEntryId}`)
|
||||
|
||||
for (const ref of referencing || []) {
|
||||
relatedIds.add(ref.id)
|
||||
}
|
||||
|
||||
// Fetch audit log entries for all related IDs
|
||||
const { data, error } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('table_name', 'journal_entries')
|
||||
.in('record_id', Array.from(relatedIds))
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch correction chain: ${error.message}`)
|
||||
}
|
||||
|
||||
return (data as AuditLogEntry[]) || []
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { eventBus } from '@/lib/events'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import type { FiscalPeriod, PeriodStatus } from '@/types'
|
||||
import { addDaysIso } from '@/lib/dates/iso'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
const log = createLogger('period-service')
|
||||
|
||||
@@ -760,7 +761,7 @@ export async function createNextPeriod(
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('previous_period_id', currentPeriodId)
|
||||
.eq('period_start', addDaysUTC(nextEndStr, 1))
|
||||
.eq('period_start', addDaysIso(nextEndStr, 1))
|
||||
if (mischained && mischained.length > 0) {
|
||||
const successorIds = mischained.map((row: { id: string }) => row.id)
|
||||
const { error: relinkError } = await supabase
|
||||
@@ -819,7 +820,7 @@ export async function findNextPeriod(
|
||||
// off-by-one on servers in TZ+ when the day after period_end crosses a
|
||||
// DST spring-forward, because setDate(local) writes local-time fields
|
||||
// and toISOString() converts back through the shifted offset.
|
||||
const expectedStartStr = addDaysUTC(current.period_end, 1)
|
||||
const expectedStartStr = addDaysIso(current.period_end, 1)
|
||||
|
||||
// The chain is only trusted when it is date-adjacent. SIE import used to
|
||||
// point previous_period_id at the NEAREST later period regardless of the
|
||||
@@ -852,102 +853,6 @@ export async function findNextPeriod(
|
||||
return (byDate as FiscalPeriod | null) ?? null
|
||||
}
|
||||
|
||||
/** Add `days` to a YYYY-MM-DD string in pure UTC and return YYYY-MM-DD. */
|
||||
function addDaysUTC(isoDate: string, days: number): string {
|
||||
const d = new Date(isoDate + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a previous fiscal period before the given one.
|
||||
* Computes a 12-month period ending the day before the given period starts.
|
||||
* Updates previous_period_id chain so the given period points to the new one.
|
||||
*/
|
||||
export async function createPreviousPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
currentPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', currentPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !current) {
|
||||
throw new Error('Current fiscal period not found')
|
||||
}
|
||||
|
||||
// Compute previous period end (day before current start)
|
||||
const prevEnd = new Date(current.period_start + 'T12:00:00Z')
|
||||
prevEnd.setUTCDate(prevEnd.getUTCDate() - 1)
|
||||
|
||||
// Compute previous period start (1st of month, 12 months before prevEnd)
|
||||
const prevStart = new Date(prevEnd)
|
||||
prevStart.setUTCMonth(prevStart.getUTCMonth() - 11)
|
||||
prevStart.setUTCDate(1)
|
||||
|
||||
const prevStartStr = prevStart.toISOString().split('T')[0]
|
||||
const prevEndStr = prevEnd.toISOString().split('T')[0]
|
||||
|
||||
// Validate period duration
|
||||
const durationError = validatePeriodDuration(prevStartStr, prevEndStr, { isFirstPeriod: false })
|
||||
if (durationError) {
|
||||
throw new Error(durationError)
|
||||
}
|
||||
|
||||
// Check for overlapping periods
|
||||
const { data: overlapping } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', prevEndStr)
|
||||
.gte('period_end', prevStartStr)
|
||||
.limit(1)
|
||||
|
||||
if (overlapping && overlapping.length > 0) {
|
||||
throw new Error('Previous fiscal period already exists or overlaps with an existing period')
|
||||
}
|
||||
|
||||
// Generate name
|
||||
const startYear = prevStart.getFullYear()
|
||||
const endYear = prevEnd.getFullYear()
|
||||
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
||||
|
||||
const { data: newPeriod, error: insertError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
name,
|
||||
period_start: prevStartStr,
|
||||
period_end: prevEndStr,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !newPeriod) {
|
||||
throw new Error(`Failed to create previous period: ${insertError?.message}`)
|
||||
}
|
||||
|
||||
// Update the current period to point to the new one
|
||||
const { error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ previous_period_id: newPeriod.id })
|
||||
.eq('id', currentPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
throw new Error(`Failed to update period chain: ${updateError.message}`)
|
||||
}
|
||||
|
||||
return newPeriod as FiscalPeriod
|
||||
}
|
||||
|
||||
export type PeriodStatusValue = 'open' | 'locked' | 'closed'
|
||||
|
||||
export interface PeriodStatusForDate {
|
||||
@@ -1084,50 +989,3 @@ export async function resolvePeriodStatusForDate(
|
||||
}
|
||||
return { period_id: period.id, status: 'open', lock_date: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status summary for a fiscal period.
|
||||
*/
|
||||
export async function getPeriodStatus(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<PeriodStatus> {
|
||||
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
// Count draft entries in this period
|
||||
const { count: draftCount } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'draft')
|
||||
|
||||
// Check if next period exists via the chain pointer
|
||||
const { data: nextPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('previous_period_id', fiscalPeriodId)
|
||||
.maybeSingle()
|
||||
|
||||
return {
|
||||
is_locked: !!period.locked_at,
|
||||
is_closed: period.is_closed,
|
||||
has_closing_entry: !!period.closing_entry_id,
|
||||
has_opening_balances: period.opening_balances_set,
|
||||
draft_count: draftCount ?? 0,
|
||||
next_period_exists: !!nextPeriod,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Pure UTC arithmetic on `YYYY-MM-DD` strings.
|
||||
*
|
||||
* Every helper here is deliberately UTC: accounting dates are calendar dates
|
||||
* with no wall-clock component, and doing the arithmetic at `T00:00:00Z`
|
||||
* means no DST edge can shift a day. None of these is a substitute for
|
||||
* `getSwedishLocalDate()` / `swedishToday()` (Europe/Stockholm), which differ
|
||||
* from `todayIsoUtc()` around midnight Swedish time.
|
||||
*/
|
||||
|
||||
/** Add `days` to a `YYYY-MM-DD` string in pure UTC and return `YYYY-MM-DD`. */
|
||||
export function addDaysIso(iso: string, days: number): string {
|
||||
const d = new Date(iso + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Whole days from `from` to `to` (both `YYYY-MM-DD`); negative when `to` is earlier. */
|
||||
export function daysBetweenIso(from: string, to: string): number {
|
||||
const ms = new Date(to + 'T00:00:00Z').getTime() - new Date(from + 'T00:00:00Z').getTime()
|
||||
return Math.round(ms / 86_400_000)
|
||||
}
|
||||
|
||||
/** The UTC calendar date of `d` as `YYYY-MM-DD`. */
|
||||
export function toIsoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Today's UTC calendar date as `YYYY-MM-DD`. */
|
||||
export function todayIsoUtc(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ export const AMOUNT_TOLERANCE_PERCENT = 0.05
|
||||
* against real statements, the spread ran 1.2% to 3%.
|
||||
*/
|
||||
export const CONVERTED_AMOUNT_TOLERANCE_PERCENT = 0.09
|
||||
export const MIN_MATCH_CONFIDENCE = 0.4
|
||||
|
||||
/**
|
||||
* Flat discount on a confidence scored from a prominentAmounts fallback
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
|
||||
import { formatDate, getCompanyDisplayName } from '@/lib/utils'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
|
||||
import { applyPlaceholders, escapeHtml, sanitizeSubjectLine, userTextToHtml } from './user-text'
|
||||
@@ -165,7 +165,7 @@ function buildPlaceholderValues(data: InvoiceEmailData, lang: EmailLang): Record
|
||||
fakturanummer: invoice.invoice_number ?? '',
|
||||
kundnamn: fullName,
|
||||
förnamn: fullName ? fullName.split(' ')[0] : '',
|
||||
företag: getCompanyPrimaryName(company),
|
||||
företag: getCompanyDisplayName(company),
|
||||
förfallodatum: formatDate(invoice.due_date),
|
||||
belopp: formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang),
|
||||
}
|
||||
@@ -245,7 +245,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<!-- Header -->
|
||||
<div style="margin-bottom: 30px; border-bottom: 2px solid ${primaryColor}; padding-bottom: 16px;">
|
||||
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: ${primaryColor};">
|
||||
${L.documentFrom(documentType, getCompanyPrimaryName(company))}
|
||||
${L.documentFrom(documentType, getCompanyDisplayName(company))}
|
||||
</h1>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
${L.documentNumber(documentType)} ${invoice.invoice_number}
|
||||
@@ -348,7 +348,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
</p>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
${custom.signoff !== undefined ? userTextToHtml(custom.signoff) : L.sincerely}<br>
|
||||
<strong style="color: ${primaryColor};">${getCompanyPrimaryName(company)}</strong>
|
||||
<strong style="color: ${primaryColor};">${getCompanyDisplayName(company)}</strong>
|
||||
</p>
|
||||
${company.org_number ? `
|
||||
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
|
||||
@@ -382,7 +382,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
const firstName = customer.name ? customer.name.split(' ')[0] : ''
|
||||
const custom = resolveCustomTexts(data, lang)
|
||||
|
||||
let text = `${L.documentFrom(documentType, getCompanyPrimaryName(company))}\n`
|
||||
let text = `${L.documentFrom(documentType, getCompanyDisplayName(company))}\n`
|
||||
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
|
||||
|
||||
text += `${custom.greeting ?? L.greeting(firstName)}\n\n`
|
||||
@@ -437,7 +437,7 @@ export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
|
||||
if (custom.subject !== undefined) return sanitizeSubjectLine(custom.subject)
|
||||
|
||||
const documentType = getDocumentLabel(invoice, lang)
|
||||
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company))
|
||||
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyDisplayName(company))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -462,7 +462,7 @@ export function generatePaymentConfirmationEmailSubject(data: InvoiceEmailData):
|
||||
const { invoice, customer, company } = data
|
||||
const L = LABELS[resolveLang(customer)]
|
||||
return sanitizeSubjectLine(
|
||||
L.confirmationSubject(invoice.invoice_number ?? '', getCompanyPrimaryName(company)),
|
||||
L.confirmationSubject(invoice.invoice_number ?? '', getCompanyDisplayName(company)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -482,13 +482,13 @@ export function generatePaymentConfirmationEmailHtml(data: InvoiceEmailData): st
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(L.confirmationSubject(invoice.invoice_number ?? '', getCompanyPrimaryName(company)))}</title>
|
||||
<title>${escapeHtml(L.confirmationSubject(invoice.invoice_number ?? '', getCompanyDisplayName(company)))}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
|
||||
<div style="margin-bottom: 30px; border-bottom: 2px solid ${primaryColor}; padding-bottom: 16px;">
|
||||
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: ${primaryColor};">
|
||||
${escapeHtml(L.confirmationHeading(getCompanyPrimaryName(company)))}
|
||||
${escapeHtml(L.confirmationHeading(getCompanyDisplayName(company)))}
|
||||
</h1>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
${L.documentNumber(L.docInvoice)} ${invoiceNumber}
|
||||
@@ -532,7 +532,7 @@ export function generatePaymentConfirmationEmailHtml(data: InvoiceEmailData): st
|
||||
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">${L.confirmationQuestions}</p>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
${L.sincerely}<br>
|
||||
<strong style="color: ${primaryColor};">${escapeHtml(getCompanyPrimaryName(company))}</strong>
|
||||
<strong style="color: ${primaryColor};">${escapeHtml(getCompanyDisplayName(company))}</strong>
|
||||
</p>
|
||||
${company.org_number ? `
|
||||
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
|
||||
@@ -556,7 +556,7 @@ export function generatePaymentConfirmationEmailText(data: InvoiceEmailData): st
|
||||
const paidDate = paidDateForCustomer(invoice)
|
||||
const number = invoice.invoice_number ?? ''
|
||||
|
||||
let text = `${L.confirmationHeading(getCompanyPrimaryName(company))}\n`
|
||||
let text = `${L.confirmationHeading(getCompanyDisplayName(company))}\n`
|
||||
text += `${L.documentNumber(L.docInvoice)} ${number}\n\n`
|
||||
text += `${L.greeting(firstName)}\n\n`
|
||||
text += `${L.confirmationBody(number)}\n\n`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Invoice, Customer, CompanySettings, ReminderTextOverrides } from '@/types'
|
||||
import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate, getCompanyDisplayName } from '@/lib/utils'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
|
||||
import { applyPlaceholders, escapeHtml, sanitizeSubjectLine } from './user-text'
|
||||
@@ -208,7 +208,7 @@ function buildReminderPlaceholderValues(data: ReminderEmailData): Record<string,
|
||||
fakturanummer: invoice.invoice_number ?? '',
|
||||
kundnamn: fullName,
|
||||
förnamn: fullName ? fullName.split(' ')[0] : '',
|
||||
företag: getCompanyPrimaryName(company),
|
||||
företag: getCompanyDisplayName(company),
|
||||
fakturadatum: formatDate(invoice.invoice_date),
|
||||
förfallodatum: formatDate(invoice.due_date),
|
||||
belopp: formatReminderTotalDue(amounts),
|
||||
@@ -454,7 +454,7 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
|
||||
</p>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
Med vänliga hälsningar,<br>
|
||||
<strong>${getCompanyPrimaryName(company)}</strong>
|
||||
<strong>${getCompanyDisplayName(company)}</strong>
|
||||
</p>
|
||||
${company.org_number ? `
|
||||
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chunk as chunksOf } from '@/lib/utils'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
type MultiUserAccess,
|
||||
type MultiUserGrantRow,
|
||||
} from './multi-user-state'
|
||||
import { isUuid } from '@/lib/invariants/uuid'
|
||||
|
||||
/**
|
||||
* Entitlement gate: the single primitive behind the paywall ("non-payer loses
|
||||
@@ -99,25 +101,13 @@ function isPaywallBypassed(): boolean {
|
||||
return isDevBypass()
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
/**
|
||||
* Only server-resolved UUIDs may be interpolated into the PostgREST `.or()`
|
||||
* filter below: commas/dots/parens are filter syntax. companyId/teamId always
|
||||
* come from the DB, but we validate at this boundary as defense in depth.
|
||||
*/
|
||||
function isUuid(v: string): boolean {
|
||||
return UUID_RE.test(v)
|
||||
}
|
||||
// Only server-resolved UUIDs may be interpolated into the PostgREST `.or()`
|
||||
// filter below: commas/dots/parens are filter syntax. companyId/teamId always
|
||||
// come from the DB, but we validate with isUuid at this boundary as defense
|
||||
// in depth.
|
||||
|
||||
const CAPABILITY_SCOPE_CHUNK_SIZE = 100
|
||||
|
||||
function chunksOf<T>(values: T[], size: number): T[][] {
|
||||
const chunks: T[][] = []
|
||||
for (let index = 0; index < values.length; index += size) {
|
||||
chunks.push(values.slice(index, index + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function grantIsActive(expiresAt: string | null, now: number): boolean {
|
||||
return expiresAt === null || new Date(expiresAt).getTime() > now
|
||||
@@ -611,11 +601,3 @@ export async function getCompanyEntitlements(
|
||||
multiUser,
|
||||
}
|
||||
}
|
||||
|
||||
/** Capability list only; see getCompanyEntitlements for the full shape. */
|
||||
export async function getCompanyCapabilities(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<CapabilityKey[]> {
|
||||
return (await getCompanyEntitlements(supabase, companyId)).capabilities
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CapabilityKey } from './keys'
|
||||
|
||||
/**
|
||||
* Append a usage event to metered_events. Best-effort and non-blocking:
|
||||
* metering must never break the feature it measures, so failures are swallowed.
|
||||
*
|
||||
* Usage cannot be backfilled, so we capture it from day one even though no
|
||||
* usage-based pricing exists yet: it is the raw material for future firm-level
|
||||
* "active company" / consumption billing.
|
||||
*/
|
||||
export async function recordMeteredEvent(
|
||||
supabase: SupabaseClient,
|
||||
params: {
|
||||
companyId: string
|
||||
teamId?: string | null
|
||||
key: CapabilityKey
|
||||
eventType: string
|
||||
attribution?: Record<string, unknown>
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await supabase.from('metered_events').insert({
|
||||
company_id: params.companyId,
|
||||
team_id: params.teamId ?? null,
|
||||
capability_key: params.key,
|
||||
event_type: params.eventType,
|
||||
attribution: params.attribution ?? {},
|
||||
})
|
||||
} catch {
|
||||
// best-effort; never block the metered operation
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,7 @@ export {
|
||||
type MultiUserAccess,
|
||||
type MultiUserGrantRow,
|
||||
} from './multi-user-state'
|
||||
export type { MultiUserState } from './multi-user-state'
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
import { UUID_RE } from '@/lib/invariants/uuid'
|
||||
|
||||
/**
|
||||
* Whether the owner-only dormancy rule is enforced at all in this
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
export { eventBus } from './bus'
|
||||
export type {
|
||||
CoreEvent,
|
||||
CoreEventType,
|
||||
EventPayload,
|
||||
EventHandler,
|
||||
EventSubscription,
|
||||
} from './types'
|
||||
|
||||
@@ -368,8 +368,3 @@ export type EventPayload<T extends CoreEventType> = Extract<CoreEvent, { type: T
|
||||
/** Handler function for a specific event type */
|
||||
export type EventHandler<T extends CoreEventType> = (payload: EventPayload<T>) => Promise<void> | void
|
||||
|
||||
/** Subscription: event type + handler */
|
||||
export interface EventSubscription<T extends CoreEventType = CoreEventType> {
|
||||
eventType: T
|
||||
handler: EventHandler<T>
|
||||
}
|
||||
|
||||
@@ -42,6 +42,4 @@ export function buildRegisterExport(
|
||||
}
|
||||
|
||||
/** Today's date as `YYYY-MM-DD` for export filenames. */
|
||||
export function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
export { todayIsoUtc as todayIso } from '@/lib/dates/iso'
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export { extensionRegistry } from './registry'
|
||||
export { loadExtensions } from './loader'
|
||||
export type {
|
||||
Extension,
|
||||
ApiRouteDefinition,
|
||||
MappingRuleTypeDefinition,
|
||||
ExtensionEventHandler,
|
||||
ExtensionContext,
|
||||
SettingsPanelDefinition,
|
||||
} from './types'
|
||||
@@ -1,3 +1,5 @@
|
||||
import { luhnValidate } from '@/lib/bankgiro/luhn'
|
||||
|
||||
/**
|
||||
* Validates a Swedish personal number (YYYYMMDD-XXXX) using Luhn checksum.
|
||||
* Returns an error message string, or null if valid.
|
||||
@@ -21,17 +23,7 @@ export function validateSwedishPersonalNumber(pnr: string): string | null {
|
||||
|
||||
// Luhn check on the last 10 digits (YYMMDDXXXX)
|
||||
const luhnDigits = cleaned.slice(2)
|
||||
let sum = 0
|
||||
for (let i = 0; i < 10; i++) {
|
||||
let digit = parseInt(luhnDigits[i])
|
||||
if (i % 2 === 0) {
|
||||
digit *= 2
|
||||
if (digit > 9) digit -= 9
|
||||
}
|
||||
sum += digit
|
||||
}
|
||||
|
||||
if (sum % 10 !== 0) return 'Ogiltig kontrollsiffra'
|
||||
if (!luhnValidate(luhnDigits)) return 'Ogiltig kontrollsiffra'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-messag
|
||||
* don't blank out on refresh. Read `loading` to show a pending indicator.
|
||||
* - When `url`/`enabled` start inactive and later become active, `loading`
|
||||
* flips true on the effect tick, not synchronously on the activating render.
|
||||
* Pair with `DataState` (which branches on `loading` first) to avoid a flash.
|
||||
* Branch on `loading` first to avoid a flash.
|
||||
|
||||
*
|
||||
* Response convention: the JSON body is returned as-is, typed as `T`. Most
|
||||
* Accounted routes wrap payloads as `{ data: ... }`, so the common usage is
|
||||
|
||||
@@ -72,12 +72,6 @@ export interface ArticleImportParseResult {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Input for executing the article import. */
|
||||
export interface ArticleImportExecuteInput {
|
||||
rows: ParsedArticleRow[]
|
||||
update_duplicates: boolean
|
||||
}
|
||||
|
||||
/** Result of executing the article import. */
|
||||
export interface ArticleImportExecuteResult {
|
||||
success: boolean
|
||||
|
||||
@@ -165,15 +165,6 @@ export function parseGenericCSV(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get column headers from a CSV file for the mapping UI
|
||||
*/
|
||||
export function getCSVHeaders(content: string, delimiter: string = ','): string[] {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0] || ''
|
||||
return parseCSVLine(firstLine, delimiter).map((h) => h.trim().replace(/^"|"$/g, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preview of the first few rows of a CSV file
|
||||
*/
|
||||
|
||||
@@ -79,25 +79,6 @@ export interface BankFileFormat {
|
||||
parse: (content: string) => BankFileParseResult
|
||||
}
|
||||
|
||||
/** Import tracking record stored in DB */
|
||||
export interface BankFileImport {
|
||||
id: string
|
||||
user_id: string
|
||||
filename: string
|
||||
file_hash: string
|
||||
file_format: string
|
||||
transaction_count: number
|
||||
imported_count: number
|
||||
duplicate_count: number
|
||||
matched_count: number
|
||||
date_from: string | null
|
||||
date_to: string | null
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** Column mapping for generic CSV format */
|
||||
export interface GenericCSVColumnMapping {
|
||||
date: number
|
||||
|
||||
@@ -61,12 +61,6 @@ export interface CustomerImportParseResult {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Input for executing the customer import. */
|
||||
export interface CustomerImportExecuteInput {
|
||||
rows: ParsedCustomerRow[]
|
||||
update_duplicates: boolean
|
||||
}
|
||||
|
||||
/** Result of executing the customer import. */
|
||||
export interface CustomerImportExecuteResult {
|
||||
success: boolean
|
||||
|
||||
@@ -52,16 +52,6 @@ export interface OpeningBalanceParseResult {
|
||||
detected_bank_format: string | null
|
||||
}
|
||||
|
||||
/** Input for executing the opening balance import */
|
||||
export interface OpeningBalanceExecuteInput {
|
||||
fiscal_period_id: string
|
||||
lines: {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}[]
|
||||
}
|
||||
|
||||
/** Result of executing the opening balance import */
|
||||
export interface OpeningBalanceExecuteResult {
|
||||
success: boolean
|
||||
|
||||
@@ -66,3 +66,12 @@ export function normalizeEmail(value: string | null): string | null {
|
||||
if (!value) return null
|
||||
return value.trim().toLowerCase() || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercased dedup key for matching a row by name (articles, and any other
|
||||
* importer that dedupes on a free-text name). Same rule as normalizeEmail.
|
||||
*/
|
||||
export function normalizeNameKey(value: string | null): string | null {
|
||||
if (!value) return null
|
||||
return value.trim().toLowerCase() || null
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ export async function ensureFiscalPeriod(
|
||||
// lib/bookkeeping/validate-period-duration.ts, the same arithmetic and the
|
||||
// same threshold validatePeriodDuration() applies on every other
|
||||
// period-creation path (fiscal-periods POST/PATCH, period-service's
|
||||
// createNextPeriod/createPreviousPeriod, onboarding's computeFiscalPeriod),
|
||||
// createNextPeriod, onboarding's computeFiscalPeriod),
|
||||
// so an 18-month förlängt räkenskapsår imports exactly as it does there and
|
||||
// a 19-month one does not. 18 is the ceiling for an extended or re-laid
|
||||
// year; ongoing years are 12. BFL sets no minimum, so there is no floor here.
|
||||
|
||||
@@ -73,11 +73,6 @@ export interface SupplierImportParseResult {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Input for executing the supplier import. */
|
||||
export interface SupplierImportExecuteInput {
|
||||
rows: ParsedSupplierRow[]
|
||||
update_duplicates: boolean
|
||||
}
|
||||
|
||||
/** Result of executing the supplier import. */
|
||||
export interface SupplierImportExecuteResult {
|
||||
|
||||
@@ -192,14 +192,6 @@ export interface AccountMapping {
|
||||
requiresVatTreatmentReview?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Account mapping context for the mapper
|
||||
*/
|
||||
export interface MappingContext {
|
||||
sourceAccounts: SIEAccount[]
|
||||
existingMappings?: Map<string, AccountMapping>
|
||||
}
|
||||
|
||||
/**
|
||||
* SIE import record (matches database table)
|
||||
*/
|
||||
@@ -243,39 +235,6 @@ export interface SIEAccountMappingRecord {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing an import
|
||||
*/
|
||||
export interface ImportOptions {
|
||||
// The parsed SIE data
|
||||
parsed: ParsedSIEFile
|
||||
|
||||
// Account mappings to use
|
||||
mappings: AccountMapping[]
|
||||
|
||||
// Whether to create a new fiscal period
|
||||
createFiscalPeriod: boolean
|
||||
|
||||
// Whether to import opening balances as a journal entry
|
||||
importOpeningBalances: boolean
|
||||
|
||||
// Whether to import transactions (SIE4 only)
|
||||
importTransactions: boolean
|
||||
|
||||
// Voucher series to use for imported entries
|
||||
voucherSeries?: string
|
||||
|
||||
// Voucher series for the opening-balance (Ingående balanser) entry.
|
||||
// Defaults to a series the file's own vouchers do not use (see
|
||||
// lib/import/opening-balance-defaults.ts) so the IB voucher never shifts
|
||||
// the numbering of the file's series (issue #1882).
|
||||
openingBalanceSeries?: string
|
||||
|
||||
// Opt-in: mark imported verifikat as "Inget underlag krävs" so a migration
|
||||
// doesn't flood "Att hantera: saknade underlag". OFF by default.
|
||||
markImportedNoDocRequired?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured import details for UI display.
|
||||
* Provides machine-readable data so the UI can render proper explanations
|
||||
@@ -495,17 +454,3 @@ export interface MigrationDocumentation {
|
||||
* Wizard step state
|
||||
*/
|
||||
export type ImportWizardStep = 'upload' | 'preview' | 'mapping' | 'review' | 'result'
|
||||
|
||||
/**
|
||||
* Full wizard state
|
||||
*/
|
||||
export interface ImportWizardState {
|
||||
step: ImportWizardStep
|
||||
file: File | null
|
||||
parsed: ParsedSIEFile | null
|
||||
mappings: AccountMapping[]
|
||||
preview: ImportPreview | null
|
||||
importResult: ImportResult | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { UUID_RE, isUuid } from '../uuid'
|
||||
|
||||
describe('UUID_RE / isUuid', () => {
|
||||
it('accepts lower- and upper-case hex layouts', () => {
|
||||
expect(isUuid('123e4567-e89b-12d3-a456-426614174000')).toBe(true)
|
||||
expect(isUuid('123E4567-E89B-12D3-A456-426614174000')).toBe(true)
|
||||
expect(UUID_RE.test('00000000-0000-0000-0000-000000000000')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects anything that is not exactly the 8-4-4-4-12 shape', () => {
|
||||
expect(isUuid('123e4567e89b12d3a456426614174000')).toBe(false)
|
||||
expect(isUuid('123e4567-e89b-12d3-a456-42661417400')).toBe(false)
|
||||
expect(isUuid('123e4567-e89b-12d3-a456-426614174000,x.eq.1')).toBe(false)
|
||||
expect(isUuid(' 123e4567-e89b-12d3-a456-426614174000')).toBe(false)
|
||||
expect(isUuid('')).toBe(false)
|
||||
})
|
||||
|
||||
it('narrows non-strings to false', () => {
|
||||
expect(isUuid(null)).toBe(false)
|
||||
expect(isUuid(undefined)).toBe(false)
|
||||
expect(isUuid(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -34,8 +34,8 @@ export {
|
||||
} from './fiscal-year'
|
||||
|
||||
export {
|
||||
ORG_NUMBER_LENGTH,
|
||||
stripOrgNumberFormatting,
|
||||
|
||||
isOrgNumberShaped,
|
||||
normalizeOrgNumber,
|
||||
isValidOrgNumber,
|
||||
|
||||
@@ -38,8 +38,6 @@ import { luhnValidate } from '@/lib/bankgiro/luhn'
|
||||
* with its own message. Tighten the intake, not the outflow.
|
||||
*/
|
||||
|
||||
/** Digits-only canonical storage length. */
|
||||
export const ORG_NUMBER_LENGTH = 10
|
||||
|
||||
/**
|
||||
* Strip the separators Swedish users and provider APIs put in org numbers.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* UUID shape, as stored in every `id` column. Anchored and case-insensitive;
|
||||
* version/variant nibbles are NOT checked (Postgres accepts any hex layout, and
|
||||
* fixtures use non-RFC ids). Consumers use it as an injection guard before
|
||||
* interpolating a server-resolved id into a PostgREST filter, or to validate a
|
||||
* route parameter before hitting the database.
|
||||
*/
|
||||
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
export function isUuid(value: unknown): value is string {
|
||||
return typeof value === 'string' && UUID_RE.test(value)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
||||
import { ACCOUNT_NUMBER_RE, ACCOUNT_NUMBER_MESSAGE } from './account-number'
|
||||
import { ISO_DATE_RE, ISO_DATE_MESSAGE, SANE_DATE_MESSAGE, isSaneDateString } from './iso-date'
|
||||
import { FISCAL_YEAR_RE, FISCAL_YEAR_MESSAGE } from './fiscal-year'
|
||||
import { isValidOrgNumber, normalizeOrgNumber } from './org-number'
|
||||
import { isValidOrgNumber } from './org-number'
|
||||
|
||||
/**
|
||||
* Zod primitives built from the shared rules.
|
||||
@@ -41,8 +41,3 @@ export const fiscalYearSchema = z.string().regex(FISCAL_YEAR_RE, FISCAL_YEAR_MES
|
||||
export const orgNumberSchema = z
|
||||
.string()
|
||||
.refine(isValidOrgNumber, 'Ogiltigt organisationsnummer (10 eller 12 siffror, giltig kontrollsiffra)')
|
||||
|
||||
/** Org number that is normalized to the canonical 10-digit storage form on parse. */
|
||||
export const normalizedOrgNumberSchema = orgNumberSchema.transform(
|
||||
(v) => normalizeOrgNumber(v) as string,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
* by lib/invoices/bulk-reconcile-supplier-vouchers.ts.
|
||||
*/
|
||||
|
||||
import { chunk } from '@/lib/utils'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { ORE_TOLERANCE, roundOre } from '@/lib/money'
|
||||
@@ -175,12 +176,6 @@ function daysBetween(from: string, to: string): number | null {
|
||||
return Math.round((b - a) / MS_PER_DAY)
|
||||
}
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
function emptyCounts(): RegistrationLinkCounts {
|
||||
return {
|
||||
scanned: 0,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatOrgNumber } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import {
|
||||
Document,
|
||||
@@ -675,15 +676,6 @@ function formatDate(date: string): string {
|
||||
return date.slice(0, 10)
|
||||
}
|
||||
|
||||
// Format org number
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
const cleaned = orgNumber.replace(/\D/g, '')
|
||||
if (cleaned.length === 10) {
|
||||
return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
|
||||
}
|
||||
return orgNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment state the PDF prints for a real faktura (#1693): the BETALD stamp
|
||||
* and the "Betalt / Att betala" rows. Null for every other document or status,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { escapeXml } from '@/lib/xml/escape'
|
||||
import {
|
||||
generateOcrReference,
|
||||
validateBankgiroNumber,
|
||||
@@ -111,15 +112,6 @@ function formatDecimal(value: number): string {
|
||||
return fraction ? `${sign}${whole}.${fraction}` : `${sign}${whole}`
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function hasText(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import { persistPeppolEvidence, persistVerifiedPeppolEvent } from '@/lib/invoices/peppol-delivery'
|
||||
import { persistPeppolEvidence, persistVerifiedPeppolEvent, describeError } from '@/lib/invoices/peppol-delivery'
|
||||
import type { PeppolTransport } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
/** Deliveries the provider may still say something new about. */
|
||||
@@ -42,10 +42,6 @@ export interface PeppolDeliveryPollResult {
|
||||
errors: Array<{ providerSubmissionId: string; reason: string }>
|
||||
}
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
return (err instanceof Error ? err.message : String(err)).slice(0, 500)
|
||||
}
|
||||
|
||||
/** Open deliveries for a provider, oldest status first; default horizon 45 days. */
|
||||
export async function listOpenPeppolDeliveries(args: {
|
||||
service: SupabaseClient
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface StagedPeppolDelivery extends PeppolDeliverySummary {
|
||||
filename: string
|
||||
}
|
||||
|
||||
/** Error text for a peppol_* row's failure column: message or String(err), capped at 500 chars. */
|
||||
export function describeError(err: unknown): string {
|
||||
return (err instanceof Error ? err.message : String(err)).slice(0, 500)
|
||||
}
|
||||
|
||||
export function sha256Hex(value: string | Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { sha256Hex } from '@/lib/invoices/peppol-delivery'
|
||||
import { describeError, sha256Hex } from '@/lib/invoices/peppol-delivery'
|
||||
import {
|
||||
parseUblJsonDocument,
|
||||
type PeppolInboundDocument,
|
||||
@@ -88,10 +88,6 @@ function roundMoney(value: number | null): number | null {
|
||||
return value === null ? null : roundOre(value)
|
||||
}
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
return (err instanceof Error ? err.message : String(err)).slice(0, 500)
|
||||
}
|
||||
|
||||
/** Company for a recipient identifier, via a live registration; null when nobody is registered. */
|
||||
export async function resolvePeppolRecipientCompany(args: {
|
||||
service: SupabaseClient
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { escapeXml } from '@/lib/xml/escape'
|
||||
import type { Invoice, InvoiceItem } from '@/types'
|
||||
import { truncateToWholeKronor } from '@/lib/money'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
@@ -431,15 +432,6 @@ export function evaluateInvoiceForFile(
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* The 31 January deadline: a begäran must reach Skatteverket no later than
|
||||
* 31 January the year AFTER the buyer paid. Returns true when `paidDate`'s
|
||||
|
||||
@@ -29,6 +29,18 @@ import { clearSettledInvoiceSuggestions } from './clear-settled-invoice-suggesti
|
||||
import { documentCurrency, ledgerLineSideAmountIn } from '@/lib/bookkeeping/ledger-line-amount'
|
||||
import type { SupplierInvoice, Supplier } from '@/types'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import {
|
||||
AMOUNT_TOLERANCE,
|
||||
DATE_PROXIMITY_BUMP,
|
||||
DEFAULT_DATE_WINDOW_DAYS,
|
||||
EXCLUDED_SOURCE_TYPES,
|
||||
isDateWithinDays,
|
||||
round2,
|
||||
type FiscalPeriodRow,
|
||||
type VoucherMatchLineRow as JournalEntryLine,
|
||||
type VoucherRow,
|
||||
} from './voucher-matching-shared'
|
||||
import { formatAmount as formatNumber } from '@/lib/utils'
|
||||
|
||||
const log = createLogger('supplier-voucher-matching')
|
||||
|
||||
@@ -40,15 +52,6 @@ const log = createLogger('supplier-voucher-matching')
|
||||
* the 244x range catches that. PR #602 Swedish-compliance fix. */
|
||||
const AP_ACCOUNT_PREFIX = '244'
|
||||
|
||||
/** ±90 days from the invoice's due_date as the default search window. */
|
||||
const DEFAULT_DATE_WINDOW_DAYS = 90
|
||||
|
||||
/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
|
||||
const AMOUNT_TOLERANCE = 0.005
|
||||
|
||||
/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
|
||||
const DATE_PROXIMITY_BUMP = 0.05
|
||||
|
||||
export interface SupplierVoucherCandidate {
|
||||
journal_entry_id: string
|
||||
voucher_series: string | null
|
||||
@@ -73,46 +76,11 @@ export interface SupplierVoucherCandidate {
|
||||
match_reason: string
|
||||
}
|
||||
|
||||
interface JournalEntryLine {
|
||||
id: string
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number | null
|
||||
credit_amount: number | null
|
||||
/** Labels the DOCUMENT, NOT the unit of debit_amount/credit_amount. */
|
||||
currency: string | null
|
||||
/** The line's amount in `currency`: the only non-SEK figure on the row. */
|
||||
amount_in_currency: number | string | null
|
||||
}
|
||||
|
||||
interface VoucherRow {
|
||||
id: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string
|
||||
status: string
|
||||
source_type: string | null
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
/** fiscal_periods carries no `status` column: lock state is the (is_closed,
|
||||
* locked_at) pair, which is exactly what enforce_period_lock() reads in
|
||||
* migration 20240101000017 and what resolvePeriodStatusForDate() uses in
|
||||
* lib/core/bookkeeping/period-service.ts. */
|
||||
interface FiscalPeriodRow {
|
||||
id: string
|
||||
is_closed: boolean | null
|
||||
locked_at: string | null
|
||||
}
|
||||
|
||||
interface CandidateContext {
|
||||
invoice: SupplierInvoice & { supplier?: Supplier }
|
||||
remainingAmount: number
|
||||
}
|
||||
|
||||
const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
|
||||
|
||||
/**
|
||||
* Find posted journal entries whose lines debit 2440 and could plausibly be
|
||||
* the payment for this supplier invoice. Ranking mirrors the customer side:
|
||||
@@ -797,17 +765,6 @@ function computeRemaining(invoice: SupplierInvoice): number {
|
||||
return Math.max(0, round2(invoice.total - paid))
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function isDateWithinDays(a: string, b: string, days: number): boolean {
|
||||
const ad = new Date(a).getTime()
|
||||
const bd = new Date(b).getTime()
|
||||
if (Number.isNaN(ad) || Number.isNaN(bd)) return false
|
||||
return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
|
||||
}
|
||||
|
||||
function descriptionMentionsToken(description: string | null, token: string): boolean {
|
||||
if (!description || !token) return false
|
||||
const normalizedDesc = description.replace(/\s+/g, '').toLowerCase()
|
||||
@@ -815,10 +772,3 @@ function descriptionMentionsToken(description: string | null, token: string): bo
|
||||
if (normalizedTok.length < 2) return false
|
||||
return normalizedDesc.includes(normalizedTok)
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
|
||||
export const QVALIA_PROVIDER = 'qvalia'
|
||||
export const QVALIA_PRODUCTION_BASE_URL = 'https://api.qvalia.com'
|
||||
export const QVALIA_SANDBOX_BASE_URL = 'https://api-test.qvalia.com'
|
||||
export const QVALIA_DEFAULT_WEBHOOK_HEADER = 'x-accounted-webhook-key'
|
||||
|
||||
export type QvaliaAuthScheme = 'apikey' | 'raw'
|
||||
@@ -143,10 +142,6 @@ export class QvaliaApiError extends PeppolTransportError {
|
||||
}
|
||||
}
|
||||
|
||||
export function isQvaliaApiError(error: unknown): error is QvaliaApiError {
|
||||
return error instanceof QvaliaApiError
|
||||
}
|
||||
|
||||
function authorizationHeader(config: QvaliaConfig): string {
|
||||
return config.authScheme === 'raw' ? config.apiKey : `ApiKey ${config.apiKey}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Row shapes, tolerances and helpers shared by the customer-side
|
||||
* (voucher-matching.ts) and supplier-side (supplier-voucher-matching.ts)
|
||||
* "link an existing verifikat as the payment" flows. Both files read the same
|
||||
* journal tables and rank candidates the same way; only the account side
|
||||
* (151x credits vs 244x debits) and the invoice type differ.
|
||||
*/
|
||||
|
||||
/** ±90 days from the invoice's due_date as the default search window. */
|
||||
export const DEFAULT_DATE_WINDOW_DAYS = 90
|
||||
|
||||
/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
|
||||
export const AMOUNT_TOLERANCE = 0.005
|
||||
|
||||
/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
|
||||
export const DATE_PROXIMITY_BUMP = 0.05
|
||||
|
||||
export interface VoucherMatchLineRow {
|
||||
id: string
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number | null
|
||||
credit_amount: number | null
|
||||
/** Labels the DOCUMENT, NOT the unit of debit_amount/credit_amount. */
|
||||
currency: string | null
|
||||
/** The line's amount in `currency`: the only non-SEK figure on the row. */
|
||||
amount_in_currency: number | string | null
|
||||
}
|
||||
|
||||
export interface VoucherRow {
|
||||
id: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string
|
||||
status: string
|
||||
source_type: string | null
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
/** `fiscal_periods` has no `status` column: open/locked/closed is derived from
|
||||
* `is_closed` + `locked_at`, exactly as the `enforce_period_lock` trigger
|
||||
* (migration 017) and `resolvePeriodStatusForDate()` do it. */
|
||||
export interface FiscalPeriodRow {
|
||||
id: string
|
||||
is_closed: boolean | null
|
||||
locked_at: string | null
|
||||
}
|
||||
|
||||
/** SQL-side filter for posted, non-storno, non-opening entries. */
|
||||
export const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
|
||||
|
||||
export function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
export function isDateWithinDays(a: string, b: string, days: number): boolean {
|
||||
const ad = new Date(a).getTime()
|
||||
const bd = new Date(b).getTime()
|
||||
if (Number.isNaN(ad) || Number.isNaN(bd)) return false
|
||||
return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
|
||||
}
|
||||
@@ -32,6 +32,18 @@ import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/b
|
||||
import { clearSettledInvoiceSuggestions } from './clear-settled-invoice-suggestions'
|
||||
import { documentCurrency, ledgerLineSideAmountIn } from '@/lib/bookkeeping/ledger-line-amount'
|
||||
import type { Invoice, Customer } from '@/types'
|
||||
import {
|
||||
AMOUNT_TOLERANCE,
|
||||
DATE_PROXIMITY_BUMP,
|
||||
DEFAULT_DATE_WINDOW_DAYS,
|
||||
EXCLUDED_SOURCE_TYPES,
|
||||
isDateWithinDays,
|
||||
round2,
|
||||
type FiscalPeriodRow,
|
||||
type VoucherMatchLineRow as JournalEntryLine,
|
||||
type VoucherRow,
|
||||
} from './voucher-matching-shared'
|
||||
import { formatAmount as formatNumber } from '@/lib/utils'
|
||||
|
||||
const log = createLogger('voucher-matching')
|
||||
|
||||
@@ -71,15 +83,6 @@ async function resolveAccountingMethod(
|
||||
: 'accrual'
|
||||
}
|
||||
|
||||
/** ±90 days from the invoice's due_date as the default search window. */
|
||||
const DEFAULT_DATE_WINDOW_DAYS = 90
|
||||
|
||||
/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
|
||||
const AMOUNT_TOLERANCE = 0.005
|
||||
|
||||
/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
|
||||
const DATE_PROXIMITY_BUMP = 0.05
|
||||
|
||||
export interface VoucherCandidate {
|
||||
journal_entry_id: string
|
||||
voucher_series: string | null
|
||||
@@ -104,46 +107,11 @@ export interface VoucherCandidate {
|
||||
match_reason: string
|
||||
}
|
||||
|
||||
interface JournalEntryLine {
|
||||
id: string
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number | null
|
||||
credit_amount: number | null
|
||||
/** Labels the DOCUMENT, NOT the unit of debit_amount/credit_amount. */
|
||||
currency: string | null
|
||||
/** The line's amount in `currency`: the only non-SEK figure on the row. */
|
||||
amount_in_currency: number | string | null
|
||||
}
|
||||
|
||||
interface VoucherRow {
|
||||
id: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string
|
||||
status: string
|
||||
source_type: string | null
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
/** `fiscal_periods` has no `status` column: open/locked/closed is derived from
|
||||
* `is_closed` + `locked_at`, exactly as the `enforce_period_lock` trigger
|
||||
* (migration 017) and `resolvePeriodStatusForDate()` do it. */
|
||||
interface FiscalPeriodRow {
|
||||
id: string
|
||||
is_closed: boolean | null
|
||||
locked_at: string | null
|
||||
}
|
||||
|
||||
interface CandidateContext {
|
||||
invoice: Invoice & { customer?: Customer }
|
||||
remainingAmount: number
|
||||
}
|
||||
|
||||
/** Internal: SQL-side filter for posted, non-storno, non-opening entries. */
|
||||
const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
|
||||
|
||||
/**
|
||||
* Find posted journal entries that could plausibly be the payment for this
|
||||
* invoice and return up to `limit` ranked candidates. On faktureringsmetoden
|
||||
@@ -891,27 +859,9 @@ function computeRemaining(invoice: Invoice): number {
|
||||
return Math.max(0, round2(invoice.total - paid))
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function isDateWithinDays(a: string, b: string, days: number): boolean {
|
||||
const ad = new Date(a).getTime()
|
||||
const bd = new Date(b).getTime()
|
||||
if (Number.isNaN(ad) || Number.isNaN(bd)) return false
|
||||
return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
|
||||
}
|
||||
|
||||
function descriptionMentionsInvoice(description: string | null, invoiceNumber: string): boolean {
|
||||
if (!description || !invoiceNumber) return false
|
||||
const normalizedDesc = description.replace(/\s+/g, '').toLowerCase()
|
||||
const normalizedNum = invoiceNumber.replace(/\s+/g, '').toLowerCase()
|
||||
return normalizedDesc.includes(normalizedNum)
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ import { getSenderForBrand, getBaseUrlForBrand } from '@/lib/email/brand-sender'
|
||||
import { resolveBrandForCompany } from '@/lib/branding/resolve'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { chunk } from '@/lib/utils'
|
||||
import { escapeHtml } from '@/lib/email/user-text'
|
||||
|
||||
const log = createLogger('bookkeeping-digest')
|
||||
|
||||
@@ -482,13 +484,6 @@ function toReferenceUuid(referenceKey: string): string {
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
||||
}
|
||||
|
||||
/** Split ids into .in()-safe chunks (see IN_CLAUSE_CHUNK). */
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Company names are user-controlled and reach the Subject header: strip
|
||||
* CR/LF and other control characters so the value can never smuggle extra
|
||||
@@ -499,11 +494,3 @@ function sanitizeHeaderText(input: string): string {
|
||||
return input.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -28,17 +28,8 @@
|
||||
export {
|
||||
captureException,
|
||||
captureMessage,
|
||||
flushObservability,
|
||||
getEnvironment,
|
||||
getObservabilitySink,
|
||||
getRelease,
|
||||
isObservabilityConfigured,
|
||||
noopSink,
|
||||
registerObservabilitySink,
|
||||
resetObservabilitySink,
|
||||
type ObservabilityContext,
|
||||
type ObservabilityLevel,
|
||||
type ObservabilitySink,
|
||||
} from './sink'
|
||||
|
||||
export { REDACTED, REDACT_KEYS, redact, redactString } from './redact'
|
||||
|
||||
@@ -136,5 +136,3 @@ export const PackSchema = z
|
||||
.strict()
|
||||
|
||||
export type Pack = z.infer<typeof PackSchema>
|
||||
export type PackLine = z.infer<typeof PackLineSchema>
|
||||
export type PackMeta = z.infer<typeof PackMetaSchema>
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
* payments it initiates. Subject to 7-year retention.
|
||||
*/
|
||||
|
||||
import { escapeXml } from '@/lib/xml/escape'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { splitDomesticBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
import type { PaymentReference, SupplierPayee } from './supplier-payee'
|
||||
@@ -345,15 +346,6 @@ function sanitizeText(value: string): string {
|
||||
return transliterated.replace(DISALLOWED_TEXT, '?')
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function formatDecimal(amount: number): string {
|
||||
return roundOre(amount).toFixed(2)
|
||||
}
|
||||
|
||||
@@ -38,3 +38,23 @@ export function pdfNumberText(text: string): string {
|
||||
export function pdfText(text: string): string {
|
||||
return text.replaceAll(UNICODE_MINUS, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-decimal sv-SE amount for a react-pdf <Text>: the same Intl call as
|
||||
* lib/utils formatAmount, run through pdfNumberText so a negative årets
|
||||
* resultat or a credit note never prints unsigned (issue #1982).
|
||||
*/
|
||||
export function pdfAmount(amount: number): string {
|
||||
return pdfNumberText(
|
||||
new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount),
|
||||
)
|
||||
}
|
||||
|
||||
/** ISO date string as sv-SE (YYYY-MM-DD in the server timezone); empty input stays empty. */
|
||||
export function formatDateSv(iso: string): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('sv-SE')
|
||||
}
|
||||
|
||||
+125
-146
@@ -82,10 +82,13 @@ import {
|
||||
createSupplierCreditNoteEntry,
|
||||
createSupplierInvoiceRegistrationEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
|
||||
import { linkInvoiceToVoucher, type LinkInvoiceToVoucherResult } from '@/lib/invoices/voucher-matching'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
|
||||
import {
|
||||
linkSupplierInvoiceToVoucher,
|
||||
type LinkSupplierInvoiceToVoucherResult,
|
||||
} from '@/lib/invoices/supplier-voucher-matching'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import {
|
||||
@@ -176,7 +179,7 @@ import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
|
||||
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
||||
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
|
||||
import { applyRecurringScheduleUpdate } from '@/lib/invoices/apply-recurring-schedule-update'
|
||||
import { BulkBookInboxSchema } from '@/lib/api/schemas'
|
||||
import { BulkBookInboxSchema, OpeningBalancesBulkSchema } from '@/lib/api/schemas'
|
||||
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
|
||||
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
import { z } from 'zod'
|
||||
@@ -200,6 +203,7 @@ import type {
|
||||
CreditNote,
|
||||
CreateJournalEntryLineInput,
|
||||
JournalEntrySourceType,
|
||||
FiscalPeriod,
|
||||
} from '@/types'
|
||||
|
||||
const log = createLogger('pending-operations/commit')
|
||||
@@ -300,6 +304,34 @@ async function recordSkippedInvoiceJournalEntry(
|
||||
|
||||
// ── Executors ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The company's booking context for invoice-shaped executors: accounting
|
||||
* method and entity type with the engine defaults when the settings row is
|
||||
* missing or the read fails (errors are deliberately ignored, as before).
|
||||
*/
|
||||
async function loadBookingContext(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<{ accountingMethod: AccountingMethod; entityType: EntityType }> {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
||||
|
||||
return {
|
||||
accountingMethod: (settings?.accounting_method as AccountingMethod) || 'accrual',
|
||||
entityType: (settings?.entity_type as EntityType) || 'enskild_firma',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-body shared by the ledger-writing executors: a BookkeepingError is
|
||||
* rethrown so the dispatcher maps it to a structured code; anything else
|
||||
* becomes a plain failure with the executor's fallback text and status.
|
||||
*/
|
||||
function failUnlessBookkeepingError(err: unknown, fallback: string, status: number): ExecutorResult {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : fallback, status }
|
||||
}
|
||||
|
||||
type ExecutorResult = {
|
||||
data?: Record<string, unknown>
|
||||
error?: string
|
||||
@@ -2253,11 +2285,7 @@ async function commitMarkInvoicePaid(
|
||||
}
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const { accountingMethod, entityType } = await loadBookingContext(supabase, companyId)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
@@ -2911,11 +2939,7 @@ async function commitMatchTransactionInvoice(
|
||||
// (BookkeepingDatabaseError on a failed cash_accounts lookup), and a throw
|
||||
// here must reject the op with NOTHING posted. Behavior-preserving on the
|
||||
// happy path: these are pure reads.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const { accountingMethod, entityType } = await loadBookingContext(supabase, companyId)
|
||||
|
||||
// Route on invoice state, not the company's current setting. Mirror of
|
||||
// the match-invoice route fix: see that handler for the full rationale.
|
||||
@@ -3137,6 +3161,38 @@ async function commitMatchTransactionInvoice(
|
||||
return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } }
|
||||
}
|
||||
|
||||
type VoucherLinkOutcome =
|
||||
| { ok: true; result: LinkInvoiceToVoucherResult | LinkSupplierInvoiceToVoucherResult }
|
||||
| { ok: false; code: string }
|
||||
|
||||
/**
|
||||
* Map a voucher-link outcome (customer or supplier invoice) to the executor
|
||||
* result. 404/409 are auto-rejected by the dispatcher (the user can re-stage
|
||||
* with adjusted inputs); 400 surfaces as a normal failure so the UI can
|
||||
* explain what went wrong.
|
||||
*/
|
||||
function voucherLinkOutcomeToResult(outcome: VoucherLinkOutcome): ExecutorResult {
|
||||
if (!outcome.ok) {
|
||||
const entry = getErrorEntry(outcome.code)
|
||||
return {
|
||||
error: entry?.message_en ?? outcome.code,
|
||||
status: entry?.httpStatus ?? 500,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
invoice_status: outcome.result.invoiceStatus,
|
||||
paid_amount: outcome.result.paidAmount,
|
||||
remaining_amount: outcome.result.remainingAmount,
|
||||
payment_amount: outcome.result.paymentAmount,
|
||||
payment_id: outcome.result.paymentId,
|
||||
journal_entry_id: outcome.result.journalEntryId,
|
||||
reconciled_transaction_id: outcome.result.reconciledTransactionId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function commitLinkInvoiceVoucher(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -3157,29 +3213,7 @@ async function commitLinkInvoiceVoucher(
|
||||
notes,
|
||||
})
|
||||
|
||||
if (!outcome.ok) {
|
||||
const entry = getErrorEntry(outcome.code)
|
||||
const httpStatus = entry?.httpStatus ?? 500
|
||||
// 404/409 are auto-rejected by the dispatcher (the user can re-stage with
|
||||
// adjusted inputs); 400 surfaces as a normal failure so the UI can
|
||||
// explain what went wrong.
|
||||
return {
|
||||
error: entry?.message_en ?? outcome.code,
|
||||
status: httpStatus,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
invoice_status: outcome.result.invoiceStatus,
|
||||
paid_amount: outcome.result.paidAmount,
|
||||
remaining_amount: outcome.result.remainingAmount,
|
||||
payment_amount: outcome.result.paymentAmount,
|
||||
payment_id: outcome.result.paymentId,
|
||||
journal_entry_id: outcome.result.journalEntryId,
|
||||
reconciled_transaction_id: outcome.result.reconciledTransactionId,
|
||||
},
|
||||
}
|
||||
return voucherLinkOutcomeToResult(outcome)
|
||||
}
|
||||
|
||||
async function commitLinkSupplierInvoiceVoucher(
|
||||
@@ -3202,45 +3236,43 @@ async function commitLinkSupplierInvoiceVoucher(
|
||||
notes,
|
||||
})
|
||||
|
||||
if (!outcome.ok) {
|
||||
const entry = getErrorEntry(outcome.code)
|
||||
// 404/409 are auto-rejected by the dispatcher (the user can re-stage with
|
||||
// adjusted inputs); 400 surfaces as a normal failure so the UI can explain.
|
||||
return {
|
||||
error: entry?.message_en ?? outcome.code,
|
||||
status: entry?.httpStatus ?? 500,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
invoice_status: outcome.result.invoiceStatus,
|
||||
paid_amount: outcome.result.paidAmount,
|
||||
remaining_amount: outcome.result.remainingAmount,
|
||||
payment_amount: outcome.result.paymentAmount,
|
||||
payment_id: outcome.result.paymentId,
|
||||
journal_entry_id: outcome.result.journalEntryId,
|
||||
reconciled_transaction_id: outcome.result.reconciledTransactionId,
|
||||
},
|
||||
}
|
||||
return voucherLinkOutcomeToResult(outcome)
|
||||
}
|
||||
|
||||
// ── Stream 1 Phase 1 + follow-up executors ───────────────────────
|
||||
|
||||
/**
|
||||
* Close / lock / unlock share one shape: require fiscal_period_id, run the
|
||||
* period-service transition, answer with the period id plus the timestamp
|
||||
* the transition set (closed_at or locked_at), and turn any throw into a
|
||||
* 400 with the service's message.
|
||||
*/
|
||||
async function runPeriodTransition(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>,
|
||||
transition: (supabase: SupabaseClient, companyId: string, userId: string, id: string) => Promise<FiscalPeriod>,
|
||||
timestampKey: 'closed_at' | 'locked_at',
|
||||
fallback: string
|
||||
): Promise<ExecutorResult> {
|
||||
const id = params.fiscal_period_id as string
|
||||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||||
try {
|
||||
const period = await transition(supabase, companyId, userId, id)
|
||||
return { data: { period_id: period.id, [timestampKey]: period[timestampKey] } }
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : fallback, status: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
async function commitClosePeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
const id = params.fiscal_period_id as string
|
||||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||||
try {
|
||||
const period = await closePeriod(supabase, companyId, userId, id)
|
||||
return { data: { period_id: period.id, closed_at: period.closed_at } }
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Close failed', status: 400 }
|
||||
}
|
||||
return runPeriodTransition(supabase, userId, companyId, params, closePeriod, 'closed_at', 'Close failed')
|
||||
}
|
||||
|
||||
async function commitLockPeriod(
|
||||
@@ -3249,14 +3281,7 @@ async function commitLockPeriod(
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
const id = params.fiscal_period_id as string
|
||||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||||
try {
|
||||
const period = await lockPeriod(supabase, companyId, userId, id)
|
||||
return { data: { period_id: period.id, locked_at: period.locked_at } }
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Lock failed', status: 400 }
|
||||
}
|
||||
return runPeriodTransition(supabase, userId, companyId, params, lockPeriod, 'locked_at', 'Lock failed')
|
||||
}
|
||||
|
||||
async function commitUnlockPeriod(
|
||||
@@ -3265,14 +3290,7 @@ async function commitUnlockPeriod(
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
const id = params.fiscal_period_id as string
|
||||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||||
try {
|
||||
const period = await unlockPeriod(supabase, companyId, userId, id)
|
||||
return { data: { period_id: period.id, locked_at: period.locked_at } }
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Unlock failed', status: 400 }
|
||||
}
|
||||
return runPeriodTransition(supabase, userId, companyId, params, unlockPeriod, 'locked_at', 'Unlock failed')
|
||||
}
|
||||
|
||||
async function commitUncategorizeTransaction(
|
||||
@@ -3288,8 +3306,7 @@ async function commitUncategorizeTransaction(
|
||||
try {
|
||||
await reverseEntry(supabase, companyId, userId, journalEntryId)
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Reversal failed', status: 500 }
|
||||
return failUnlessBookkeepingError(err, 'Reversal failed', 500)
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
@@ -3716,8 +3733,7 @@ async function commitRunYearEnd(
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Year-end failed', status: 400 }
|
||||
return failUnlessBookkeepingError(err, 'Year-end failed', 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3831,11 +3847,7 @@ async function commitPostKontantmetodCutoff(
|
||||
if (err instanceof KontantmetodCutoffPartialError) {
|
||||
throw new PartialCommitError(err.message, err.postedIds, err.cause)
|
||||
}
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Kontantmetodens bokslutsavgränsning misslyckades',
|
||||
status: 400,
|
||||
}
|
||||
return failUnlessBookkeepingError(err, 'Kontantmetodens bokslutsavgränsning misslyckades', 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3853,8 +3865,7 @@ async function commitSetOpeningBalances(
|
||||
const entry = await generateOpeningBalances(supabase, companyId, userId, closedId, nextId)
|
||||
return { data: { opening_balance_entry_id: entry.id } }
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Opening balances failed', status: 400 }
|
||||
return failUnlessBookkeepingError(err, 'Opening balances failed', 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3876,8 +3887,7 @@ async function commitRunCurrencyRevaluation(
|
||||
: { entry_id: null, items_revalued: 0, message: 'No foreign-currency items to revalue' },
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Revaluation failed', status: 400 }
|
||||
return failUnlessBookkeepingError(err, 'Revaluation failed', 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3910,8 +3920,7 @@ async function commitPostAnnualDepreciation(
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Depreciation posting failed', status: 400 }
|
||||
return failUnlessBookkeepingError(err, 'Depreciation posting failed', 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4517,8 +4526,7 @@ async function commitCreditSupplierInvoice(
|
||||
}
|
||||
} catch (err) {
|
||||
await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId)
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Failed to book credit note', status: 500 }
|
||||
return failUnlessBookkeepingError(err, 'Failed to book credit note', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4660,14 +4668,7 @@ async function commitCreditInvoice(
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, accounting_method')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
const { accountingMethod, entityType } = await loadBookingContext(supabase, companyId)
|
||||
|
||||
// Resolve the original verifikation reference so the credit-note JE can
|
||||
// point back to the corrected entry per BFL 5 kap. 5 §. We tolerate
|
||||
@@ -4959,8 +4960,7 @@ async function commitImportSie(
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'SIE import failed', status: 500 }
|
||||
return failUnlessBookkeepingError(err, 'SIE import failed', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5216,8 +5216,7 @@ async function commitCreateVoucher(
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Failed to create voucher', status: 500 }
|
||||
return failUnlessBookkeepingError(err, 'Failed to create voucher', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5309,8 +5308,7 @@ async function commitCorrectEntry(
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Failed to correct entry', status: 500 }
|
||||
return failUnlessBookkeepingError(err, 'Failed to correct entry', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5394,8 +5392,7 @@ async function commitReverseEntry(
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) throw err
|
||||
return { error: err instanceof Error ? err.message : 'Failed to reverse entry', status: 500 }
|
||||
return failUnlessBookkeepingError(err, 'Failed to reverse entry', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5597,7 +5594,6 @@ async function commitUpdatePayslipLine(
|
||||
|
||||
try {
|
||||
const { updatePayslipLine } = await import('@/lib/salary/payslip-lines')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await updatePayslipLine(supabase, {
|
||||
companyId,
|
||||
salaryRunId,
|
||||
@@ -5642,7 +5638,6 @@ async function commitSetRunSalary(
|
||||
|
||||
try {
|
||||
const { setRunEmployeeSalary } = await import('@/lib/salary/run-employees')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await setRunEmployeeSalary(supabase, {
|
||||
companyId,
|
||||
salaryRunId,
|
||||
@@ -5686,7 +5681,6 @@ async function commitUpdateSalaryRun(
|
||||
|
||||
try {
|
||||
const { updateDraftSalaryRun } = await import('@/lib/salary/update-run')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await updateDraftSalaryRun(supabase, {
|
||||
companyId,
|
||||
salaryRunId,
|
||||
@@ -5724,7 +5718,6 @@ async function commitCreateEmployee(
|
||||
): Promise<ExecutorResult> {
|
||||
try {
|
||||
const { createEmployee } = await import('@/lib/salary/employee-commands')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await createEmployee(supabase, {
|
||||
companyId,
|
||||
userId,
|
||||
@@ -5761,7 +5754,6 @@ async function commitUpdateEmployee(
|
||||
|
||||
try {
|
||||
const { updateEmployee } = await import('@/lib/salary/employee-commands')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await updateEmployee(supabase, { companyId, employeeId, patch })
|
||||
if (!result.ok) {
|
||||
const entry = getErrorEntry(result.code)
|
||||
@@ -5796,7 +5788,6 @@ async function commitRegisterAbsence(
|
||||
|
||||
try {
|
||||
const { upsertAbsenceRange } = await import('@/lib/salary/absence')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await upsertAbsenceRange(supabase, {
|
||||
companyId,
|
||||
employeeId,
|
||||
@@ -5856,7 +5847,6 @@ async function commitBookSalaryRun(
|
||||
|
||||
try {
|
||||
const { advanceAndBookSalaryRun } = await import('@/lib/salary/book-run')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await advanceAndBookSalaryRun(supabase, {
|
||||
companyId,
|
||||
userId,
|
||||
@@ -5912,7 +5902,6 @@ async function commitDeleteAbsence(
|
||||
|
||||
try {
|
||||
const { deleteAbsenceRange } = await import('@/lib/salary/absence')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await deleteAbsenceRange(supabase, {
|
||||
companyId,
|
||||
employeeId,
|
||||
@@ -5966,13 +5955,11 @@ async function commitSetEmployeeOpeningBalances(
|
||||
}
|
||||
|
||||
try {
|
||||
const { OpeningBalancesBulkSchema } = await import('@/lib/api/schemas')
|
||||
const parsed = OpeningBalancesBulkSchema.safeParse({ items })
|
||||
if (!parsed.success) {
|
||||
return { error: 'Ogiltiga ingående saldon i den godkända operationen', status: 400 }
|
||||
}
|
||||
const { setOpeningBalancesBulk } = await import('@/lib/salary/opening-balances')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await setOpeningBalancesBulk(supabase, {
|
||||
companyId,
|
||||
userId,
|
||||
@@ -6014,7 +6001,6 @@ async function commitVacationYearClose(
|
||||
|
||||
try {
|
||||
const { commitVacationYearClose: runClose } = await import('@/lib/salary/semesterberedning')
|
||||
const { getErrorEntry } = await import('@/lib/errors/structured-errors')
|
||||
const result = await runClose(supabase, companyId, userId, yearStart, { bookAdjustment })
|
||||
if (!result.ok) {
|
||||
const entry = getErrorEntry(result.code)
|
||||
@@ -6049,20 +6035,25 @@ async function commitVacationYearClose(
|
||||
// Error, which the dispatcher catch releases back to 'pending'; a non-recoverable
|
||||
// failure becomes a plain { error, status } that rejects the op.
|
||||
|
||||
function getSkatteverketServices(): SkatteverketCommitServices {
|
||||
const services = extensionRegistry.get('skatteverket')?.services as
|
||||
| Partial<SkatteverketCommitServices>
|
||||
| undefined
|
||||
if (!services?.commitSubmitVatDeclaration || !services?.commitSubmitAgi) {
|
||||
// Extension absent or not wired. Recoverable: leave the op pending so a
|
||||
// re-enable + re-approve works without re-staging.
|
||||
/**
|
||||
* Resolve the Skatteverket extension's services and require the given keys
|
||||
* to be wired. Extension absent or not wired is recoverable: the op is left
|
||||
* pending so a re-enable + re-approve works without re-staging.
|
||||
*/
|
||||
function requireSkatteverketServices<T extends object>(keys: ReadonlyArray<keyof T>): T {
|
||||
const services = extensionRegistry.get('skatteverket')?.services as Partial<T> | undefined
|
||||
if (!services || keys.some((key) => !services[key])) {
|
||||
throw new SkatteverketRecoverableError(
|
||||
'Skatteverket-integrationen är inte tillgänglig.',
|
||||
'EXTENSION_DISABLED',
|
||||
503,
|
||||
)
|
||||
}
|
||||
return services as SkatteverketCommitServices
|
||||
return services as T
|
||||
}
|
||||
|
||||
function getSkatteverketServices(): SkatteverketCommitServices {
|
||||
return requireSkatteverketServices<SkatteverketCommitServices>(['commitSubmitVatDeclaration', 'commitSubmitAgi'])
|
||||
}
|
||||
|
||||
function handleSkvSubmitResult(result: SkvSubmitResult): ExecutorResult {
|
||||
@@ -6115,19 +6106,7 @@ async function commitSubmitAgi(
|
||||
// work (or fail recoverable) independently of the SKV filing services.
|
||||
|
||||
function getSkattekontoBookingService(): SkattekontoBookingCommitService {
|
||||
const services = extensionRegistry.get('skatteverket')?.services as
|
||||
| Partial<SkattekontoBookingCommitService>
|
||||
| undefined
|
||||
if (!services?.commitBookSkattekontoRows) {
|
||||
// Extension absent or not wired. Recoverable: leave the op pending so a
|
||||
// re-enable + re-approve works without re-staging.
|
||||
throw new SkatteverketRecoverableError(
|
||||
'Skatteverket-integrationen är inte tillgänglig.',
|
||||
'EXTENSION_DISABLED',
|
||||
503,
|
||||
)
|
||||
}
|
||||
return services as SkattekontoBookingCommitService
|
||||
return requireSkatteverketServices<SkattekontoBookingCommitService>(['commitBookSkattekontoRows'])
|
||||
}
|
||||
|
||||
async function commitBookSkattekontoRows(
|
||||
|
||||
@@ -29,7 +29,3 @@ export class PartialCommitError extends Error {
|
||||
this.cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
export function isPartialCommitError(err: unknown): err is PartialCommitError {
|
||||
return err instanceof PartialCommitError
|
||||
}
|
||||
|
||||
@@ -137,6 +137,3 @@ export const UpdateAccountParamsSchema = z.object({
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateAccountParams = z.infer<typeof CreateAccountParamsSchema>
|
||||
export type UpdateAccountParams = z.infer<typeof UpdateAccountParamsSchema>
|
||||
|
||||
@@ -89,5 +89,3 @@ export const UpdateArticleParamsSchema = z.object({
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type CreateArticleParams = z.infer<typeof CreateArticleParamsSchema>
|
||||
export type UpdateArticleParams = z.infer<typeof UpdateArticleParamsSchema>
|
||||
|
||||
@@ -94,6 +94,3 @@ export const UpdateCompanySettingsParamsSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type UpdateCompanySettingsParams = z.infer<
|
||||
typeof UpdateCompanySettingsParamsSchema
|
||||
>
|
||||
|
||||
@@ -168,5 +168,3 @@ export const CreateSupplierParamsSchema = z
|
||||
// An EU supplier below its national registration threshold has no VAT number at
|
||||
// all, so requiring one refused legitimate suppliers on the staged/agent path
|
||||
// while the dashboard form created the very same company without complaint.
|
||||
|
||||
export type CreateSupplierParams = z.infer<typeof CreateSupplierParamsSchema>
|
||||
|
||||
@@ -49,4 +49,3 @@ export const UpdateCustomerParamsSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type UpdateCustomerParams = z.infer<typeof UpdateCustomerParamsSchema>
|
||||
|
||||
@@ -69,5 +69,3 @@ export const CreateDimensionValueParamsSchema = z
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateDimensionValueParams = z.infer<typeof CreateDimensionValueParamsSchema>
|
||||
|
||||
@@ -15,5 +15,3 @@ export const IgnoreTransactionParamsSchema = z.object({
|
||||
// true = ignore (default), false = restore a previously ignored row.
|
||||
ignored: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export type IgnoreTransactionParams = z.infer<typeof IgnoreTransactionParamsSchema>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user