fix(accounting): 'Save & mark paid' actually pays; incoming invoices appear in tax/export
#1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize + markInboundPaid with the entered reference) instead of opening the pay dialog and leaving it unpaid. Removed the PayModal chain. #2 Cost side missed captured incoming invoices: the query required currency='CHF', but email/upload invoices often have a null currency → silently excluded. Now include null-currency rows (treated as the report currency). Also replaced COALESCE(invoice_date, created_at) with a split date filter (invoice_date BETWEEN, else created_at range) to avoid the mixed date/timestamp comparison risk on Postgres. Same fix in the ledger export (buildPostings). en/de: categorizedPaidToast.
This commit is contained in:
@@ -297,10 +297,15 @@ async function buildPostings({ from, to, currency } = {}) {
|
||||
.modify((q) => {
|
||||
if (hasCatCol) q.leftJoin('expense_categories', 'inbound_documents.category_id', 'expense_categories.id');
|
||||
})
|
||||
.whereRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) >= ? AND COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) <= ?', [from, toEnd])
|
||||
.where('inbound_documents.currency', cur)
|
||||
.where((qb) => {
|
||||
qb.whereBetween('inbound_documents.invoice_date', [from, to])
|
||||
.orWhere((q2) => q2.whereNull('inbound_documents.invoice_date')
|
||||
.andWhere('inbound_documents.created_at', '>=', from)
|
||||
.andWhere('inbound_documents.created_at', '<=', toEnd));
|
||||
})
|
||||
.where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); })
|
||||
.whereNotIn('inbound_documents.status', ['declined', 'duplicate'])
|
||||
.orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc')
|
||||
.orderBy('inbound_documents.created_at', 'asc')
|
||||
.select(
|
||||
'inbound_documents.id', 'inbound_documents.invoice_number', 'inbound_documents.invoice_date',
|
||||
'inbound_documents.created_at', 'inbound_documents.supplier_name', 'inbound_documents.tax_treatment',
|
||||
|
||||
@@ -213,10 +213,21 @@ async function loadCosts({ from, to, cur }) {
|
||||
if (await db.schema.hasTable('inbound_documents')) {
|
||||
const inbound = await db('inbound_documents')
|
||||
.leftJoin('events', 'inbound_documents.event_id', 'events.id')
|
||||
.whereRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) >= ? AND COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) <= ?', [from, toEnd])
|
||||
.where('inbound_documents.currency', cur)
|
||||
// Date in range: invoice_date (a DATE) when set, else created_at (a
|
||||
// TIMESTAMP). Split instead of COALESCE so we never compare mixed
|
||||
// date/timestamp types (a Postgres error the mocked tests can't see).
|
||||
.where((qb) => {
|
||||
qb.whereBetween('inbound_documents.invoice_date', [from, to])
|
||||
.orWhere((q2) => q2.whereNull('inbound_documents.invoice_date')
|
||||
.andWhere('inbound_documents.created_at', '>=', from)
|
||||
.andWhere('inbound_documents.created_at', '<=', toEnd));
|
||||
})
|
||||
// Currency match, but INCLUDE rows with no currency set — captured
|
||||
// invoices (email/upload) often have a null currency; treat them as the
|
||||
// report currency rather than silently dropping them from the cost side.
|
||||
.where((qb) => { qb.where('inbound_documents.currency', cur).orWhereNull('inbound_documents.currency'); })
|
||||
.whereNotIn('inbound_documents.status', ['declined', 'duplicate'])
|
||||
.orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc')
|
||||
.orderBy('inbound_documents.created_at', 'asc')
|
||||
.select(
|
||||
'inbound_documents.id',
|
||||
'inbound_documents.invoice_date',
|
||||
|
||||
@@ -3567,7 +3567,8 @@
|
||||
"confirmPaid": "Als bezahlt markieren",
|
||||
"paid": "Bezahlt",
|
||||
"paidToast": "Als bezahlt markiert.",
|
||||
"categorizedToast": "Kategorisiert."
|
||||
"categorizedToast": "Kategorisiert.",
|
||||
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert."
|
||||
},
|
||||
"expense": {
|
||||
"kind": "Art",
|
||||
|
||||
@@ -3567,7 +3567,8 @@
|
||||
"confirmPaid": "Mark paid",
|
||||
"paid": "Paid",
|
||||
"paidToast": "Marked as paid.",
|
||||
"categorizedToast": "Categorized."
|
||||
"categorizedToast": "Categorized.",
|
||||
"categorizedPaidToast": "Categorized and marked paid."
|
||||
},
|
||||
"expense": {
|
||||
"kind": "Type",
|
||||
|
||||
@@ -164,7 +164,7 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
|
||||
);
|
||||
};
|
||||
|
||||
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: (pay?: boolean) => void }> = ({ doc, categories, onClose, onDone }) => {
|
||||
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const [supplier, setSupplier] = useState(doc.supplierName || '');
|
||||
const [amountMajor, setAmountMajor] = useState<number>(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN);
|
||||
@@ -186,10 +186,11 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null,
|
||||
});
|
||||
|
||||
// `pay` is threaded through as the mutation variable so onSuccess can decide
|
||||
// whether to continue into the mark-paid step (#5).
|
||||
// `pay` decides whether to also mark the supplier invoice paid in the same
|
||||
// step (#5/#1). When true we mark it paid directly (using the reference
|
||||
// entered) — no second dialog — so "Save & mark paid" actually pays.
|
||||
const save = useMutation({
|
||||
mutationFn: async (_pay: boolean) => {
|
||||
mutationFn: async (pay: boolean) => {
|
||||
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null });
|
||||
await accountingService.categorizeInbound(doc.id, {
|
||||
disposition,
|
||||
@@ -198,8 +199,14 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null,
|
||||
...markupPayload(),
|
||||
});
|
||||
if (pay) {
|
||||
await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined });
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, pay) => {
|
||||
toast.success(pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.'));
|
||||
onDone();
|
||||
},
|
||||
onSuccess: (_data, pay) => { toast.success(t('accounting.incoming.categorizedToast', 'Categorized.')); onDone(pay); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
});
|
||||
|
||||
@@ -304,16 +311,7 @@ export const AccountingInboxPage: React.FC = () => {
|
||||
};
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
|
||||
|
||||
// #5: after categorizing, optionally continue straight into the mark-paid
|
||||
// dialog (re-fetch so the reference just entered prefills the pay form).
|
||||
const handleTriageDone = async (pay?: boolean) => {
|
||||
const id = triageDoc?.id;
|
||||
setTriageDoc(null);
|
||||
refresh();
|
||||
if (pay && id != null) {
|
||||
try { setPayDoc(await accountingService.getInbound(id)); } catch (_e) { /* leave it categorized */ }
|
||||
}
|
||||
};
|
||||
const handleTriageDone = () => { setTriageDoc(null); refresh(); };
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user