fix(transactions): steer duplicate guard to matching for ledger-only vouchers (#958)
When the booking-time duplicate guard flags a ledger-only voucher candidate (transaction_id null: a verifikat from an SIE import, a paid invoice, a salary payout), the dialog's primary action is now "Matcha mot verifikatet": it links the bank transaction to the existing voucher via /api/reconciliation/bank/link (the same path MatchVoucherDialog uses) instead of double-booking the same affarshandelse. "Bokfor anda" stays available but demoted to an outline button. Sibling-transaction candidates keep today's layout: matching a second bank line onto a voucher that already has one is the N:1 edge case, not the default. Wired through both call sites: the transactions page (runCategorize) reuses handleVoucherLinked's refresh, and TransactionBookingDialog (JournalEntryForm, /api/transactions/[id]/book path) reuses the booked flow so in-dialog attached documents land on the matched verifikat and the row leaves the list, with a "Bankhandelsen kopplad" toast instead of "Bokford". No lib change: the candidate payload already carries the transaction_id discriminator, covered by existing detection tests. Closes #919. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d7e110b6b2
commit
82c66739d7
@@ -51,3 +51,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-08] Pinned @anthropic-ai/bedrock-sdk to exact 0.29.1 (dependabot #884 auto-bumped it to 0.32.0, which broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks"). Guarded three ways against accidental re-bump: exact pin in package.json, dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock.
|
||||
[2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible.
|
||||
[2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against.
|
||||
[2026-07-09] Issue #919 (duplicate guard should steer to matching): the match action lives INSIDE DuplicateBookingDialog (fetch to /api/reconciliation/bank/link + account resolution via /api/cash-accounts + resolveAccount, exactly the MatchVoucherDialog path) rather than in each call site or a new endpoint: both call sites (transactions page runCategorize + TransactionBookingDialog/JournalEntryForm) share one implementation and pass only the transaction context + an onMatched callback mirroring onLinked. Match is primary ONLY for ledger-only candidates (transaction_id null, the SIE-import case); sibling-transaction candidates keep "Bokför ändå" primary since N:1 matching is the edge case. No lib change: the candidate already carries the transaction_id discriminator, covered by existing tests.
|
||||
|
||||
@@ -45,6 +45,7 @@ import TransactionAttachDocumentDialog from '@/components/transactions/Transacti
|
||||
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
|
||||
import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog'
|
||||
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
|
||||
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
|
||||
import TemplatePicker from '@/components/transactions/TemplatePicker'
|
||||
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
@@ -244,14 +245,7 @@ export default function TransactionsPage() {
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<{
|
||||
transactionId: string
|
||||
retry: () => Promise<string | null>
|
||||
candidate: {
|
||||
transaction_id: string | null
|
||||
journal_entry_id: string
|
||||
voucher_label: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
amount: number
|
||||
}
|
||||
candidate: BookedDuplicateCandidate
|
||||
} | null>(null)
|
||||
const [duplicateProcessing, setDuplicateProcessing] = useState(false)
|
||||
|
||||
@@ -889,14 +883,7 @@ export default function TransactionsPage() {
|
||||
// merely says "book anyway" with no way to do so: open a dialog with
|
||||
// the already-booked sibling and let the user confirm. "Bokför ändå"
|
||||
// re-runs with force bound to this candidate (server re-detects it).
|
||||
const candidate = result.error.details.candidate as {
|
||||
transaction_id: string | null
|
||||
journal_entry_id: string
|
||||
voucher_label: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
amount: number
|
||||
}
|
||||
const candidate = result.error.details.candidate as BookedDuplicateCandidate
|
||||
setDuplicateWarning({
|
||||
transactionId: id,
|
||||
retry: () =>
|
||||
@@ -1657,6 +1644,9 @@ export default function TransactionsPage() {
|
||||
transactionId: string,
|
||||
journalEntryId: string,
|
||||
attachedDocumentId?: string | null,
|
||||
// True when the duplicate guard's match action LINKED the transaction to
|
||||
// an existing voucher instead of creating a new one.
|
||||
matched?: boolean,
|
||||
) {
|
||||
setExitingIds((prev) => new Set(prev).add(transactionId))
|
||||
setTimeout(() => {
|
||||
@@ -1683,7 +1673,11 @@ export default function TransactionsPage() {
|
||||
setBookingDialogOpen(false)
|
||||
setBookingDialogTransaction(null)
|
||||
setBookingDialogTemplate(null)
|
||||
toast({ title: 'Bokförd' })
|
||||
if (matched) {
|
||||
toast({ title: 'Bankhändelsen kopplad', description: 'Ingen ny bokföring skapad.' })
|
||||
} else {
|
||||
toast({ title: 'Bokförd' })
|
||||
}
|
||||
}
|
||||
|
||||
function openAttachDocumentDialog(transaction: TransactionWithInvoice) {
|
||||
@@ -2675,6 +2669,21 @@ export default function TransactionsPage() {
|
||||
candidate={duplicateWarning?.candidate ?? null}
|
||||
processing={duplicateProcessing}
|
||||
onCancel={() => setDuplicateWarning(null)}
|
||||
// Ledger-only candidate (transaction_id null, e.g. a verifikat from an
|
||||
// SIE import): the primary action links the bank line to the existing
|
||||
// voucher instead of double-booking it. Success refreshes the same
|
||||
// state a MatchVoucherDialog link does.
|
||||
matchTransaction={
|
||||
duplicateWarning
|
||||
? transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? {
|
||||
id: duplicateWarning.transactionId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMatched={(transactionId, journalEntryId, voucherLabel) => {
|
||||
setDuplicateWarning(null)
|
||||
handleVoucherLinked(transactionId, journalEntryId, voucherLabel)
|
||||
}}
|
||||
onBookAnyway={async () => {
|
||||
const retry = duplicateWarning?.retry
|
||||
setDuplicateProcessing(true)
|
||||
|
||||
@@ -26,7 +26,7 @@ import { TemplateForm } from '@/components/settings/TemplateForm'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
|
||||
import DuplicateBookingDialog, { type DuplicateMatchTransaction } from '@/components/transactions/DuplicateBookingDialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
useSubmitWithAccountActivation,
|
||||
@@ -82,6 +82,13 @@ interface Props {
|
||||
editEntryId?: string
|
||||
/** Fired after a successful draft edit (editEntryId path). */
|
||||
onUpdated?: () => void
|
||||
/** The bank transaction being booked (set by TransactionBookingDialog).
|
||||
* Enables the duplicate guard's "Matcha mot verifikatet" action for
|
||||
* ledger-only voucher candidates. */
|
||||
duplicateMatchTransaction?: DuplicateMatchTransaction
|
||||
/** Fired after the duplicate guard's match action links the transaction to
|
||||
* the existing voucher (no new entry was created). */
|
||||
onDuplicateMatched?: (journalEntryId: string) => void
|
||||
}
|
||||
|
||||
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }
|
||||
@@ -101,6 +108,8 @@ export default function JournalEntryForm({
|
||||
bare,
|
||||
editEntryId,
|
||||
onUpdated,
|
||||
duplicateMatchTransaction,
|
||||
onDuplicateMatched,
|
||||
}: Props) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
@@ -1939,6 +1948,15 @@ export default function JournalEntryForm({
|
||||
processing={isSubmitting}
|
||||
onCancel={() => setDuplicateCandidate(null)}
|
||||
onBookAnyway={handleBookAnyway}
|
||||
matchTransaction={duplicateMatchTransaction ?? null}
|
||||
onMatched={
|
||||
onDuplicateMatched
|
||||
? (_transactionId, journalEntryId) => {
|
||||
setDuplicateCandidate(null)
|
||||
onDuplicateMatched(journalEntryId)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
import type { CashAccount } from '@/types'
|
||||
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
|
||||
/** The bank transaction being booked, as much as the caller knows about it.
|
||||
* Enables the "Matcha mot verifikatet" action for ledger-only candidates. */
|
||||
export interface DuplicateMatchTransaction {
|
||||
id: string
|
||||
cash_account_id?: string | null
|
||||
currency?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft warning shown when the booking-time duplicate guard fires
|
||||
* (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): another already-booked transaction
|
||||
@@ -20,20 +34,110 @@ import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplic
|
||||
* is present on both candidate kinds (a sibling-transaction candidate and a
|
||||
* ledger-only voucher candidate, which has no transaction_id), and the server
|
||||
* re-detects it so a stale id can't wave the guard away.
|
||||
*
|
||||
* Ledger-only candidates (candidate.transaction_id === null: the voucher exists
|
||||
* but no bank transaction is linked to it, e.g. a verifikat from an SIE import
|
||||
* or an invoice marked paid) get "Matcha mot verifikatet" as the PRIMARY action
|
||||
* when the caller supplies `matchTransaction` + `onMatched`: it links the bank
|
||||
* line to the existing voucher via /api/reconciliation/bank/link (the same path
|
||||
* MatchVoucherDialog uses) instead of double-booking the affärshändelse.
|
||||
* "Bokför ändå" stays available but demoted. Sibling-transaction candidates
|
||||
* keep booking as the primary action: matching a second bank line onto a
|
||||
* voucher that already has one is the N:1 edge case, not the default.
|
||||
*/
|
||||
export default function DuplicateBookingDialog({
|
||||
candidate,
|
||||
processing = false,
|
||||
onBookAnyway,
|
||||
onCancel,
|
||||
matchTransaction,
|
||||
onMatched,
|
||||
}: {
|
||||
/** The already-booked sibling, or null to keep the dialog closed. */
|
||||
candidate: BookedDuplicateCandidate | null
|
||||
processing?: boolean
|
||||
onBookAnyway: () => void
|
||||
onCancel: () => void
|
||||
/** The transaction being booked; required for the match action. */
|
||||
matchTransaction?: DuplicateMatchTransaction | null
|
||||
/** Called after /api/reconciliation/bank/link succeeds. Mirrors
|
||||
* MatchVoucherDialog's onLinked signature: everything the refresh needs is
|
||||
* passed in, so a mid-request dialog close can't strand the update. The
|
||||
* caller owns the success toast and state refresh (and closes the dialog by
|
||||
* clearing `candidate`). */
|
||||
onMatched?: (transactionId: string, journalEntryId: string, voucherLabel: string) => void
|
||||
}) {
|
||||
const t = useTranslations('transactions')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const [matching, setMatching] = useState(false)
|
||||
|
||||
// The match action is offered only for ledger-only voucher candidates: the
|
||||
// voucher has no bank transaction linked yet, so linking THIS one to it is
|
||||
// the right default (one affärshändelse, one verifikat).
|
||||
const canMatch =
|
||||
candidate !== null && candidate.transaction_id === null && !!matchTransaction && !!onMatched
|
||||
|
||||
async function handleMatch() {
|
||||
if (!candidate || !matchTransaction || !onMatched || matching) return
|
||||
setMatching(true)
|
||||
try {
|
||||
// Link on the exact 19xx account the candidate voucher was booked to
|
||||
// when the guard reported it: a legacy transaction without a
|
||||
// cash_account_id would otherwise resolve by currency and can pick a
|
||||
// different 19xx than the voucher's leg, dead-ending the link. Fall back
|
||||
// to resolving from the company's cash accounts, same as
|
||||
// MatchVoucherDialog: the link route validates the voucher has a leg on
|
||||
// this account and that the transaction belongs to it.
|
||||
let account = candidate.account_number ?? '1930'
|
||||
if (!candidate.account_number) {
|
||||
try {
|
||||
const caRes = await fetch('/api/cash-accounts')
|
||||
if (caRes.ok) {
|
||||
const caJson = await caRes.json()
|
||||
const accounts = (caJson.data ?? []) as CashAccount[]
|
||||
account = resolveAccount(
|
||||
accounts,
|
||||
matchTransaction.cash_account_id ?? null,
|
||||
matchTransaction.currency ?? 'SEK',
|
||||
).account
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup: fall back to 1930; the link route re-validates.
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch('/api/reconciliation/bank/link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transaction_id: matchTransaction.id,
|
||||
journal_entry_id: candidate.journal_entry_id,
|
||||
account_number: account,
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok || result.error) {
|
||||
toast({
|
||||
title: t('dialog_duplicate_match_failed'),
|
||||
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status, locale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
onMatched(matchTransaction.id, candidate.journal_entry_id, candidate.voucher_label)
|
||||
} catch {
|
||||
toast({
|
||||
title: t('dialog_duplicate_match_failed'),
|
||||
description: getErrorMessage(null, { context: 'transaction', locale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const busy = processing || matching
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -79,12 +183,24 @@ export default function DuplicateBookingDialog({
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={onCancel} disabled={processing}>
|
||||
<Button variant="outline" onClick={onCancel} disabled={busy}>
|
||||
{t('dialog_duplicate_cancel')}
|
||||
</Button>
|
||||
<Button onClick={onBookAnyway} disabled={processing}>
|
||||
{t('dialog_duplicate_book_anyway')}
|
||||
</Button>
|
||||
{canMatch ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onBookAnyway} disabled={busy}>
|
||||
{t('dialog_duplicate_book_anyway')}
|
||||
</Button>
|
||||
<Button onClick={handleMatch} disabled={busy}>
|
||||
{matching && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('dialog_duplicate_match')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onBookAnyway} disabled={busy}>
|
||||
{t('dialog_duplicate_book_anyway')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,9 @@ interface TransactionBookingDialogProps {
|
||||
transactionId: string,
|
||||
journalEntryId: string,
|
||||
attachedDocumentId?: string | null,
|
||||
/** True when the transaction was LINKED to an existing voucher via the
|
||||
* duplicate guard's match action: no new verifikat was created. */
|
||||
matched?: boolean,
|
||||
) => void
|
||||
preselectedTemplate?: BookingTemplateLibrary | null
|
||||
}
|
||||
@@ -154,7 +157,7 @@ export default function TransactionBookingDialog({
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
|
||||
const handleBooked = async (transactionId: string, journalEntryId: string) => {
|
||||
const handleBooked = async (transactionId: string, journalEntryId: string, matched = false) => {
|
||||
// Link any attached documents to the new journal entry: freshly uploaded
|
||||
// files, and existing inbox documents picked via InboxDocumentPicker. For
|
||||
// picked docs, inbox_item_id stamps the inbox item as consumed so it drops
|
||||
@@ -221,7 +224,7 @@ export default function TransactionBookingDialog({
|
||||
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
onBooked(transactionId, journalEntryId, pinnedDocId)
|
||||
onBooked(transactionId, journalEntryId, pinnedDocId, matched)
|
||||
}
|
||||
|
||||
// The receipt to show beside the form. A transaction may arrive with a
|
||||
@@ -377,6 +380,15 @@ export default function TransactionBookingDialog({
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
duplicateMatchTransaction={{
|
||||
id: transaction.id,
|
||||
cash_account_id: transaction.cash_account_id ?? null,
|
||||
currency: transaction.currency ?? 'SEK',
|
||||
}}
|
||||
// Duplicate guard match: the bank line was linked to an existing
|
||||
// voucher (no new entry). Reuse the booked flow so attached
|
||||
// documents land on that verifikat and the row leaves the list.
|
||||
onDuplicateMatched={(journalEntryId) => handleBooked(transaction.id, journalEntryId, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,7 @@ describe('detectBookedDuplicateTransaction', () => {
|
||||
entry_date: '2025-12-19',
|
||||
description: 'TELENOR SVERIGE AB',
|
||||
amount: -1616,
|
||||
account_number: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,6 +252,9 @@ describe('detectLedgerDuplicateVoucher', () => {
|
||||
entry_date: '2026-03-30',
|
||||
description: 'Inbetalning kundfaktura 2026001',
|
||||
amount: 98565,
|
||||
// The matched leg's account rides along so the dialog's match action
|
||||
// links on the exact 19xx the voucher was booked to (issue #919).
|
||||
account_number: '1930',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,6 +274,7 @@ describe('detectLedgerDuplicateVoucher', () => {
|
||||
expect(result?.journal_entry_id).toBe('je-3')
|
||||
expect(result?.transaction_id).toBeNull()
|
||||
expect(result?.amount).toBe(16609)
|
||||
expect(result?.account_number).toBe('1930')
|
||||
})
|
||||
|
||||
it('does NOT flag an inbound receipt against a credit-only voucher (wrong direction)', async () => {
|
||||
|
||||
@@ -46,6 +46,14 @@ export interface BookedDuplicateCandidate {
|
||||
entry_date: string
|
||||
description: string | null
|
||||
amount: number
|
||||
/**
|
||||
* The 19xx settlement account of the voucher leg that matched, set for
|
||||
* ledger-only candidates so the match action can link on the exact account
|
||||
* the voucher was booked to (a legacy transaction without cash_account_id
|
||||
* would otherwise resolve by currency and can pick the wrong 19xx). Null for
|
||||
* sibling-transaction candidates, whose legs are not fetched.
|
||||
*/
|
||||
account_number: string | null
|
||||
}
|
||||
|
||||
/** Minimal shape of the transaction about to be booked. */
|
||||
@@ -162,6 +170,7 @@ export async function detectBookedDuplicateTransaction(
|
||||
entry_date: entryDate,
|
||||
description: best.description,
|
||||
amount: roundOre(Number(best.amount)),
|
||||
account_number: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +336,7 @@ export async function detectLedgerDuplicateVoucher(
|
||||
entry_date: best.journal_entry.entry_date,
|
||||
description: best.journal_entry.description,
|
||||
amount: roundOre(Number(inbound ? best.debit_amount : best.credit_amount)),
|
||||
account_number: best.account_number,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3872,6 +3872,8 @@
|
||||
"dialog_duplicate_voucher_label": "Voucher {label}",
|
||||
"dialog_duplicate_voucher_generic": "Existing voucher",
|
||||
"dialog_duplicate_view_voucher": "View the voucher",
|
||||
"dialog_duplicate_match": "Match to the voucher",
|
||||
"dialog_duplicate_match_failed": "Could not match",
|
||||
"dialog_duplicate_book_anyway": "Book anyway",
|
||||
"dialog_duplicate_cancel": "Cancel",
|
||||
"load_failed_title": "Could not load transactions",
|
||||
|
||||
@@ -3872,6 +3872,8 @@
|
||||
"dialog_duplicate_voucher_label": "Verifikat {label}",
|
||||
"dialog_duplicate_voucher_generic": "Befintlig verifikation",
|
||||
"dialog_duplicate_view_voucher": "Visa verifikatet",
|
||||
"dialog_duplicate_match": "Matcha mot verifikatet",
|
||||
"dialog_duplicate_match_failed": "Kunde inte matcha",
|
||||
"dialog_duplicate_book_anyway": "Bokför ändå",
|
||||
"dialog_duplicate_cancel": "Avbryt",
|
||||
"load_failed_title": "Kunde inte ladda transaktioner",
|
||||
|
||||
Reference in New Issue
Block a user