* fix(invoices): reminders, AR ledger, AR reconciliation and deadlines only read fakturor The overdue-reminder run, the kundreskontra, the 1510 reconciliation and the deadlines page selected invoices by status alone. A sent proforma past its due date was chased with a betalningspaminnelse and flipped to 'overdue', and it appeared as a receivable. All four now filter document_type = 'invoice', which is also the precondition for adding quotes (offert): a quote carries a date but never a receivable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): offert (quote) document type with its own OF-series, decisions and conversion Adds document_type 'quote' with valid_until, quote_status (open / accepted / declined; expired is derived from valid_until, never stored) and quote_decided_at. Quotes are numbered OF-nnn at insert from company_settings.next_quote_number via generate_quote_number(), the same pattern as delivery notes, so a declined quote never leaves a hole in the F-series the way a proforma does. The column next_quote_number already existed on prod and staging without a migration; the migration adopts it. Engine: build-invoice-write writes the quote columns and keeps remaining_amount at 0; the draft editor refuses accepted or declined quotes; PATCH refuses changing a quote's or delivery note's document type since the number belongs to the series; mark-paid refuses quotes. New POST /api/invoices/[id]/quote-status records the decision and locks once an invoice exists. Conversion is extracted into lib/invoices/convert-to-invoice.ts (one implementation for the route and the MCP staged commit, which had drifted): a converted quote stays and flips to accepted, the invoice links back via converted_from_id and gets its due date from the customer's payment terms; a declined or already invoiced quote is refused. next-number previews the OF-series for quotes. Migration applied to the staging branch and registered as 20260902140000; the pg test runs in CI (pg-real). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): quote PDF, email and filename surfaces The customer-facing surfaces get a quote sibling for every proforma branch: PDF title OFFERT / QUOTE with Offertdatum and Giltig till instead of the due date, a notice that the document is not an invoice or a payment request, and no payment box, OCR, bankgiro, Swish, QR or payment link. The email says the quote is attached and valid until the expiry, drops the payment details and pay-online button, and asks about the quote rather than the invoice. Filenames read "Offert nr OF-001". Seller VAT number and payment accounts are skipped for quotes as for proformas: a quote is not a faktura under ML 17 kap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): offert in the editor, list and detail pages Editor: "Offert" document type with a required "Giltig till" field (default today + 30 days) in place of the due date; the wire body mirrors it into due_date so the shared schema is satisfied. Payment link, ROT/RUT, periodisering and the bank box are already gated on real invoices. The type cannot be switched on an existing quote (its OF-number belongs to the series). List: an Offerter tab beside Proforma, "Ny offert" in the split button, and a status column that shows the decision or the derived expiry: Utgången and Avböjd are exception chips, Öppen and Accepterad muted text. Detail: Acceptera and Skapa faktura in the header, Avböj in the overflow menu; an expired quote asks before accepting or invoicing (bypassable); once an invoice exists the page links to it as Fakturerad and hides the decision actions. Strings in both sv and en. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(mcp,v1): expose offert on the MCP tools and the v1 REST surface MCP: create_invoice takes document_type quote with a required valid_until and allocates the OF-number at insert; the convert tool keeps its id and accepts quotes with the registry refusal codes; new set_quote_status; list_invoices and get_invoice expose valid_until and the effective quote status, including a derived expired filter. The tools/list payload stays under its ceiling without a ledger change. The MCP staged convert now uses the shared converter. v1: POST /invoices/{id}/quote-status (registered in the endpoint registry, scope map and route loader), valid_until and quote_status in the list, create and detail shapes, and a quote_status list filter. Skill atoms mention offert. Decision log lines for the own number series, derived expiry, accepted-not-cancelled conversion and the header action layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * test(invoices): pass route params and period id in the new quote tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * refactor(invoices): literal update payloads in the converter so the phantom-column guard can read them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * fix(invoices): close the quote review findings in one pass Skeptics (correctness, compliance, regression) and CodeRabbit on #2163: - quote_status is no longer a write-builder output, so a v1 PATCH or MCP update_invoice can never reset a recorded accept/decline; new quotes are opened by the invoices_quote_defaults trigger (20260902141000), which also keeps due_date and valid_until equal. v1 PATCH and the MCP update executor now use the shared editable-draft predicate. - One live invoice per converted source, enforced by a partial unique index; the converter maps 23505 to INVOICE_QUOTE_ALREADY_INVOICED and both quote-status routes compare-and-set on the decision they read. - MCP-created quotes carry remaining_amount 0; mark-paid, transaction match and voucher link refuse non-invoices on the MCP staging tools, the executors and the dashboard link route. - Conversion of a foreign-currency source refetches the rate for the conversion day (ML 8 kap 21-23 paragraphs) and fails closed without one; 0-day payment terms mean due on receipt. - bulk-create refuses quotes per item; list_invoices rejects a quote_status filter combined with another document_type; an omitted document_type on PATCH means unchanged. - attention, push notifications, open-AR count, FX revaluation, year-end and accrual auto-detect and bank-match suggestions only read fakturor. - Quote PDF and email print Summa / Total instead of Att betala. - Regenerated skills/accounted-api for the new v1 endpoint. Declined with reasons in DECISIONS.md: NOT VALID + VALIDATE and CONCURRENTLY on the migrations (repo precedent, 13.8k rows, transactional apply); re-validating VAT treatment at conversion (the converted invoice is a draft the user reviews; follow-up). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): second review round: migration versions, order links, batch allocation, races - Migrations renamed to 20260902220000 / 20260902221000: #2166 shipped its own 20260902141000 to prod while this PR was in review and prod's head moved past both files; below-head versions are skipped by branching, which would have left the quote trigger off prod. Staging rows renamed. - Quote lines never carry sales_order_item_id (an offer must not count as invoiced kundorder quantity); the converter carries a proforma line's order link onto the invoice. - Converter compare-and-sets the source (proforma cancel, quote accept): a concurrent cancel, proforma-to-order conversion or decision removes the orphan invoice with INVOICE_CONVERT_SOURCE_CHANGED instead of a second document for the same sale. - MCP set_quote_status gets the same compare-and-set as the HTTP routes; 0-row updates report INVOICE_QUOTE_CHANGED_CONCURRENTLY everywhere. quote-status (dashboard, v1, MCP) accepts valid_until so an expired sent quote can be reopened, as the docs promised. - MCP mark-paid refuses only quotes, parity with the dashboard route (a sent proforma marked paid is a supported prepayment record). - Batch allocation (dashboard route and MCP tool) refuses non-invoices before the RPC, which gates on status alone. - Customer AR drill-down, v1 customer open invoices and archive guard, and the calendar feed read fakturor only. - Draft quote PDF says "UTKAST" instead of "not a valid invoice"; the editor locks the document type on existing quotes and delivery notes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * chore(invoices): use roundOre in the quote MCP summaries and FX test after main tightened the guard baseline Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): third review round: atomic decision lock, viewer gate, lookup errors, quote payment terms - 20260902222000: BEFORE UPDATE trigger locks an accepted quote while a live converted invoice exists (the compare-and-set in the three decision writers could still be beaten by a conversion landing in between); the routes and the MCP tool map the raise to 409 INVOICE_QUOTE_ALREADY_INVOICED. generate_quote_number now also requires a non-viewer membership so a viewer's session token cannot burn OF-numbers through PostgREST. - Converter checks quote eligibility before the Riksbanken call and treats a failed company_settings read as a failure instead of a 30-day default. - Re-sending the same decision keeps quote_decided_at (idempotent). - gnubok_find_voucher_candidates_for_invoice refuses non-invoices like its write sibling; the dashboard link route surfaces a failed lookup. - Late-fee and credit-term texts never print on a quote. Applied and registered on staging; pg tests added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): review nits: fail-closed batch lookup, dry-run expiry, quote heading, quote-date CHECK - match-batch surfaces a failed document lookup instead of allocating. - v1 quote-status dry-run preview carries the new valid_until. - Quote PDF heading reads Offertinformation / Quote information. - 20260902222000 also pins the date invariants the trigger maintains as a CHECK: a quote always has valid_until = due_date, nothing else has one. Applied on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
615 lines
23 KiB
TypeScript
615 lines
23 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
|
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
|
import {
|
|
fetchPaymentsAsOf,
|
|
outstandingAsOf,
|
|
todayIsoDate,
|
|
type PaymentsAsOf,
|
|
} from '@/lib/reports/reskontra-payments'
|
|
import {
|
|
BookkeepingDatabaseError,
|
|
CurrencyRevaluationAlreadyExistsError,
|
|
} from '@/lib/bookkeeping/errors'
|
|
import type {
|
|
Currency,
|
|
Invoice,
|
|
SupplierInvoice,
|
|
RevaluationItem,
|
|
CurrencyRevaluationPreview,
|
|
CurrencyRevaluationResult,
|
|
CreateJournalEntryLineInput,
|
|
} from '@/types'
|
|
|
|
export const FX_CLOSING_RATE_UNAVAILABLE = 'FX_CLOSING_RATE_UNAVAILABLE' as const
|
|
|
|
/** A currency whose balansdagen rate could not be established. */
|
|
export interface MissingClosingRate {
|
|
currency: Currency
|
|
date: string
|
|
}
|
|
|
|
/**
|
|
* Raised when the revaluation would have to invent a closing rate.
|
|
*
|
|
* The balansdagen valuation of monetary items (ÅRL 4 kap. 13 §) posts a real
|
|
* verifikat to 3960/7960, so the rate behind it must be a real Riksbanken
|
|
* observation. `code` is in the structured-error registry, so REST routes and
|
|
* MCP tools translate it without any per-caller handling.
|
|
*/
|
|
export class ClosingRateUnavailableError extends Error {
|
|
readonly code = FX_CLOSING_RATE_UNAVAILABLE
|
|
constructor(public readonly missingRates: MissingClosingRate[]) {
|
|
super(
|
|
`No Riksbanken exchange rate available for ${missingRates
|
|
.map((m) => `${m.currency} on ${m.date}`)
|
|
.join(', ')}: currency revaluation refused rather than posted from an estimated rate.`
|
|
)
|
|
this.name = 'ClosingRateUnavailableError'
|
|
}
|
|
}
|
|
|
|
/** An open foreign-currency row that carries no exchange rate at all. */
|
|
export interface UnconvertedFxItem {
|
|
type: 'receivable' | 'payable'
|
|
source_id: string
|
|
reference: string
|
|
currency: Currency
|
|
amount_in_currency: number
|
|
}
|
|
|
|
/**
|
|
* Preview plus the two exclusion channels a caller must be able to show:
|
|
* rows that carry no original rate, and currencies with no closing rate.
|
|
* Same shape of contract as `unconverted_fx_count` on the reskontra reports.
|
|
*/
|
|
export interface CurrencyRevaluationPreviewWithExclusions extends CurrencyRevaluationPreview {
|
|
/**
|
|
* Open foreign-currency rows excluded because they have no `exchange_rate`
|
|
* on file. They cannot be revalued (there is no original SEK value to
|
|
* compare against) and they are usually the largest unmeasured exposure, so
|
|
* they are counted and returned instead of silently dropped.
|
|
*/
|
|
unconvertedFx: UnconvertedFxItem[]
|
|
unconvertedFxCount: number
|
|
/** Currencies with no Riksbanken observation for the closing date. */
|
|
missingClosingRates: MissingClosingRate[]
|
|
}
|
|
|
|
export interface CurrencyRevaluationResultWithExclusions extends CurrencyRevaluationResult {
|
|
preview: CurrencyRevaluationPreviewWithExclusions
|
|
}
|
|
|
|
/** A stored rate is usable only when present and strictly positive. */
|
|
function hasUsableRate(rate: number | null | undefined): boolean {
|
|
return rate != null && Number(rate) > 0
|
|
}
|
|
|
|
/**
|
|
* Which invoice rows can carry balance-sheet FX exposure for this company.
|
|
*
|
|
* ÅRL 4 kap. 13 § revalues MONETARY ITEMS ON THE BALANCE SHEET, and an invoice
|
|
* row only puts anything on 1510/2440 once its registration is booked. Under
|
|
* #967 "Registrera men bokför inte" (and under kontantmetoden) a registered
|
|
* invoice can sit in the reskontra with nothing on the balance sheet at all;
|
|
* revaluing those fabricated a write-down of an account standing at zero.
|
|
*
|
|
* NOT keyed on accounting_method: kontantmetoden companies must still book
|
|
* their outstanding fordringar/skulder at balansdagen (BFL 5 kap 2 § 3 st), and
|
|
* those year-end-converted rows are genuine FX exposure that has to be valued.
|
|
* The predicate is therefore per row ("is this one booked?"), never per company
|
|
* ("does this company use the cash method?"):
|
|
* - 'booked_only': only rows carrying a registration entry are on the balance
|
|
* sheet (kontantmetoden, or faktureringsmetoden with defer_invoice_booking)
|
|
* - 'all': inline booking at issue, the historical default. Every open row is
|
|
* booked, including legacy/SIE-imported rows that predate the entry links,
|
|
* so requiring a link there would silently drop real exposure.
|
|
*/
|
|
export type FxExposureScope = 'all' | 'booked_only'
|
|
|
|
export function fxExposureScope(
|
|
settings:
|
|
| { accounting_method?: string | null; defer_invoice_booking?: boolean | null }
|
|
| null
|
|
| undefined
|
|
): FxExposureScope {
|
|
// No settings row: historical default (accrual, book at issue).
|
|
if (!settings) return 'all'
|
|
if ((settings.accounting_method || 'accrual') !== 'accrual') return 'booked_only'
|
|
return settings.defer_invoice_booking ? 'booked_only' : 'all'
|
|
}
|
|
|
|
/**
|
|
* Never throws: `previewCurrencyRevaluation` is the read-only surface and the
|
|
* year-end preview must still render (same contract as the missing-rate path).
|
|
* A settings row that cannot be read falls back to the historical default,
|
|
* which is also what a missing row means.
|
|
*/
|
|
export async function fetchFxExposureScope(
|
|
supabase: SupabaseClient,
|
|
companyId: string
|
|
): Promise<FxExposureScope> {
|
|
const { data, error } = await supabase
|
|
.from('company_settings')
|
|
.select('accounting_method, defer_invoice_booking')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (error) return 'all'
|
|
return fxExposureScope(data)
|
|
}
|
|
|
|
/**
|
|
* Fetch open foreign-currency receivables (invoices).
|
|
* Returns invoices with status 'sent', 'overdue' or 'partially_paid' and
|
|
* non-SEK currency, INCLUDING ones with no `exchange_rate`: filtering those
|
|
* out in SQL hid the largest unmeasured FX exposure from the caller. The
|
|
* caller partitions them and reports the excluded rows.
|
|
*
|
|
* 'partially_paid' belongs in the list: payment-sync moves a customer invoice
|
|
* to that status on a partial settlement, and its unpaid remainder is still a
|
|
* monetary item that ÅRL 4 kap. 13 § values at balansdagen. Omitting it made
|
|
* partially paid foreign receivables entirely invisible to the revaluation
|
|
* (the payables side has always included it).
|
|
*
|
|
* When `asOfDate` is given the population is measured AS OF that date, not as
|
|
* of now. Two independent adjustments:
|
|
* - the date ceiling (`invoice_date <= asOfDate`) is UNCONDITIONAL: an invoice
|
|
* issued after the balансdagen was not on the balance sheet being valued,
|
|
* whether or not that date happens to be in the past. Post-dated invoices
|
|
* make this reachable for a current period too.
|
|
* - the status widening to 'paid' applies only to a historical date, where an
|
|
* invoice settled since (in March, say) was still open on 31 December. For
|
|
* today or the future the stored open state IS the as-of state.
|
|
* The caller recomputes each row's outstanding from the payment history (see
|
|
* previewCurrencyRevaluation). Same reconstruction contract as
|
|
* countOpenFxItemsAtBalansdagen in year-end-service and the reskontra reports.
|
|
*/
|
|
export async function getOpenForeignCurrencyReceivables(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
asOfDate?: string
|
|
): Promise<Invoice[]> {
|
|
const historical = asOfDate != null && asOfDate < todayIsoDate()
|
|
try {
|
|
// Paginated with a stable id order so a company with >1000 open FX invoices
|
|
// is fully revalued rather than silently truncated at 1000 rows.
|
|
return await fetchAllRows<Invoice>(({ from, to }) => {
|
|
const base = supabase
|
|
.from('invoices')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
// Proformas, delivery notes and quotes are never receivables: they
|
|
// sit on no 1510 balance, so there is nothing to revalue.
|
|
.eq('document_type', 'invoice')
|
|
.neq('currency', 'SEK')
|
|
.in(
|
|
'status',
|
|
historical
|
|
? ['sent', 'overdue', 'partially_paid', 'paid']
|
|
: ['sent', 'overdue', 'partially_paid']
|
|
)
|
|
const scoped = asOfDate != null ? base.lte('invoice_date', asOfDate) : base
|
|
return scoped.order('id', { ascending: true }).range(from, to)
|
|
}, { dedupeBy: (i) => i.id })
|
|
} catch (err) {
|
|
throw new BookkeepingDatabaseError(
|
|
'fetch_currency_receivables',
|
|
err instanceof Error ? err.message : 'fetch failed'
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch open foreign-currency payables (supplier invoices).
|
|
* Returns supplier invoices with open statuses and non-SEK currency,
|
|
* INCLUDING ones with no `exchange_rate` (see the receivables note above).
|
|
* Uses remaining_amount for partial payments.
|
|
*
|
|
* `asOfDate` behaves as on the receivables side: the date ceiling is
|
|
* unconditional, the widening to 'paid' applies only to a historical date.
|
|
*/
|
|
export async function getOpenForeignCurrencyPayables(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
asOfDate?: string
|
|
): Promise<SupplierInvoice[]> {
|
|
const historical = asOfDate != null && asOfDate < todayIsoDate()
|
|
try {
|
|
// Paginated with a stable id order so a company with >1000 open FX payables
|
|
// is fully revalued rather than silently truncated at 1000 rows.
|
|
return await fetchAllRows<SupplierInvoice>(({ from, to }) => {
|
|
const base = supabase
|
|
.from('supplier_invoices')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.neq('currency', 'SEK')
|
|
.in(
|
|
'status',
|
|
historical
|
|
? ['registered', 'approved', 'overdue', 'partially_paid', 'paid']
|
|
: ['registered', 'approved', 'overdue', 'partially_paid']
|
|
)
|
|
const scoped = asOfDate != null ? base.lte('invoice_date', asOfDate) : base
|
|
return scoped.order('id', { ascending: true }).range(from, to)
|
|
}, { dedupeBy: (i) => i.id })
|
|
} catch (err) {
|
|
throw new BookkeepingDatabaseError(
|
|
'fetch_currency_payables',
|
|
err instanceof Error ? err.message : 'fetch failed'
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preview currency revaluation without persisting.
|
|
* Computes per-item differences and aggregated journal lines.
|
|
*
|
|
* Receivables (1510):
|
|
* closing > original → gain: Debit 1510, Credit 3960
|
|
* closing < original → loss: Credit 1510, Debit 7960
|
|
*
|
|
* Payables (2440):
|
|
* closing > original → loss (liability grew): Debit 7960, Credit 2440
|
|
* closing < original → gain (liability shrank): Debit 2440, Credit 3960
|
|
*
|
|
* Never throws on a missing rate: this is the read-only surface, and the
|
|
* year-end preview must still render. Both exclusion channels come back on
|
|
* the result (`unconvertedFx*`, `missingClosingRates`) so the caller can show
|
|
* them. `executeCurrencyRevaluation` is what refuses to post.
|
|
*/
|
|
export async function previewCurrencyRevaluation(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
closingDate: string
|
|
): Promise<CurrencyRevaluationPreviewWithExclusions> {
|
|
const emptyPreview: CurrencyRevaluationPreviewWithExclusions = {
|
|
items: [],
|
|
lines: [],
|
|
closingRates: {},
|
|
totalGain: 0,
|
|
totalLoss: 0,
|
|
netEffect: 0,
|
|
unconvertedFx: [],
|
|
unconvertedFxCount: 0,
|
|
missingClosingRates: [],
|
|
}
|
|
|
|
const scope = await fetchFxExposureScope(supabase, companyId)
|
|
|
|
const [receivables, payables] = await Promise.all([
|
|
getOpenForeignCurrencyReceivables(supabase, companyId, closingDate),
|
|
getOpenForeignCurrencyPayables(supabase, companyId, closingDate),
|
|
])
|
|
|
|
// For a historical balansdagen the live status/paid_amount columns describe
|
|
// today, not the balance sheet being valued: reconstruct each row's
|
|
// outstanding from the payment history, the same walk-back the reskontra
|
|
// reports and countOpenFxItemsAtBalansdagen use.
|
|
const isHistorical = closingDate < todayIsoDate()
|
|
const [receivablePayments, payablePayments]: [PaymentsAsOf | null, PaymentsAsOf | null] =
|
|
await Promise.all([
|
|
isHistorical && receivables.length > 0
|
|
? fetchPaymentsAsOf(supabase, 'invoice_payments', 'invoice_id', companyId, closingDate)
|
|
: null,
|
|
isHistorical && payables.length > 0
|
|
? fetchPaymentsAsOf(
|
|
supabase,
|
|
'supplier_invoice_payments',
|
|
'supplier_invoice_id',
|
|
companyId,
|
|
closingDate
|
|
)
|
|
: null,
|
|
])
|
|
|
|
// Partition into rows we can revalue and rows with no original rate, and
|
|
// collect the distinct currencies of the revaluable rows.
|
|
const currencies = new Set<Currency>()
|
|
const unconvertedFx: UnconvertedFxItem[] = []
|
|
const revaluableReceivables: Array<{ inv: Invoice; outstanding: number }> = []
|
|
const revaluablePayables: Array<{ si: SupplierInvoice; outstanding: number }> = []
|
|
|
|
for (const inv of receivables) {
|
|
// Deferred booking: an unbooked registration is not on 1510, so it is not
|
|
// a monetary item to revalue (nor unmeasured exposure to report).
|
|
if (scope === 'booked_only' && !inv.journal_entry_id) continue
|
|
// Only the OUTSTANDING amount is a monetary item at balansdagen: the paid
|
|
// part has already been settled at its own realized rate. Kundreskontran
|
|
// derives outstanding from total - paid_amount (see year-end-service),
|
|
// so the same definition is used here, öre-rounded.
|
|
const total = Number(inv.total) || 0
|
|
const live = Math.round((total - (Number(inv.paid_amount) || 0)) * 100) / 100
|
|
const outstanding = receivablePayments
|
|
? outstandingAsOf(inv, total, live, receivablePayments, closingDate)
|
|
: live
|
|
// Nothing outstanding on balansdagen: nothing to revalue, no exposure.
|
|
if (outstanding <= 0) continue
|
|
if (!hasUsableRate(inv.exchange_rate)) {
|
|
unconvertedFx.push({
|
|
type: 'receivable',
|
|
source_id: inv.id,
|
|
reference: inv.invoice_number ?? '',
|
|
currency: inv.currency,
|
|
amount_in_currency: outstanding,
|
|
})
|
|
continue
|
|
}
|
|
currencies.add(inv.currency)
|
|
revaluableReceivables.push({ inv, outstanding })
|
|
}
|
|
|
|
for (const si of payables) {
|
|
// See the receivables note: unbooked registrations carry no 2440 balance.
|
|
if (scope === 'booked_only' && !si.registration_journal_entry_id) continue
|
|
const total = Number(si.total) || 0
|
|
const live = Number(si.remaining_amount) || 0
|
|
const outstanding = payablePayments
|
|
? outstandingAsOf(si, total, live, payablePayments, closingDate)
|
|
: live
|
|
// Nothing outstanding on balansdagen: nothing to revalue, no exposure.
|
|
if (outstanding <= 0) continue
|
|
if (!hasUsableRate(si.exchange_rate)) {
|
|
unconvertedFx.push({
|
|
type: 'payable',
|
|
source_id: si.id,
|
|
reference: si.supplier_invoice_number,
|
|
currency: si.currency as Currency,
|
|
amount_in_currency: outstanding,
|
|
})
|
|
continue
|
|
}
|
|
currencies.add(si.currency as Currency)
|
|
revaluablePayables.push({ si, outstanding })
|
|
}
|
|
|
|
if (currencies.size === 0) {
|
|
return { ...emptyPreview, unconvertedFx, unconvertedFxCount: unconvertedFx.length }
|
|
}
|
|
|
|
// Fetch closing rates one currency at a time through fetchExchangeRate: it
|
|
// is the documented booking path and returns null rather than one of the
|
|
// hardcoded display-only constants in getFallbackRate(). fetchMultipleRates
|
|
// pads its Map with those constants to keep a fully-populated-Map contract,
|
|
// which is fine for a rate widget and unacceptable for a verifikat: this
|
|
// function's output is posted to 3960/7960. Passing `supabase` uses the
|
|
// shared exchange_rates cache, so a balansdagen rate stays reproducible.
|
|
const currencyList = Array.from(currencies)
|
|
const fetched = await Promise.all(
|
|
currencyList.map((currency) => fetchExchangeRate(currency, new Date(closingDate), supabase))
|
|
)
|
|
|
|
const rateMap = new Map<Currency, number>()
|
|
const missingClosingRates: MissingClosingRate[] = []
|
|
for (let i = 0; i < currencyList.length; i++) {
|
|
const observed = fetched[i]
|
|
if (observed && Number.isFinite(observed.rate) && observed.rate > 0) {
|
|
rateMap.set(currencyList[i], observed.rate)
|
|
} else {
|
|
missingClosingRates.push({ currency: currencyList[i], date: closingDate })
|
|
}
|
|
}
|
|
|
|
const closingRates: Record<string, number> = {}
|
|
for (const [currency, rate] of rateMap) {
|
|
closingRates[currency] = rate
|
|
}
|
|
|
|
const items: RevaluationItem[] = []
|
|
|
|
// Process receivables (use the outstanding amount for partial payments,
|
|
// mirroring the payables' remaining_amount below)
|
|
for (const { inv, outstanding } of revaluableReceivables) {
|
|
const closingRate = rateMap.get(inv.currency)
|
|
if (!closingRate || !inv.exchange_rate) continue
|
|
|
|
const amountInCurrency = outstanding
|
|
const originalSek = Math.round(amountInCurrency * inv.exchange_rate * 100) / 100
|
|
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
|
|
const difference = Math.round((closingSek - originalSek) * 100) / 100
|
|
|
|
if (Math.abs(difference) < 0.01) continue
|
|
|
|
items.push({
|
|
type: 'receivable',
|
|
source_id: inv.id,
|
|
reference: inv.invoice_number ?? '',
|
|
currency: inv.currency,
|
|
amount_in_currency: amountInCurrency,
|
|
original_rate: inv.exchange_rate,
|
|
closing_rate: closingRate,
|
|
original_sek: originalSek,
|
|
closing_sek: closingSek,
|
|
difference_sek: difference,
|
|
})
|
|
}
|
|
|
|
// Process payables (outstanding as of balansdagen, mirroring receivables)
|
|
for (const { si, outstanding } of revaluablePayables) {
|
|
const closingRate = rateMap.get(si.currency as Currency)
|
|
if (!closingRate || !si.exchange_rate) continue
|
|
|
|
const amountInCurrency = outstanding
|
|
|
|
const originalSek = Math.round(amountInCurrency * si.exchange_rate * 100) / 100
|
|
const closingSek = Math.round(amountInCurrency * closingRate * 100) / 100
|
|
const difference = Math.round((closingSek - originalSek) * 100) / 100
|
|
|
|
if (Math.abs(difference) < 0.01) continue
|
|
|
|
items.push({
|
|
type: 'payable',
|
|
source_id: si.id,
|
|
reference: si.supplier_invoice_number,
|
|
currency: si.currency as Currency,
|
|
amount_in_currency: amountInCurrency,
|
|
original_rate: si.exchange_rate,
|
|
closing_rate: closingRate,
|
|
original_sek: originalSek,
|
|
closing_sek: closingSek,
|
|
difference_sek: difference,
|
|
})
|
|
}
|
|
|
|
// Build aggregated journal lines
|
|
let debit1510 = 0 // Receivable gain (revalue up)
|
|
let credit1510 = 0 // Receivable loss (revalue down)
|
|
let debit2440 = 0 // Payable gain (liability shrank)
|
|
let credit2440 = 0 // Payable loss (liability grew)
|
|
let credit3960 = 0 // Gains
|
|
let debit7960 = 0 // Losses
|
|
|
|
for (const item of items) {
|
|
if (item.type === 'receivable') {
|
|
if (item.difference_sek > 0) {
|
|
// Closing > original → gain: Debit 1510, Credit 3960
|
|
debit1510 += item.difference_sek
|
|
credit3960 += item.difference_sek
|
|
} else {
|
|
// Closing < original → loss: Credit 1510, Debit 7960
|
|
credit1510 += Math.abs(item.difference_sek)
|
|
debit7960 += Math.abs(item.difference_sek)
|
|
}
|
|
} else {
|
|
// Payable
|
|
if (item.difference_sek > 0) {
|
|
// Closing > original → loss (liability grew): Debit 7960, Credit 2440
|
|
debit7960 += item.difference_sek
|
|
credit2440 += item.difference_sek
|
|
} else {
|
|
// Closing < original → gain (liability shrank): Debit 2440, Credit 3960
|
|
debit2440 += Math.abs(item.difference_sek)
|
|
credit3960 += Math.abs(item.difference_sek)
|
|
}
|
|
}
|
|
}
|
|
|
|
const lines: CreateJournalEntryLineInput[] = []
|
|
|
|
if (debit1510 > 0) {
|
|
lines.push({
|
|
account_number: '1510',
|
|
debit_amount: Math.round(debit1510 * 100) / 100,
|
|
credit_amount: 0,
|
|
line_description: 'Omvärdering kundfordringar: orealiserad kursvinst',
|
|
})
|
|
}
|
|
if (credit1510 > 0) {
|
|
lines.push({
|
|
account_number: '1510',
|
|
debit_amount: 0,
|
|
credit_amount: Math.round(credit1510 * 100) / 100,
|
|
line_description: 'Omvärdering kundfordringar: orealiserad kursförlust',
|
|
})
|
|
}
|
|
if (debit2440 > 0) {
|
|
lines.push({
|
|
account_number: '2440',
|
|
debit_amount: Math.round(debit2440 * 100) / 100,
|
|
credit_amount: 0,
|
|
line_description: 'Omvärdering leverantörsskulder: orealiserad kursvinst',
|
|
})
|
|
}
|
|
if (credit2440 > 0) {
|
|
lines.push({
|
|
account_number: '2440',
|
|
debit_amount: 0,
|
|
credit_amount: Math.round(credit2440 * 100) / 100,
|
|
line_description: 'Omvärdering leverantörsskulder: orealiserad kursförlust',
|
|
})
|
|
}
|
|
if (credit3960 > 0) {
|
|
lines.push({
|
|
account_number: '3960',
|
|
debit_amount: 0,
|
|
credit_amount: Math.round(credit3960 * 100) / 100,
|
|
line_description: 'Orealiserade valutakursvinster',
|
|
})
|
|
}
|
|
if (debit7960 > 0) {
|
|
lines.push({
|
|
account_number: '7960',
|
|
debit_amount: Math.round(debit7960 * 100) / 100,
|
|
credit_amount: 0,
|
|
line_description: 'Orealiserade valutakursförluster',
|
|
})
|
|
}
|
|
|
|
const totalGain = Math.round(credit3960 * 100) / 100
|
|
const totalLoss = Math.round(debit7960 * 100) / 100
|
|
const netEffect = Math.round((totalGain - totalLoss) * 100) / 100
|
|
|
|
return {
|
|
items,
|
|
lines,
|
|
closingRates,
|
|
totalGain,
|
|
totalLoss,
|
|
netEffect,
|
|
unconvertedFx,
|
|
unconvertedFxCount: unconvertedFx.length,
|
|
missingClosingRates,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute currency revaluation for a fiscal period.
|
|
* Creates a journal entry with source_type 'currency_revaluation'.
|
|
*
|
|
* Returns null if no foreign-currency items exist.
|
|
* Throws if a revaluation entry already exists for this period (idempotency).
|
|
* Throws ClosingRateUnavailableError if any required balansdagen rate is
|
|
* missing: this entry must never be computed from an estimated rate.
|
|
*/
|
|
export async function executeCurrencyRevaluation(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
closingDate: string,
|
|
fiscalPeriodId: string,
|
|
userId?: string
|
|
): Promise<CurrencyRevaluationResultWithExclusions | null> {
|
|
// Idempotency check: prevent double revaluation
|
|
const { count, error: checkError } = await supabase
|
|
.from('journal_entries')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('fiscal_period_id', fiscalPeriodId)
|
|
.eq('source_type', 'currency_revaluation')
|
|
.eq('status', 'posted')
|
|
|
|
if (checkError) {
|
|
throw new BookkeepingDatabaseError('check_existing_revaluation', checkError.message)
|
|
}
|
|
|
|
if ((count ?? 0) > 0) {
|
|
throw new CurrencyRevaluationAlreadyExistsError()
|
|
}
|
|
|
|
const preview = await previewCurrencyRevaluation(supabase, companyId, closingDate)
|
|
|
|
// Refuse before anything is posted. Checked ahead of the empty-preview
|
|
// shortcut on purpose: when every currency lacks a closing rate the item
|
|
// list is also empty, and returning null there would report "nothing to
|
|
// revalue" for what is really "we could not value it". A partial post is
|
|
// refused too: it would understate the FX result on 3960/7960 while looking
|
|
// like a complete balansdagen valuation (ÅRL 4 kap. 13 §).
|
|
if (preview.missingClosingRates.length > 0) {
|
|
throw new ClosingRateUnavailableError(preview.missingClosingRates)
|
|
}
|
|
|
|
if (preview.items.length === 0 || preview.lines.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const entry = await createJournalEntry(supabase, companyId, userId ?? companyId, {
|
|
fiscal_period_id: fiscalPeriodId,
|
|
entry_date: closingDate,
|
|
description: `Omvärdering utländsk valuta ${closingDate}`,
|
|
source_type: 'currency_revaluation',
|
|
voucher_series: 'A',
|
|
lines: preview.lines,
|
|
})
|
|
|
|
return { entry, preview }
|
|
}
|