Browse guides

Kira Backend

Panduan sistem backend Kira

Kira backend

How the finance screens read and write.

Write path

Each write is one Zod schema and one function in src/lib/finance/, taking a FinanceWriteContext ({ client, orgId }) and returning a FinResult. The server actions in src/app/(app)/finance/actions.ts check the viewer may edit, parse the input with that schema, call the function and revalidate the screens that show the data. org_id never comes from the browser.

A screen file under src/screens/finance/ is an async server component: it loads the rows, builds the figures with a pure …View function, and passes the client view its actions only when the viewer can edit. The header "Add" button is shown only to people who can add. With no Supabase environment it shows sample data and no actions.

The rules of a screen or a form that need no browser are pure functions beside the data layer, with unit tests; the client components only draw them.

FileHolds
purchase-views.tsKPI figures, chart data, table filters and CSV rows for Supplier Bills and Payments Out
sales-views.tsKPI figures, chart data, table filters, status words and CSV rows for Invoices and Payments In; what each row's menu offers (invoiceActions, paymentInActions); which customer the filter is on after a refresh (customerChoice); how an invoice's balance is worded, a customer in credit included (owed, inCredit, balanceText)
sales-followon-views.tsKPI figures, chart data, table filters, status words and CSV rows for Quotations, Credit Notes and Refunds; what a row's menu offers on each (quotationActions, creditNoteActions, refundActions)
view-helpers.tsWhat those two share: dates, sums to the sen, the eight-month window of a trend chart, how payment methods are grouped and coloured, and the "This payment also covers …" sentence of a split payment
document-lines.tsWhat a bill and an invoice share: the line schema, the real-date and SST-rate rules (isIsoDate, roundRate), and reading a saved line
bill-math.tsLine amounts and bill totals, rounded exactly as the generated columns amount and sst_amount round them; the SST rate keeps two decimals, as its column does (roundRate in document-lines.ts)
document-form.tsWhat the bill, invoice and quotation forms share: a line as it was typed and DocumentFormKind; and what only the bill and invoice forms use: the due date following the other party's payment terms and the sentence for a document that was saved as a draft but not posted. DocumentFormKind is the description of one form that the shared card draws: its nouns, its rules, its second date (secondDate: "Due date" or "Valid until") and, for a document that is marked as sent rather than posted, its own words (words; otherwise POSTING_WORDS)
quotation-form.tsThe quotation form: defaults, the valid-until date following the quotation date (thirty days, QUOTATION_VALID_DAYS, whatever the customer's payment terms), validation, what is sent. A product fills a line at its price. quotationSavedNotSent words a quotation that was saved as a draft but not marked as sent
bill-form.tsThe bill form: defaults, the due date following the supplier's terms, validation, what is sent. A product fills a line at its cost. savedNotPosted words a bill that was saved as a draft but not posted
invoice-form.tsThe invoice form: the same rules with a customer and no supplier's reference. A product fills a line at its price. Also the small e-Invois form (status and UUID)
allocation-form.tsWhat the two payment forms share: ticking documents, amounts added in whole sen, what payments not yet posted hold of a document, and AllocationFormKind, the description of one form that the shared card draws
payment-form.tsThe payment-out form: which bills can take a payment and how much, defaults, validation, what is sent. heldByBill adds up what unpaid payments already hold of each bill; keepPayable drops a ticked bill that was paid off while the form was open
receipt-form.tsThe payment-in form: which invoices can take a payment and how much (receivableInvoices), defaults, validation, what is sent. It has no scheduling
credit-note-form.tsThe credit note form: which invoices can take a credit note and how much (creditableInvoices: posted, for the total less what is already credited), defaults, validation, what is sent. creditNoteSavedNotIssued words a credit note that was saved as a draft but not issued
refund-form.tsThe refund form: what can be refunded and how much (refundTargets: an invoice whose customer is in credit, or one of its issued credit notes, less refunds still to be paid), defaults, validation, what is sent
cash-book.tsReading the cash book: one row per movement of money in (listCashReceipts, from the view finance_cash_receipts) and out (listPaymentVouchers, from finance_payment_vouchers), a page at a time, with the row types and the kinds a receipt and a voucher can be. It only reads
cash-receipts.tsA receipt's writes, and the one read its form needs: the Zod schema of a draft receipt (saveCashReceiptInput, for invoices or for income, told apart by kind), saving a draft (saveCashReceipt), posting it (postCashReceipt), voiding a posted one (voidCashReceipt), deleting a draft (deleteCashReceipt), the sentence for each database code (CASH_RECEIPT_MESSAGES), and the active income categories (listIncomeCategories). Every write given an id asks finance_cash_receipts first, so one side of a transfer is never reached
cash-receipt-form.tsThe receipt form: defaults, the form for a draft being changed (editCashReceiptForm, from its row and its Payments In rows), which invoices it can go against (receivableFor: the payment-in rule, without counting what the draft being changed holds itself), validation, what is sent (the chosen side only), a picker's list with a draft's archived choice in it (withCurrent). receiptSavedNotPosted words a receipt that was saved as a draft but not posted. receiptInvoicesForForm and receiptPaymentsForForm trim what the loader sends the form to the invoices that can still take money and the payments that hold part of one. leftOutInvoicesNote says when ticked invoices were left out. afterRefusedReceiptSave makes the form forget the id of a draft that was deleted meanwhile
expenses.tsExpenses: reading them (listExpenses, from the view finance_expense_totals, a page at a time; listExpenseCategories), the row type, the three statuses a person sets and paid, which is only how an expense reads; and the writes of the Expenses screen: saveExpenseInput, saveExpense (finance_save_expense, then a read of the status the database left), approveExpense and rejectExpense (one update of status), deleteExpense, and EXPENSE_MESSAGES
expense-form.tsThe Expenses screen's rules, pure: the form (newExpenseForm, editExpenseForm, expensePayload, expenseFormError, expenseSaveHint), what a row's menu offers each person (expenseActions), what is left of an expense (expenseLeft), how a part-paid expense reads (isPartPaid, expensePaymentNote), and the note for an expense just written that is not in the list
expense-payment.tsMarking an approved expense paid: markExpensePaidInput, markExpensePaid (a read of the expense as it was shown, then finance_record_voucher with status posted and one allocation, then a read of the voucher's number), EXPENSE_PAYMENT_MESSAGES, and the Mark paid card's rules, pure (newExpensePayForm, expensePayPayload, expensePayError, expensePayHint, the words of the note afterwards)
cash-book-views.tsKPI figures, chart data, table filters, status words and CSV rows for Cash Receipts, Payment Vouchers and Expenses; the period selector of the first two (inPeriod), which sets the cards, the donut and the table rows alike (cashReceiptsInPeriod, paymentVouchersInPeriod: a draft receipt, a draft voucher, and a voucher waiting for approval or still to be paid, stay in every period (their cards count a draft receipt and what is waiting whatever the date; no card counts a draft voucher, which is kept so that what it holds of a bill can be found); search, filters, "View all" and Export work on top of it); which category a voucher counts under (voucherCategory) and which screen changes it (voucherHome); what a receipt's row menu offers, by its status and by whether the person may sign off (cashReceiptActions), and the words of its questions (receiptName, receiptVoidEffect, receiptDeleteEffect)
receipts.tsReading the Receipts Inbox (listInboxReceipts, from the view finance_receipt_inbox), a page at a time, with the row type and the bucket's name, size limit and five file types. An inbox receipt is an uploaded photo or PDF, not a cash receipt: everything exported says Inbox. It never reads file_path: only getReceiptFile in receipt-inbox.ts does, on the server
receipt-views.tsThe three counts, the donut, the status and type words, the "Matched to" words (inboxDocument), whether a row says anything about AI reading and what (inboxReadingNote, INBOX_READING_NOTES), the filter and the CSV rows of the Receipts Inbox
receipt-files.tsWhat is decided about a file before it is uploaded: its type from its first bytes (sniffReceiptType), the path it is stored at, the name kept to show (cleanFileName), what a refused upload means, and the upload queue's states
receipt-inbox.tsThe inbox's writes and its review form: record an upload (registerReceipt), review (reviewReceipt), delete (deleteInboxReceipt), read a receipt's path and sign a download link, and the sentences for FIN11, FIN23, FIN24
receipt-files-server.tsServer only: reads a stored file's first bytes before it is opened (storedFileCheck), removes a deleted receipt's file (removeReleasedReceiptFile) and runs the clean-up (cleanReceiptFiles)
receipt-match.tsMatching a receipt: the schemas and writes that make an expense or a draft bill from one (receiptToExpense, receiptToBill), tie it to a document that exists (attachReceipt) and untie it (detachReceipt); the read behind the paperclip on Expenses and Supplier Bills (listAttachedReceipts, receiptsByDocument); how a matched receipt's document stands (matchedDocuments), what a row offers (receiptMatchActions), the two forms pre-filled from a receipt, the attach picker's list, and the three lists of screens its actions refresh
receipt-reading.tsReading a receipt with AI, the rules that are not the model's, pure: who is offered it (receiptReadingPlan, canOfferReading), what the person is asked before anything is sent (readingQuestion) or told when it cannot start (readingBlocker), every refusal's sentence, the model's instructions and the schema of its answer, what is kept of an answer (normalizeReading), the category list as the model sees it (names only) and how its number is turned back into a category (categoryAt), and how long a suggested category stands on the screen (standingSuggestion)
receipt-reader.tsServer only: which model reads receipts (receiptModelId, from the environment, no id in code), whether the workspace has a key (workspaceKeyState), the receipt as the reading needs it (getReceiptForReading), the whole stored file through the caller's link (receiptBytes, sendableAs), the one call to the model (readReceiptWithModel: no tool, no retry, 45 seconds), and the write of what came back (setReceiptExtraction)
rfqs.tsRFQs (requests for quotation). Described here only for the link a copied RFQ keeps: saveRfqInput takes source_rfq_id, the RFQ a new draft was copied from, which must be an id, and saveRfq sends it to finance_save_rfq; a list row carries source_rfq_id (the first RFQ of its group, or null) and quoted_at (when the supplier's answer was recorded; until then the list and its export show no total, as prices the buyer typed are not a quote); getRfqGroup reads the RFQs of one RFQ's group that have gone to their supplier, with their lines, in the order they were made. saveRfq words FIN11 on a new copy with a sentence of its own (RFQ_MESSAGES.copiedFromGone)
rfq-form.tsThe RFQ form. A form opened by Copy to another supplier (rfqFormCopiedFrom) remembers the RFQ it was copied from, and rfqPayload sends it with the new draft. When that save is refused because the RFQ it was copied from is gone, rfqFormAfterRefusal drops the link, so saving again saves the RFQ on its own; the shared card applies it through DocumentFormKind.afterRefusal, which no other form has
rfq-compare.tsCompare quotes, pure. A group is the first RFQ and every RFQ that names it (rfqGroupId); an RFQ can be compared once it and at least one other of its group have gone to their supplier (comparableRfqIds), and rfqActions(row, comparable) in purchase-views.ts then offers Compare quotes in every status but draft. compareQuotes lays the group out as a row for each item and a cell for each RFQ: lines are matched by product, or else by description, in the same unit and pack size; only an RFQ whose answer was recorded (quoted_at) has prices; the lowest price is for one unit with SST added, compared to four decimals, marked on every supplier tied at it and only when at least two priced the line; a cell worded differently from its row keeps its own description. Also the question the card asks before making an order (orderableRfq) and what deleting a draft costs the RFQs copied from it (rfqCopyCount, rfqDeleteWarning)
purchase-order-print.tsA printed purchase order, pure (purchaseOrderPrint): what each part says, for an approved or closed order read by getPrintablePurchaseOrder. "Prepared by" is whoever submitted the order (else whoever raised it, with no time) and "Approved by" its approver; each prints the person's full name (else the part of their email before the @), their job title (else their role), and the time in Malaysia. Someone who has left the workspace (no role in it, even if another shared workspace still shows their profile) prints with a blank name and designation, and only the time stays. "Deliver to" is the order's address, else the workspace's. The print view (purchase-order-print-dialog.tsx) uses Titan's letterhead and print styles
csv.tsCSV text for Export: RFC 4180, a UTF-8 byte-order mark, and a guard against spreadsheet formulas
categories.tsExpense and income categories, for the Categories card on Expenses: listCategories (both kinds, archived ones included, each with its mark when the books lean on it), createCategoryInput and createCategory, renameCategoryInput and renameCategory, setCategoryActiveInput and setCategoryActive (archive and restore; the two marked categories are not archived), and CATEGORY_MESSAGES. Nothing deletes a category. The forms' own pickers (listExpenseCategories, listIncomeCategories: active ones of one kind) stay where they were
category-form.tsThe Categories card's rules, pure: the two lists (categoryLists), what a row shows and offers (categoryMark, categoryActions), a name that is taken (categoryNameClash, categoryAddError, categoryRenameError), and every sentence the card says

The forms validate by running the write's own Zod schema on what they are about to send, so the browser and the server say the same sentences.

One card draws the three document forms (document-form-card.tsx) and one draws both payment forms (allocation-form-card.tsx). BillFormCard, InvoiceFormCard, QuotationFormCard, PaymentFormCard and ReceiptFormCard are thin wrappers that hand the card their kind (BILL_FORM_KIND, INVOICE_FORM_KIND, QUOTATION_FORM_KIND, PAYMENT_OUT_KIND, PAYMENT_IN_KIND). A rule or a word that differs between a bill, an invoice and a quotation, or between money out and money in, belongs in the kind, not in the card. withCurrentParty in document-form.ts gives all three document cards their picker's list: the active customers or suppliers, with a draft's own party first when it was archived since.

A credit note and a refund each have a card of their own (credit-note-form-card.tsx, refund-form-card.tsx), and no kind. A credit note is one invoice, one amount and a reason: no lines, so not the document card; no account and no method, so not the payment card. A refund is one payment against exactly one invoice or one credit note, with a reason; the payment card splits one payment across many documents. Both cards are used on more than one screen: the credit note card on Credit Notes and from an invoice's row, the refund card on Refunds and from an invoice's or a credit note's row. Where the thing saved shows on another screen than the one the person is on, a note at the top says so and links there (saved-note-card.tsx). The card hands back what it saved, and the note is worded from that (refundSavedNote, creditNoteSavedNote): the amount as the schema rounds and stores it, the invoice or credit note it went against, the customer, and whether a refund was paid out or saved to pay later and a credit note issued or kept as a draft. A card opened from a row has that row chosen but its picker is not locked, so the note names what was saved, whichever row the card was opened from.

A server action refreshes the screens after every attempt that reached the database, refused or not, so a record someone else changed or removed shows as it now is. saveAndPostBillAction saves a draft and posts it; when only the post is refused it answers with the draft's id (draftId) and the form goes on editing that draft. getBillAction reads one draft with its lines for the Edit form. saveAndPostInvoiceAction and getInvoiceAction do the same for an invoice, and saveAndSendQuotationAction and getQuotationAction for a quotation, through the same two helpers. src/app/(app)/finance/error.tsx is shown when a finance screen's data cannot be read.

Which screens an action refreshes is every screen whose figures it can move; some refresh more than they need to. A bill or a payment out: Supplier Bills, Payments Out, Customers & Suppliers. An invoice or a payment in: Invoices, Payments In, Customers & Suppliers, Quotations, Credit Notes, Refunds. A contact: the eight screens that show a contact's name. A quotation saved, sent, answered or deleted: Quotations only, as nothing else counts or names a quotation. A quotation made into an invoice: Quotations and Invoices, as a draft invoice is not yet owed. A draft credit note saved or deleted: Credit Notes only, as a draft takes nothing off its invoice. A credit note issued or voided, and a refund recorded, marked paid, voided or deleted: Invoices, Payments In, Customers & Suppliers, Credit Notes and Refunds, as each can move what an invoice owes; never Payments Out, which shows no refund, and not Quotations.

The cash book lists every movement of money, so the actions that record one refresh it as well: an invoice or a payment in also refreshes Cash Receipts; a bill or a payment out, and a credit note issued or voided or a refund at any step, also refresh Payment Vouchers; a contact also refreshes Cash Receipts, Payment Vouchers and Expenses, where it can be the payer, the payee or the vendor. Every cash receipt action (save, save and post, post, void, delete) refreshes what an invoice or a payment in refreshes, whatever the receipt is for: one against invoices moves what they owe, or holds part of them while it is a draft, and one save can turn a draft of one kind into the other. The five expense actions (saveExpenseAction, approveExpenseAction, rejectExpenseAction, deleteExpenseAction, markExpensePaidAction) refresh EXPENSE_PATHS: Expenses, Receipts Inbox (which names the expense a receipt is tied to), Payment Vouchers and Banking (which name the category of the expenses a voucher pays), then Chart of Accounts, Journals and Reports (LEDGER_PATHS): an approved expense is two lines of the books, so approving or rejecting one moves them. Approve and reject are refused to anyone who may not sign off, before anything is read, in the expense's own sentence. Payment Vouchers has no action of its own yet: it is read-only for everyone.

The three category actions (createCategoryAction, renameCategoryAction, setCategoryActiveAction) refresh CATEGORY_PATHS: Expenses, Cash Receipts, Supplier Bills, Payment Vouchers, Banking and Receipts Inbox (every screen whose forms offer a category or whose rows name one), then LEDGER_PATHS (each category has an account in the Chart of Accounts, renamed and archived with it). All three are refused to anyone who may not sign off, before anything is parsed or read, in the category's own sentence.

The Receipts Inbox has its own actions file, src/app/(app)/finance/receipt-inbox-actions.ts: registerReceiptAction, reviewReceiptAction, deleteReceiptAction and receiptLinkAction. The first three use the same guard as every finance action and refresh /finance/receipts-inbox only; the last is a read, open to anyone who can see the receipt. Four more match a receipt, behind the same guard: receiptToExpenseAction refreshes what saving an expense refreshes (RECEIPT_EXPENSE_PATHS), receiptToBillAction what saving a bill refreshes (RECEIPT_BILL_PATHS), and attachReceiptAction and detachReceiptAction the inbox, Expenses and Supplier Bills (RECEIPT_TIE_PATHS). A 'use server' file may export only async functions, so the first two lists are written a second time in receipt-match.ts; tests/finance-receipt-match.test.ts fails if either differs from EXPENSE_PATHS or BILL_PATHS in actions.ts. BILL_PATHS includes /finance/receipts-inbox: posting a bill gives it the number the inbox shows, and deleting a draft sends its receipt back to Unmatched (EXPENSE_PATHS has had the inbox since the expense forms).

Every action refuses a viewer and the demo workspace before the data layer is reached. Without that, a viewer's void or delete would come back from the database as "no row", which reads as "That invoice no longer exists.".

Tables

Every finance table carries org_id. A table that other tables point at exposes a unique (org_id, id) and is referenced by that pair. All finance tables have member-read, writer-write RLS with the restrictive mfa_required policy.

TableHolds
finance_contactsCustomers and suppliers. is_customer and is_supplier can both be true.
finance_productsProducts and services for document lines. cost keeps four decimals, price two.
finance_accountsBank and cash accounts. Every workspace starts with "Main Bank" and "Cash in hand". Banking lists them with their balances; they have no form yet.
finance_categoriesExpense and income categories, each with a ledger account of its own. Every workspace starts with ten. Every member reads them; only an owner or admin adds, changes or deletes one (three restrictive policies beside the writer ones: finance_categories_sign_off_insert, _update, _delete). A name is 1 to 80 characters with no space at either end (finance_categories_name_check). Managed on the Categories card on Expenses ("The Categories card", under Expenses), which adds, renames, archives and restores, and never deletes.
finance_sequencesThe next document number per type.
supplier_bills, supplier_bill_linesSupplier bills and their lines. A draft has no bill_no.
finance_invoices, finance_invoice_linesSales invoices and their lines. A draft has no invoice_no. Lines keep the order they were entered in (position). quotation_id is the quotation an invoice was made from, if any.
finance_quotations, finance_quotation_linesQuotations and their lines, shaped like invoices. A draft has no quotation_no.
finance_credit_notesA credit against one posted invoice: one amount and a reason, no lines. A draft has no credit_note_no.
finance_sst_settingsOne row per workspace: the SST taxable period's start month, the service and sales tax registration numbers, and when service tax is due. Owner or admin writes; see SST settings and tax type.
finance_supplier_credit_notes, finance_supplier_credit_applicationsA credit from a supplier against one posted bill (one amount, a reason, the supplier's own reference), and which bill took how much of it. See Supplier credit notes.
finance_transactionsOne row per movement of money: direction, account, date, amount, method, status.
finance_allocationsHow a movement is split across the documents it pays. Each row names exactly one document: a bill (bill_id), an invoice (invoice_id), a credit note (credit_note_id) or an expense (expense_id). reason is what a refund is for. One payment pays one kind of document: bills, or invoices and credit notes, or expenses.
finance_expensesAn expense: a date, a category, a vendor who is a contact (vendor_id) or a typed name (vendor_name), never both, and amount, which has its SST inside it (sst_amount is that part). status is pending, approved or rejected; approved_by and approved_at say who decided and when, whichever way. It has no number.
finance_receiptsA receipt in the inbox: file_path (the object's name in the private bucket finance-receipts, empty only on the demo's seeded rows), file_name (what the uploader's device called it, for display only), mime_type and size_bytes (the database's reading of the stored object), what was read from it (vendor, receipt_date, amount with its SST inside, sst_amount, extraction_error, and extracted_at, the time a model read it and got something), status (review, unmatched, matched), and expense_id or bill_id: one of them exactly while it is matched. uploaded_by is written by the database.
private.finance_receipt_deletionsThe paths of files to remove from the bucket. Written by a trigger when a receipt is deleted and by the clean-up function that finds unrecorded uploads; reachable by nobody but four service_role functions.
finance_statement_importsOne row per bank statement file imported for one account: account_id, file_name, created_by.
finance_statement_linesOne row per line of a statement, in the file's order (position): line_date, description, and money_in or money_out, never both. transaction_id is the movement of money a person matched it to; a movement is matched by at most one line. A line belongs to its import's account.
finance_rfqsRFQs. Described here only for source_rfq_id: the first RFQ of the group a copied RFQ belongs to, and null for the first RFQ itself and for an RFQ that was not copied from another. It is set once, when the copy is first saved, and a copy of a copy names the first RFQ, so a group is one RFQ and every RFQ that names it, with no chain to follow. The foreign key is on (org_id, source_rfq_id), so the link never leaves the workspace, and there is an index on the same pair. After that the link does not change from a signed-in caller, except that deleting the first RFQ, which only a draft allows, clears it on every copy (on delete set null (source_rfq_id)).

Document numbers

select public.finance_next_number(target_org, doc) with doc set to 'invoice' returns the next number (INV-0001) and moves the counter on. It locks the workspace's row for that type, so simultaneous callers never get the same number. Types: invoice, quotation, credit_note, supplier_credit_note, bill, receipt, voucher, transfer, contra (CT-0001, taken when a contra entry is posted). Call it from inside the database function that posts a document, so a failed post does not use up a number.

A quotation takes its QT- number when it is sent and a credit note its CN- number when it is issued, and a supplier credit note its SCN- number when it is issued. A refund is money out and takes a PV- number when it is paid out, from the same counter as a payment to a supplier. A payment voucher of any other kind takes its PV- number from that counter too, when it is paid, and a cash receipt its RC- number when it is posted, from the counter payments in use. An expense has no number. A transfer between accounts takes one TR- number for both of its sides, from a counter of its own, so a transfer leaves no gap in the receipt or voucher numbers. A payment's number is unique in its workspace; the two sides of a transfer are the one case of two rows with one number.

finance_next_number runs as its owner (security definer) and is the only writer of finance_sequences: since 20261030123000_finance_write_guards the table is read-only to a signed-in person, and the function begins by asking whether the caller may write in the workspace (42501 for a viewer and for anyone outside it). 20261031090000_Kira_banking, written before that file and applied after it, replaced the function to add the transfer type and declared it security invoker again, as it had been; run as the caller it could not write the counters, and every numbered document was refused until 20261031090200_Kira_banking_next_number_definer put it back. A later migration that replaces this function, or any function listed as running as its owner under Database functions, must keep security definer and the test of the caller it begins with.

Money

Money is entered once. A payment out is one finance_transactions row with direction out, split across one supplier's bills by finance_allocations. The view finance_payments_out has one row per bill a payment pays; supplier_bill_totals counts only posted money out as paid.

A payment in is the mirror: one row with direction in, split across one customer's posted invoices. It is posted the moment it is recorded, with an RC- number: money received is never scheduled and needs no approval. The view finance_payments_in has one row per invoice a payment pays, with the same columns as finance_payments_out (invoice_id, invoice_no and customer_name where that view has the bill and the supplier); finance_invoice_totals counts only posted money in as paid. In both views allocation_id is the row's key: a payment split across documents is several rows with one transaction_id, whose amounts add up to transaction_amount.

A bill takes only money out. An invoice takes money in, which pays it, and money out, which is a refund. A credit note takes only money out.

A refund is one row with direction out, allocated to one invoice or to one credit note, with a reason on the allocation (order_cancelled, damaged_goods, wrong_item, overpayment, other). It is paid out at once (posted, numbered PV-) or left pending (scheduled). A pending refund is marked paid by finance_mark_payment_paid or deleted; a refund that was paid out can only be voided. When a pending refund is paid out, the cap on the invoice's credit and the credit note's amount are both looked at again, counting the refund itself, and it is refused with FIN05 if it no longer fits, or with FIN04 if its credit note is no longer issued. These are the function, the delete and the update a payment to a supplier uses, but markRefundPaid, voidRefund and deleteRefund in refunds.ts first read the view finance_refunds for the workspace and the transaction, and refuse with "That refund no longer exists." anything it does not show. A payment to a supplier can therefore not be marked paid, voided or deleted through them. The view finance_refunds has one row per allocation, which for a refund the app records is one row per refund, with invoice_id set whichever way it was recorded and credit_note_id when it was put against a credit note.

A refund is not on Payments Out: finance_payments_out joins each allocation to its bill, and a refund has none. supplier_bill_totals counts only money out allocated to a bill, so a refund is never counted as paid to a supplier. finance_payments_in shows only money in.

The reverse holds too. markPaymentPaid, voidPayment and deleteScheduledPayment in money.ts, which Payments Out uses, first read finance_payments_out for the workspace and the transaction and refuse with "That payment no longer exists." anything it does not show, without writing. A refund can therefore not be marked paid, voided or deleted through them. voidPaymentIn is kept to direction = 'in', so a refund and a payment to a supplier, both money out, are out of its reach; it reads finance_payments_in first as well, because money in can also be a receipt for income (see The cash book).

The payment-in form is opened from Payments In, or from "Record payment" on a posted invoice's row with that customer chosen and that invoice ticked. It offers a customer's invoices that are Sent or Overdue, each for its balance less anything a payment not yet posted holds (receivableInvoices); through the app nothing is ever held, as money in is posted at once.

A bill's balance is its total less paid money. What a new payment may take is less than that when payments are waiting: the allocation guard counts scheduled, draft and pending-approval payments too, as they hold a bill's balance. The payment form works this out (payableBills) and does not offer a bill that such payments already cover; Supplier Bills shows what is held under the bill's balance (heldByBill), for viewers too.

Statuses of a transaction: draft (being written by a database function, or a cash receipt not yet posted), pending_approval (waiting for an owner or admin; the database functions put only money out there, though the guard lets any draft be submitted, money in included), scheduled (allowed to be paid, not yet money out), posted (paid or received; numbered PV-0001 for money out and RC-0001 for money in), rejected (refused by an owner or admin; final), void. A payment is always created as a draft: a direct INSERT with any other status is refused (FIN09).

Who paid or was paid is a contact (contact_id) or a typed name (party_name), never both; nobody need be named. category_id is for money that settles no document: an income category on money in, an expense category on money out.

The cash book

Cash Receipts and Payment Vouchers list every movement of money, one row each, whatever it is for. The views are finance_cash_receipts (direction in) and finance_payment_vouchers (direction out); each row is one finance_transactions row, keyed by transaction_id, with the account, who paid or was paid (party_name: the contact's name or the typed one), the category and documents, the numbers of what it pays. The two sides of a transfer between accounts (transfer_id) are in neither.

kind says what a row is, and therefore which screen changes it:

ViewkindWhat it isAlso shown onChanged on
finance_cash_receiptsinvoiceMoney in against invoicesPayments In, one row per invoicePayments In (recorded and posted at once; void) and Cash Receipts (a draft: edit, post, delete; void)
finance_cash_receiptsincomeMoney in that settles no invoice, with an income categorynowhere elseCash Receipts
finance_payment_vouchersbillA payment to a supplierPayments Out, one row per billPayments Out
finance_payment_vouchersrefundMoney back to a customerRefundsRefunds
finance_payment_vouchersexpenseA payment of approved expensesExpenses, as PaidPayment Vouchers (delete before it is paid; approve and reject if it waits)
finance_payment_vouchersotherMoney out that settles no document, with an expense categorynowhere elsePayment Vouchers (record, approve, reject, delete before it is paid)

Nothing is counted twice. Payments In, Payments Out and Refunds are views of allocations joined to their documents, so each shows only its own kind, one row per document. The cash book views are views of the transactions, so each shows every kind once. No screen adds one of these up with another. A payment across two bills is two rows on Payments Out whose amounts come to the one row's amount on Payment Vouchers.

voidPaymentIn, which Payments In calls, reads finance_payments_in first and refuses anything it does not show, so a receipt of kind income cannot be voided through it; markPaymentPaid, voidPayment and deleteScheduledPayment read finance_payments_out, and the refund writes finance_refunds, as before. A voucher of kind expense or other is in none of those three views and is reached through none of those functions.

A cash receipt can wait as a draft. finance_save_cash_receipt creates one or replaces a draft's details and split; finance_post_cash_receipt gives it its RC- number. It is for one customer's posted invoices, in which case its amount is the sum of its split and the customer is the payer, or for an income category, with an amount of its own. While it is a draft, what it is split across is held: the guard on allocations counts a draft against each invoice, so nobody else can receive that part. Money in needs nobody's approval. A payment recorded on Payments In is still finance_record_payment_in, posted at once.

finance_record_voucher records money out that pays approved expenses, or money out that settles no document and carries an expense category. A payment to a supplier and a refund are not recorded through it.

The receipt form

Cash Receipts is writable. cash-receipts.ts holds the writes and cash-receipt-form.ts the form's rules; cash-receipt-form-card.tsx draws the form, a card of its own. A receipt is saved as a draft, posted, and after that only voided; a draft is deleted. Who may do which is the database's rule (20261030123000_finance_write_guards), and the screen offers each person only what it will let them do:

Owner, adminMemberViewer, demo
New receipt, Save draft, Edit a draft, Delete a draftYesYes, anyone's draftNo button, no menu; the action answers "You do not have permission to make changes here."
Post (the form's "Post receipt", a draft's Post)YesNot offered. postCashReceiptAction (a draft's Post) answers "Only an owner or admin can post, pay or void. Ask one of them to do this." without reading or writing. saveAndPostCashReceiptAction (the form's "Post receipt") saves the draft and answers "Saved as a draft. Only an owner or admin can post or issue it."; nothing is posted. The database answers FIN14 and uses up no numberNo
Void a posted receipt, of either kindYesNot offered; the same sentence, FIN14No

The screen loader works canSignOff out from the viewer's role (can(role, 'sign-off-finance')) and hands it to the view with the actions; the server actions ask again (SIGN_OFF in actions.ts), and the trigger finance_transaction_sign_off has the last word. A member's form has "Save draft" only and says an owner or admin posts it. "Post receipt" saves and then posts (saveAndPostCashReceiptAction); when only the post is refused the draft is kept and the form goes on editing it.

A receipt for invoices is sent its split and nothing else: the database makes its amount the sum of the split and its payer the invoices' customer. A receipt for income is sent a category, an amount, and a contact or a typed name or neither, never both (finance_transactions_party_check). The form holds both sides while it is open and sends only the one chosen.

A draft being changed is not read again: the form is built from the draft's row on the screen and, for its split, from the rows finance_payments_in has for it. What it holds of its own invoices is not counted against it (receivableFor), as the database deletes the old split before it checks the new one. Saving replaces the draft whole; a draft that was posted or deleted meanwhile answers with that sentence (FIN09, FIN11); after "That receipt no longer exists." the form forgets the draft's id and says "Save again to record it as a new receipt.", so what was typed can be saved as a new one. A ticked invoice that can no longer take money is left out, and the form says so.

Void is offered on every posted receipt, a payment recorded on Payments In included: it is the same status change voidPaymentIn makes, asked of finance_cash_receipts instead of finance_payments_in, so it also reaches a receipt for income, which voidPaymentIn refuses.

The voucher form

Payment Vouchers is writable for anyone who may edit (can(role, 'edit-data'), never the demo workspace). The writes are in src/lib/finance/vouchers.ts, the screen's rules in src/lib/finance/voucher-form.ts, the actions (recordVoucherAction, approveVoucherAction, rejectVoucherAction, deleteVoucherAction) in actions.ts. No migration came with it: every write is one the database already allowed.

New voucher records money out that settles no document: who is paid (a contact or a typed name, exactly one), an expense category, an amount, the account, the date, the method, a reference and notes. It calls finance_record_voucher asking for "To be paid" and sends no status of the person's choosing. The database decides by who is recording: an owner's or admin's voucher is approved in the same call and reads "To be paid"; anyone else's waits as "Pending approval". The status is read back and the screen says which it was. A voucher that waits cannot be edited (the guard refuses any change of one, FIN14), so a mistake in one is deleted and recorded again; one saved as a draft first can be (next section). An owner or admin can also record it as paid now; marking a voucher paid and voiding one are in "Paying and voiding a voucher" below.

Approve and Reject are offered to an owner or admin on every voucher that is waiting, whatever it pays. Each is one update of status (scheduled or rejected) and nothing else; the guard writes who decided and when. The update is matched on the voucher as the person's row showed it: status = 'pending_approval', amount, date, method, account, contact, typed name, category, reference and notes, each compared with eq, or with is null where the row showed nothing. finance_transactions.updated_at is not moved by the database, so it is not what is compared. When no row matches, one read of finance_payment_vouchers words the answer: "That voucher no longer exists.", "This voucher has already been approved.", "This voucher has already been rejected.", or "This voucher is not as it was shown to you. Check it again before you decide." A rejected voucher is final (the guard refuses to bring one back): it is deleted and recorded again. "Already been approved" is said only of a voucher that an owner or admin did approve (approved_at is set); one that is "To be paid" and was never approved (a supplier payment a member scheduled, reached by a direct call) gets "not as it was shown". The Approve and the Reject question add one line under their sentence with what the table does not draw: the category, the method, and the reference and notes when they hold something, drawn as text.

At Approve, a voucher that pays supplier bills shows the same advice Payments Out shows before a payment is marked paid: OrderNotes with the sentences of orderNotesForBills, for each purchase order behind its bills that is billed for more than its total or for more than has been received (orderNotesByVoucher). That advice is worked out only for someone who may approve, and only while such a voucher is waiting (voucherAccess); the bills and purchase orders themselves are read for anyone who may write, because the form offers them. Such a voucher is made on this screen: "New voucher" for a supplier's bills, sent for approval (next section).

Delete is offered, to anyone who may write and whoever recorded the voucher, on any voucher that is a draft, waiting or rejected, and on one that is "To be paid" when it is of kind expense or other. It is one delete matched on the status the row showed, so a voucher approved meanwhile is not deleted by someone who saw it waiting ("This voucher has changed since you opened it, so it was not deleted. Check it again."). A payment to a supplier and a refund are, from "To be paid" onward, marked paid, voided and deleted on Payments Out and Refunds (voucherHome), so their rows offer nothing here then; before that those two screens offer nothing on them, so the delete is here.

What each role is answered by the database, and what the screen offers:

WriteOwner, adminMemberViewerOutsider
finance_record_voucher for a categoryrecorded, approved by them, "To be paid"recorded, "Pending approval" whatever status was asked42501 "not allowed"42501
Approve or reject a waiting voucherdone; approved_by and approved_at written by the guardFIN14 "only an owner or admin approves or rejects a payment"no row changedno row changed
Reject one that is "To be paid"; bring a rejected one backFIN14FIN14no row changedno row changed
Delete one that is not paiddonedoneno row changedno row changed
Delete a paid oneFIN09FIN09no row changedno row changed
On the screenOwner, adminMemberViewer, demo, sample
New voucherYes: "Save voucher", approved on savingYes: "Send for approval"No button
Approve, Reject on a waiting voucherYesNot offered. The actions answer "Only an owner or admin can approve or reject a voucher. Ask one of them to do this." before anything is readNo menu
Delete on a draft, waiting or rejected voucher of any kind, and on a "To be paid" one of kind expense or otherYesYesNo menu
A supplier payment or a refund that is "To be paid" or paidA link to Payments Out or RefundsThe linkThe link

A saved draft, and a voucher for a supplier's bills

Migration 20261117100000_Kira_save_voucher.sql adds one function and nothing else: finance_save_voucher(target_org, voucher, allocations). It creates a draft voucher, or replaces the details and split of one that is still a draft (voucher.id), and with voucher.status = 'pending_approval' sends it for approval in the same call. A voucher is for one supplier's posted bills (allocations: [{bill_id, amount}]; its amount is the sum of its split, each part rounded to the sen, and its payee is the bills' supplier whatever was sent) or for an expense category (an amount and a payee of its own). It never schedules and never pays (22023 if asked), takes no expenses (FIN06; finance_record_voucher records those), and refuses an amount that is NaN by name (23514). It runs as the caller, like finance_save_cash_receipt: it writes no closed column, so the row policies decide who may write and every guard runs on every row. It takes no error code of its own. Check script: supabase/tests/Kira_save_voucher_check.sql.

On the screen, "New voucher" asks what it is for. For an expense category the first button is as before (an owner's or admin's save is approved at once; a member's waits) and "Save as draft" is beside it. For a supplier's bills the person chooses the supplier and ticks the bills (voucherPayableBills: what is owed on each less what payments, drafts and waiting vouchers already hold), sees under them the same over-billing advice the Payments Out form shows (OrderNotes, orderNotesForBills), and has "Send for approval" and "Save as draft", whoever they are. Approval of a supplier payment is a choice: Payments Out still schedules one without it, and the form says so. This screen never schedules a supplier payment, so the two screens do not offer the same thing. An owner or admin who sends a voucher for approval may approve it themselves.

A draft of kind Supplier payment or Other payment has Edit (the form, with its bills ticked for what it holds), Send for approval and Delete in its row, for anyone who may write, whoever saved it. Send is one update of status to pending_approval, matched on the row as shown like a decision ("This draft is not as it was shown to you, so it was not sent. Check it again."), and its question carries the same line of what the table does not draw; the bills behind a draft are not part of that match, which is why approval is matched again on a voucher that can no longer change. A draft is always sent for approval, also by an owner or admin. A draft holds its part of each bill (FIN05 counts it), and so does a voucher that waits; a rejected one holds nothing.

Once a voucher waits, its fields (FIN14) and its split (FIN09 from the guard on allocations) cannot be changed by anyone, so what an approver's row and question showed is what is approved. Approved, a voucher for bills is a payment "To be paid" on Payments Out (voucherHome), where it is marked paid, voided or deleted; the Kind cell links there at every status and says "Managed on Payments Out" from then on and not before (voucherKindLink).

A draft stays in the table in every period, as a draft receipt does (paymentVouchersInPeriod): it holds its part of a bill until it is sent or deleted, so it must be findable however old its date. No card or chart counts a draft, and the line under the table counts one dated outside the period among the rows it kept ("2 earlier ones still waiting or in draft").

The actions are saveVoucherAction and submitVoucherAction; neither needs a sign-off, and both refresh the screens every voucher action refreshes (VOUCHER_PATHS).

WriteOwner, adminMemberViewerOutsider
finance_save_voucher, a draft for bills or for a categorysavedsaved42501FIN06 (bills) or FIN15 (category)
The same, sent for approval in the callwaits, approved by nobodywaits42501FIN06 or FIN15
Edit a draft, whoever saved itsavedsavedFIN11FIN06 or FIN15
Edit a voucher that is no longer a draftFIN09FIN09FIN11FIN06 or FIN15
Send a draft for approval by its rowdonedoneno row changedno row changed
Change a waiting voucher's splitFIN09FIN09no row changedno row changed

Paying and voiding a voucher

No migration came with this either. Three writes, all a sign-off (an owner or admin; can(role, 'sign-off-finance') in the action, FIN14 from the database for anyone else), on the two kinds of voucher that belong to this screen: one for a category (other) and one that pays expenses (expense). A payment to a supplier and a refund are paid and voided on Payments Out and Refunds, as before (voucherHome); markVoucherPaid and voidVoucher read the voucher's kind on finance_payment_vouchers first and refuse those two ("A payment to a supplier is paid and voided on Payments Out, and a refund on Refunds.").

Paid now. On a new voucher for a category an owner or admin chooses "When": "To be paid" (the default, as before) or "Paid now". "Paid now" sends paid_now: true; recordVoucher then asks finance_record_voucher for status: 'posted', and the voucher is approved, paid and numbered PV- in one call, dated the form's date ("Date paid"). The database does not refuse a member who asks for this: it leaves their voucher waiting for approval. So recordVoucherAction refuses them first ("Only an owner or admin can record a voucher as paid."), and the form does not offer them the choice. A draft and a voucher for bills are never paid from the form.

Mark as paid is on a voucher of those two kinds that is "To be paid". The question asks the day it was paid (it starts as the person's own today) and markVoucherPaid calls finance_mark_payment_paid(target_org, target_txn, paid_on), the function Payments Out and Refunds use: the voucher gets its PV- number, its date becomes the day it was paid, and nothing else of it changes. Nothing is matched on what the row showed, because nothing needs to be: from the moment a payment leaves draft the database holds it still (FIN14 on an approved one, FIN28 on any other, FIN09 on its split; 20261117090000), so the voucher paid is the voucher the row showed, or it is gone (FIN11), or it is no longer "To be paid" (FIN09). After a FIN09 one read of the view words the answer: "This voucher has already been paid.", "This voucher has already been voided.", or "Only a voucher that is To be paid can be marked paid."

Void is on a paid voucher of those two kinds: one update of status to void, matched on status = 'posted', money out, no side of a transfer. finance_void_stamp writes voided_at. The voucher keeps its number and is kept as Void, found under Void in the status filter (its first choice leaves void ones out; the note after the write says so). In the books it is its original entry and, on the day it was voided, the same entry the other way round (payment_out:<id>:void), so it leaves nothing on any account. A voucher that paid expenses stops counting as paid on each of them (finance_expense_totals counts posted money only): the expense reads Approved again, or Part paid, and can be marked paid again, which records a new voucher with the next number. This is how a wrong "Mark paid" on Expenses is undone. A void is final: the guard refuses to bring a voided payment back (FIN09).

All three refresh VOUCHER_PATHS, which now ends with the three ledger screens, as every list of an action that moves the books does.

WriteOwner, adminMemberViewerOutsider
finance_record_voucher for a category with status: 'posted'paid, numbered, approved by themrecorded, "Pending approval", no number4250142501
finance_mark_payment_paid on a "To be paid" voucher (approved, or one for expenses that nobody had to approve)paid, numbered, dated paid_onFIN144250142501
The same on a draft, a waiting, a paid or a void voucherFIN09FIN094250142501
The same on an id that is not thereFIN11FIN114250142501
Void a paid voucherdone, voided_at writtenFIN14no row changedno row changed
Void one that is "To be paid"FIN09FIN09no row changedno row changed
Bring a void one back; delete a paid or a void one; change a paid oneFIN09FIN09no row changedno row changed
Change a "To be paid" oneFIN14 (approved) or FIN28the sameno row changedno row changed
On the screenOwner, adminMemberViewer, demo, sample
"When" on a new voucher for a category"To be paid" or "Paid now"Not offeredNo form
A "To be paid" voucher of kind expense or otherMark as paid, DeleteDeleteNo menu
A paid one of those kindsVoidNo menuNo menu
A void oneNothingNothingNothing
A supplier payment or a refund that is "To be paid" or paidA link to Payments Out or RefundsThe linkThe link

Expenses

An expense is one finance_expenses row. It is created pending. While it is pending anyone who may write can edit or delete it. An owner or admin approves or rejects it: a change of status and nothing else, and the database writes down who and when. Saved by an owner or admin (finance_save_expense), it is approved in the same call. An approved expense is locked; an owner or admin can still reject it while no payment stands against it. A rejected expense can be deleted, or edited, and the edit must itself set its status back to pending, as finance_save_expense does: an update that leaves it rejected is refused (FIN01).

An approved expense is an entry in the books: through the view finance_ledger_lines it is a debit to its category's ledger account and a credit to Accrued Expenses, for the whole amount, SST included. Each approval is kept as it was posted in finance_expense_approvals (20261125160000_Kira_expense_send_back.sql): one row per approval with its amount, SST, category and the date it is in the books, written only by the trigger finance_expense_approvals_write; members read it. The first approval is in the books on the expense date (key expense:<id>), a later one on the Malaysian day it was approved, or the expense date if that is later (expense:<id>:<round>).

Sending an approved expense back

An owner or admin sends back an approved expense that no payment stands against (finance_send_back_expense(target_org, target_expense, seen_updated_at, reason)): it returns to pending, is corrected, and is approved again as any pending expense is. The reason is required (FIN69, also for a plain update of status), at most 200 characters, and shown on the pending row (finance_expense_totals.sent_back_at and sent_back_reason) until the expense is approved again or rejected (finance_expense_approvals.reason_closed_at): edited back to pending after a rejection, it no longer shows the old reason. A payment against it, paid or still to be paid, refuses it (FIN02); anyone else is FIN14. When no approved expense with that updated_at is found the function answers null, and the app reads whether it is gone, pending again or rejected.

The approval that leaves the books, by a send-back or by rejecting an approved expense, gets a reversal of what it posted, not of what the expense says now: key …:reverse, dated the Malaysian day it left (never before the approval it undoes), described "Sent back: " or "Rejected: " and the original's description. So an expense approved at RM 100 under Rent, sent back, and approved again at RM 80 under Utilities reverses RM 100 of Rent on the send-back day and posts RM 80 of Utilities on the new approval day. Before this file, rejecting an approved expense took its entry out of the books with no reversal. An expense that has been in the books is never deleted (FIN68): it is rejected instead. finance_expense_totals.in_books says which expenses those are, and the Expenses screen offers Delete only on the others. The audit trail logs "Sent back an expense" (event sent_back, with the reason) and "Rejected an approved expense". supabase/tests/Kira_expense_send_back_check.sql proves it.

The books lock (private.finance_period_lock, restated in the same file) judges an expense by the day its entry posts, not by its expense date alone. Both use one rule, private.finance_expense_posts_on. A first approval posts on the expense date, so it is refused (FIN63) when that date is on or before the lock date. A later approval posts on the later of the expense date and the Malaysian day it was approved, and a send-back or rejection posts its reversal on the later of the approval's day and today in Malaysia; each is refused only when that day is on or before the lock date. So with the books locked to yesterday, an expense dated last month can still be sent back, rejected or approved again: the old entry stays in the closed period and the new lines are dated today. With the lock at today, all three are refused. Moving an expense between pending and rejected has no entry, so the lock never refuses it.

Two things read live, not as posted. The description of an expense's lines ("Expense to <vendor>") takes the vendor's name as the expense has it now, so correcting the vendor of a sent-back expense renames the earlier approval's lines too; their amounts, category and dates do not change. And the Expenses screen's charts group expenses by expense_date, while an expense approved again after a send-back is in the books on its approval day: in a month's total the two can differ for an expense corrected in a later month. Expense reversals use the Malaysian day, while every void (invoices, credit notes, supplier bills and credit notes, payments, transfers, contras) is dated by the UTC date of voided_at.

An expense's amount and sst_amount cannot be NaN (finance_expenses_amount_not_nan, finance_expenses_sst_amount_not_nan, from 20261114100000_Kira_expense_amounts.sql): NaN is a numeric that passes > 0, and one saved expense of NaN would turn every total it is added into NaN. supabase/tests/Kira_expense_amounts_check.sql proves it.

Three columns of an expense are the database's to write (20261116120000_Kira_expense_stamp_and_category_writers.sql). updated_at is set to the database's clock by the trigger finance_expense_stamp on every insert and on every update that changes anything, whatever the caller sent; it is left alone by an update that changes nothing, by a session with nobody signed in (a migration, a seed) and by the foreign key that clears a deleted user. The trigger's name sorts after finance_expense_guard, so the guard answers first and is not replaced. created_by and created_at are closed to the authenticated role by column grants: an insert or update that names either is refused (42501), whatever value it carries, and a new expense takes the person signed in and the time from the columns' defaults. approved_by and approved_at stay granted and are the guard's (FIN14, FIN01). supabase/tests/Kira_expense_rules_check.sql proves all of it.

The expense form

/finance/expenses is writable for anyone who may edit. "New expense" opens a card (expense-form-card.tsx): date, category, vendor (a contact, or "Not a contact" and a typed name: exactly one), the amount with its SST inside it, the SST, notes. There is one button. Who is saving decides what happens, in the database: saved by an owner or admin the expense is approved in the same call, whoever entered it, so an owner who edits a member's pending expense approves it; saved by anyone else it waits as Pending approval. The form says which before the save, and saveExpense reads the status back after it. The status, the workspace and the two approval columns are never taken from input.

Who is offered what is expenseActions(row, canDecide), and canDecide is worked out once, on the server, in src/screens/finance/expenses.tsx (can(role, 'sign-off-finance'), the same people as the database's private.finance_may_decide):

ExpenseOwner, adminMember
PendingEdit, Approve, Reject, DeleteEdit, Delete
RejectedEdit (it is sent for approval again), Deletethe same
Approved, nothing paid, nothing held by a paymentMark paid, Rejectnothing
Approved with a payment against it, and something left to payMark paidnothing
Approved with payments that cover all of it; Paidnothingnothing

Approving and rejecting are checked by the expense's own guard (private.finance_expense_guard), not by the sign-off trigger, which is not on this table: a member's direct update answers FIN14 "only an owner or admin approves or rejects an expense". FIN01 means five things on this table and is worded by what was being done (locked, cannotApprove, cannotReject, cannotDelete); FIN02 is a rejection of an expense with a payment against it; 23503 on a delete is an expense that a voided payment still names, which is kept.

Approving and rejecting decide the expense the person was shown. The update is filtered by everything the row showed, as the view returned it: id, updated_at (the text the database gave, never reformatted), status, amount, sst_amount (compared as numbers, so 120 and 120.00 are the same), expense_date, category_id, the vendor (a contact by vendor_id, with the table's own vendor_name empty; a typed vendor by vendor_name, with vendor_id empty) and notes (is null when there are none). The database moves updated_at on every change a signed-in person makes (the stamp above), so it alone would now tell a changed expense from the one shown; the fields are compared as well and stay compared, because the comparison costs nothing, was proved field by field, and does not lean on a trigger being there. When no row matches, one more read tells what happened: the expense is gone ("That expense no longer exists."); it now holds another status than the row showed ("This expense has already been approved.", "...rejected." or "This expense is pending again."); or it was changed ("This expense was changed after you opened it. Check it again, then approve or reject it."). The screen refreshes and, for the last, the question closes and the row shows what it now holds. Not compared: a contact or a category renamed after the screen was drawn.

A money box (the amount and the SST are both text boxes) that holds a comma, a $ or RM in front is told "Enter the amount as a number, without commas or RM: 1200.50." (the SST box: "Enter the SST as a number, without commas or RM: 72.00."); one with three or more decimals is told "Enter the amount to two decimal places." (the SST box: "Enter the SST to two decimal places.") instead of being rounded.

An approved expense that is paid in part reads "Part paid", with a line under its status: what was paid, what payments not yet made hold, and what has no payment yet (expensePaymentNote). The view's display_status is still approved for it, and the status filter's Approved includes it. An approved expense with only a payment waiting shows the same line under an "Approved" chip.

Mark paid

An owner or admin marks an approved expense paid from its row ("Mark paid", offered while anything is left of it: expenseLeft, its amount less what is paid and less what payments not yet made hold). The card (expense-pay-card.tsx) asks for the account the money leaves, the date paid, the method, a reference and the amount, which starts as all that is left and may be lowered to pay part. Who is paid is not a field: it is the expense's vendor, read from the database when the payment is recorded.

markExpensePaid (expense-payment.ts) reads the expense as the card showed it (id, status = approved, updated_at), then calls finance_record_voucher(target_org, { account_id, txn_date, method, reference, status: 'posted', contact_id | party_name }, [{ expense_id, amount }]), then reads the voucher's number. So a payment made here is an ordinary payment voucher: one finance_transactions row, direction out, status posted, numbered PV-, with one allocation to the expense, no category of its own, and the expense's vendor as its payee. It shows on Payment Vouchers as kind expense and on Banking, it debits Accrued Expenses in the books, and it is voided as any voucher is: one change of status by an owner or admin, with Void on its row on Payment Vouchers. Voiding it makes the expense read Approved again (only posted money counts as paid), and that is how a wrong "Mark paid" is undone: Expenses itself has no undo, and the Mark paid card says so ("To undo a wrong one, void it on Payment Vouchers.").

Paying is a sign-off: the action refuses anyone who may not sign off before anything is read, and the database refuses them on the voucher (FIN14, with nothing left behind). The database also has the last word on the amount (FIN05: more than is left, counting payments not made yet) and on the expense (FIN04: not approved; FIN06: not there). When the first read finds nothing, one more read tells why: "That expense no longer exists.", "This expense is not approved any more, so it cannot be paid." or "This expense was changed after you opened it. Check it again, then mark it paid." An amount of NaN is refused by the payment's own check (finance_transactions_amount_number_check); the schema refuses it first. Not checked by the database: that the account is active (the card offers active accounts only).

For the Receipts Inbox (stage 7d): ExpenseFormCard takes initial (fields to start a new form with) and actions.save (whatever saves it), so "Create expense" opens the same card pre-filled and saves through finance_receipt_to_expense; saveExpenseInput without its id is what that function is given. That is built: see "Matching, on the screen" under Receipts Inbox. Attaching starts from the receipt, in the inbox; an expense's row shows a paperclip for each receipt tied to it and has no "Attach receipt" item of its own.

The view finance_expense_totals adds the category's and the vendor's name, paid (posted money out allocated to the expense), held (what payments not yet posted hold of it), method and paid_on (of the latest posted payment) and display_status: pending, rejected, approved, or paid once paid covers amount. Paid is never stored.

Money out pays an approved expense, and everything standing against it never exceeds its amount; the expense row is locked while that is checked. Paying an approved expense needs no second approval: the expense was the approval.

The Categories card

"Categories", beside "New expense" on /finance/expenses, opens a card (categories-card.tsx) for an owner or admin only: the loader hands writer.categoryAdmin (every category, and the three actions) to nobody else, so a member, a viewer and the demo workspace have no button and are sent no list. It is one of the things the screen keeps open one at a time: while it is open, "New expense" and every row menu wait, and it is closed with "Done".

The card lists both kinds, expense and income, each kind's active categories by name and then its archived ones. Income categories are managed here too: Cash Receipts only picks one. A row shows the name, "Active" or "Archived", and, on the two categories the books lean on, "Invoices post here" or "Bills default here". Those two are found by their mark (finance_ledger_accounts.default_for, sales and purchases), not by their names, so they can be renamed.

WhatHowWhat the database does with it
AddName and kind, in the form at the top. The kind is chosen onceOne insert. A trigger makes the category's account in the Chart of Accounts (4000 and up for income, 6000 and up for an expense), named after it, or "Name (2)" when another account has that name
RenameAsked in the row, in a boxOne update of name. Everything filed under the category names it by id, so every expense, bill, voucher and receipt, posted ones included, shows the new name. Its account in the Chart of Accounts is renamed too, unless someone renamed that account there
ArchiveAsked in the row. Not offered on the two marked categories, and refused for them by setCategoryActive, which reads the mark firstOne update of active. The category leaves the pickers of new expenses, bills and vouchers (or receipts); what is filed under it keeps it and goes on posting to its account; the account is archived with it, so a manual journal can no longer use it (FIN24)
RestoreFrom the row's menu, at onceOne update of active; the account is restored with it

Nothing deletes a category. The database would refuse a delete of one that an expense, a payment or a bill names (23503: the three foreign keys have no delete rule) and of the two marked ones (FIN25), and would let an unused one go with its account; the app archives instead, so no sentence for either code is needed.

A name is unique within its kind, capitals aside, archived categories included. The card refuses a name it can see is taken before anything is sent, and says which case it is ("There is already an expense category called Rent."; for an archived one, to restore it instead); the database's own refusal (23505), met when two people add the same name at once, or when JavaScript and Postgres lower-case a name differently, reads "Another category of the same kind already has that name. …".

An update by someone who may not change categories raises nothing in the database: it touches no row. renameCategory and setCategoryActive therefore read the category when no row came back, and answer "Only an owner or admin can add or change categories. Ask one of them to do this." when it is still there and "That category no longer exists." when it is not. Through the app a member never gets that far: the server action tells them first.

tests/Kira-categories.test.ts runs all of this against the hosted database, as an owner and as a member.

Approval

Decided by the owner: owners and admins approve expenses and payment vouchers. In the database that is private.is_org_admin, asked by the guards themselves, so no caller gets round it.

  • An expense is approved or rejected by an owner or admin only (FIN14).
  • A payment that is pending_approval cannot be changed, and neither can its split. An owner or admin approves it, which makes it scheduled, or rejects it; nothing else changes in that update, and the guard writes approved_by and approved_at. Anyone who may write can delete it.
  • A rejected payment is final: it can be deleted, and nothing else.
  • Money out that settles no document, which is the part of a payment out above its split, needs an owner or admin when the payment becomes scheduled or posted, unless one of them approved it already. Recorded by an owner or admin it is approved in that step. Recorded by anyone else through finance_record_voucher it waits as pending_approval, whatever was asked.
  • A scheduled payment that was approved is paid as it was approved: only its status, number and date change when it is marked paid, by anyone who may write. Its amount, its direction, its account, its category, who is paid and every other column are frozen, and so is its split (FIN09): what is paid is what was approved. It can still be deleted.
  • A scheduled payment out that nobody had to approve (a payment for a supplier's bills, a pending refund, a voucher for approved expenses: money out that is exactly its split) is held still too, since 20261117090000_Kira_payment_held_still: see "A payment that has left draft is held still" below. Before that file anyone who may write could edit it where it stood or send it back to draft, and an owner or admin who raised its amount above its split was written down as approving the rest; that path is now closed to every signed-in person (FIN28). An allocation of such a payment cannot be deleted, or lowered so that the payment is more than its split (FIN14), or changed in any other way (FIN28, or the older guard's FIN03 or FIN09 where it answers first): the payment is deleted and recorded again. So no signed-in person can leave a scheduled payment out that is neither approved by an owner or admin of its own workspace nor settling documents for its whole amount. Two kinds of row are outside that sentence: one that was already scheduled above its split before these rules were applied, and one whose split a session with no signed-in user took away. Both are still measured against their split when they are paid, and an editor cannot pay them (FIN14).
  • A payment, an allocation and an expense never change workspace. An update that changes org_id is refused before anything else is looked at, with the code that table gives for "this cannot be changed": FIN09 for a payment and for an allocation, FIN01 for an expense. org_id is an ordinary column, and a row's own references (its account, contact and category) can be re-pointed in the same statement, so the foreign keys do not stop a move. Without the rule, someone who is an editor here and owns another workspace could record a payment there, move it here and pay it in one update, approved by the rules of the workspace it came from. As a second line, every place a guard asks whether the caller may approve asks it of the workspace the row is in before the write and of the one it is in after it.

A payment that has left draft is held still (20261117090000_Kira_payment_held_still). Once a payment is not a draft (pending_approval, scheduled, posted, rejected, void), for every signed-in person and by whatever path:

  • only its status moves. direction, account_id, txn_date, amount, method, reference, contact_id, party_name, category_id and notes stay as they are, with or without a change of status. The one exception is txn_date in the move from scheduled to posted, which is how finance_mark_payment_paid writes the day it was paid;
  • it does not go back to draft, whoever asks: an owner and an admin are held as a member is. No screen ever offered it; the way to change a payment that is to be paid is to delete it and record it again;
  • its split is not changed: no row of finance_allocations is added to it, taken from it, moved onto or off it, or edited.

FIN28, from private.finance_transaction_still_guard (trigger finance_transaction_still, BEFORE UPDATE with a WHEN clause that names those columns) and private.finance_allocation_still_guard (trigger finance_allocation_still, BEFORE INSERT, UPDATE and DELETE). Both run as their owner, change nothing, and step aside when nobody is signed in; the split's also steps aside when its payment is being deleted with it and when the workspace is. Posted, void, waiting, rejected and approved payments were held already and still answer FIN09 or FIN14 first, because each new trigger sorts after the guards that were there (finance_transaction_still after finance_transaction_sign_off, finance_allocation_still after finance_allocation_guard): the one payment FIN28 is new for is the scheduled payment nobody had to approve. finance_transaction_still sorts before finance_use_account_update, so an edit it refuses never takes the lock on the account that an archive waits for. A change of nothing but updated_at is let through by both new triggers (the older guards still refuse it on a waiting, rejected, paid or void payment and on the split of every payment but a draft and a scheduled one nobody approved). finance_allocation_guard runs as the caller and fires first, so someone who cannot write the workspace is answered FIN04 there and never reaches finance_allocation_still_guard; if that guard is ever made a definer or renamed to sort later, this function must ask private.is_org_writer(new.org_id) itself before it reads the payment. What the database itself writes on a payment that is not a draft (number, approved_by, approved_at, voided_at, and created_by cleared when a user is deleted) is in neither list and never calls the function.

Nothing the app does is refused: every function that records money inserts a draft, writes its split while it is a draft and then moves its status once; approving, rejecting, marking paid and voiding change status and nothing else; deleting is not an update. result.ts names the code and a sentence for it (FIN_HELD_STILL, HELD_STILL: "This is waiting to be paid, so it can no longer be changed. Delete it and record it again."); no map lists it, because no write in src/lib/finance can meet it, and tests/finance-payment-held-still.test.ts fails if one starts to edit a payment with a direct .from('finance_transactions').update(…), and pins how many .update( calls each file that names the table makes. supabase/tests/Kira_payment_held_still_check.sql goes through every column, role and status.

Who writes approved_by and approved_at. The guards do, and nobody else. On a payment they are written in three cases: when an owner or admin approves a waiting payment, when one of them rejects a payment, and when one of them schedules or pays money out that is above its split. (The guard still holds a fourth, for a scheduled payment that nobody approved and that is changed so that it is then money out above its split. Since 20261117090000 no signed-in person can make that change: FIN28.) On an expense they are written when an owner or admin approves or rejects it. In each case, when the caller is a signed-in person, the guard writes the caller's own id and the time of the update; for a session with no signed-in user it leaves approved_by as it is and keeps an approved_at that was given, writing the time only when none was. An update from a signed-in person that changes either column by hand is refused, whoever sends it and whatever else it changes. The code is FIN14 unless an earlier rule of the guard refuses the update first, as FIN09 does on a posted or void payment and FIN01 on an approved or rejected expense whose status is left as it was. So an editor cannot fill them in on a draft, an owner cannot back-date an approval, and nobody approves in another person's name. An INSERT that carries them has them cleared, on a payment and on an expense. This matters because the rules above ask "was this approved" by reading approved_at; if a caller could write it, the payment's own maker could approve it.

The two columns are cleared in two other ways. The first is on an expense: when a rejected expense goes back to pending, the guard clears both. The second is a deleted user. approved_by, like created_by, points at a user, and its foreign key sets it to null when that user is deleted. The guards let that update through whatever the status of the row, in three cases only. The first is an update made from inside another trigger, which is what the guard tests (pg_trigger_depth() > 1): the foreign key clears a deleted user from its own trigger, and today no other trigger updates these tables, so in effect it is the foreign key. If a later trigger ever updates a payment or an expense, it inherits this and nothing more: it could blank these two columns. The second is a session with no signed-in user. The third is an update that changes nothing at all. A signed-in person who blanks approved_by or created_by by hand meets the ordinary rules: FIN09 on a posted or void payment, FIN14 on an approved or waiting one, FIN01 on an approved or rejected expense. approved_at is never cleared by the foreign key, so a row keeps the fact and the time of its approval after its approver is gone. An expense loses them in the first way, when it was rejected and goes back to pending.

What approval does not reach yet. A payment to a supplier recorded on Payments Out and a refund recorded on Refunds are exactly their split, so the rule above does not touch them: finance_record_payment_out and finance_record_refund are unchanged, and an editor's payment or refund is posted or scheduled at once, as before. Whether those two should wait for approval too is the owner's decision; it changes two live screens and has its own part of stage 5.

A session with no signed-in user is not asked for a role (private.finance_may_decide). That is a migration or the SQL editor, which could disable the guards anyway, and it is how the demo seed approves its rows, with approved_by left empty and approved_at given a date of the seed's choosing. Three roles can reach the database through the API with no user: anon, which has no rights on any finance table; authenticated with a token that names no user, which passes no row-level policy and so reaches no row; and service_role, which this project gives no rights on these tables (it has no default privileges here, and no migration grants it any). If service_role were ever granted them, whoever holds the service key could approve, as they could already read and write every table past row-level security; nothing in the app writes finance tables with that key.

Banking

Banking shows the workspace's accounts and every movement of money on them. It reads two views. finance_account_balances has one row per account, archived ones included: balance is the opening balance, plus every posted movement in, less every posted movement out, whatever its date (opening_date is a label and takes no part in the sum); to_be_paid is scheduled money out; last_import_at and unmatched_lines come from the account's statements. It has the bank's name and no account number: finance_accounts.account_no is not readable by a signed-in person through the table (20261030123000_finance_write_guards), the view runs as its reader, and Banking's cards show no account number; the Accounts table shows the last four characters, and only to someone who may write. A screen that needs one calls finance_account_numbers(target_org), which answers only to someone who may write in the workspace. (The view first had the column and could be read by nobody; 20261031090300_Kira_banking_write_guards_fit made it again without.) finance_bank_transactions has one row per finance_transactions row, either direction, any status: kind is what the cash book calls it (invoice, income, bill, refund, expense, other) or transfer; other_account_name is the account at the other end of a transfer; reconciled says a statement line is matched to it. The screen lists posted rows only, and works out the running balance in the browser (withRunningBalance in banking-views.ts): per account, from the opening balance, in the order date, time recorded, id. The newest row of an account therefore carries the balance the view gives; the table's "Account balance" column is the balance of that row's account after it. The cards show exact amounts, to the sen, as a bank statement does. The table's count line is left out when no posted movement exists at all.

banking.ts only reads. Accounts can be added, edited, archived and restored (see "Accounts" below), and a transfer recorded and voided (see "The transfer form"); statements get their forms later. Every action that records, pays, voids or deletes money refreshes /finance/banking; an action added later must do the same.

A transfer between accounts is two finance_transactions rows that share a transfer_id: money out of one account and the same money in to another, on one date, with one TR- number, no payee, no category and no split. Neither side is a receipt or a voucher: finance_cash_receipts and finance_payment_vouchers leave rows with a transfer_id out, and a transfer moves the total of the accounts nowhere. finance_record_transfer makes both sides and posts them at once; finance_void_transfer voids both.

Only an owner or admin can record a transfer. The side that leaves an account is money out that settles no document, and the guard on payments holds that to an owner or admin (see Approval): the function says so first (FIN14). The guard writes the owner or admin down as approving the money out, and both sides are recorded as made by them (created_by).

finance_record_transfer runs as its owner (security definer), like the six functions that number a payment: a payment's number is a column no signed-in person writes, so only a function that runs as the owner can post. It begins by asking whether the caller may write in the workspace, which row-level security no longer asks for it: a viewer, a session that still owes a second factor and anyone outside the workspace get 42501, whatever they sent. A writer who is not an owner or admin gets FIN16 for a transfer that is wrong in itself and FIN14 otherwise. Every read and write in it names the workspace, so it cannot reach another's accounts. For the same reason two sides inserted by hand cannot be posted by hand, by anyone signed in: an update that names number is 42501, and one that posts without a number is 23514. The function is the only way a transfer is posted.

Only an owner or admin can void a transfer. Voiding any posted payment is a sign-off since 20261030123000_finance_write_guards, and the trigger that asks for one answers FIN14 to anyone else, through finance_void_transfer or by a plain update. finance_void_transfer still runs as the caller: it writes only status, and row-level security decides which transfers it can find (FIN11 for a viewer or another workspace; FIN11 and FIN09 are answered before FIN14).

The pair is kept whole by triggers that run after each statement on finance_transactions (private.finance_transfer_pair_check). Once any side of a transfer is posted or void there must be exactly two sides, one in and one out, on different accounts, with the same amount, date, status and number, naming nobody, carrying no category and split across nothing, and both posted or both void; anything else is FIN16. So one side cannot be posted, voided or changed alone, and both are posted or voided by one UPDATE. Sides that are all still drafts are not looked at: a draft moves no money. A payment never joins or leaves a transfer: an UPDATE that changes transfer_id is FIN16. A transfer has at most one side in each direction (a unique index, 23505), and a side carries a number only once it is posted or void (23514), so a draft nobody sees cannot sit on the next TR- number. The pair check is the one finance trigger function that runs as its owner (security definer): when a user is deleted the foreign keys clear created_by and approved_by, and Postgres runs that update's statement triggers at the end of the delete as whoever issued it, which for Supabase Auth is a role with no rights on public tables.

An amount is a number. 'NaN' is a valid numeric in Postgres, is greater than every number and would turn every balance it is added into to NaN, so finance_transactions.amount and both amounts of a statement line refuse it (23514), whichever function or direct write it comes through; finance_record_transfer and finance_import_statement refuse it first (FIN16, FIN17).

A bank charge and interest are not special. A charge is money out for an expense category, recorded by finance_record_voucher: from someone who is not an owner or admin it waits for approval like any other, and paying it is an owner's or admin's. Interest is money in for an income category, a cash receipt: anyone who may write saves it as a draft, it needs no approval, and posting it is a sign-off like posting any receipt (FIN14 for anyone who is not an owner or admin). A statement line is something a person imported, so matching a line approves nothing, and a line cannot be matched to a payment that is not posted.

Statements. finance_import_statement stores one file's lines for one account, at most 1,000, each unmatched. A line is what the bank said: once it is in, only its match changes (FIN17). Matching is a plain update of transaction_id, and unmatching sets it back to null; anyone who may write can do both. The guard on lines (private.finance_statement_line_guard) holds a match to a posted payment of the line's account, in its direction, for its amount. How far apart the two dates may be is not the database's business: the three days in the design limit what is proposed. A payment is matched by at most one line (23505). A matched line, or an import that has one, is unmatched before it is deleted (FIN17). When a payment is voided, a line matched to it goes back to unmatched (private.finance_transaction_unmatch). So a member can match and unmatch lines and import a statement (finance_import_statement runs as the caller; the two statement tables are not among the ones the write guards closed), and cannot void the payment a line is matched to. A cash account can be given a statement, but the screen shows a dash instead of a reconciliation status for a cash movement and for a movement on a bank account that has never had a statement imported, and leaves both out of the gauge. While no bank account has had a statement imported, the Reconciliation card says "No statement imported yet." and shows no count.

Accounts

Accounts are managed on Banking: "Add account" in the header, and Edit, Archive and Restore in the Accounts table, which lists every account, archived ones included. bank-accounts.ts has one schema and one function per write (createBankAccount, updateBankAccount, setBankAccountActive) and listBankAccountNumbers. A change always writes the bank name and number: one that is left out is cleared, and the form always sends both. bank-account-form.ts has the form's rules and what the table shows. The three actions refresh BANK_ACCOUNT_PATHS: Banking, every screen whose forms offer an account or whose rows name one, and the three ledger screens (Chart of Accounts, Journals and Reports), since each account has a ledger account and an opening balance is an entry in the books.

The ledger account. Every bank or cash account has an account in the Chart of Accounts (finance_ledger_accounts.bank_account_id), made with it by the ledger's own triggers. Renaming the bank account renames the ledger account (unless that one was renamed in the Chart of Accounts) and the description of the opening entry; archiving and restoring it archives and restores the ledger account. An opening balance is an entry in the books dated opening_date (debit the account, credit Opening Balance Equity; reversed for an overdraft). The sync runs one way: archiving or renaming the ledger account in the Chart of Accounts does not touch the bank account. The form says so on edit ("Renaming it also renames its account in the Chart of Accounts, unless that one was renamed there.") and the archive question says it for archiving. After a save that set or changed an opening balance, Banking says that Total cash and the Chart of Accounts changed with it.

What each role sees and is answered (FIN14 is from the guard, a BEFORE trigger, so it answers before the row policy does):

Owner, adminMemberViewer, demo
Header and row menuAdd account; Edit, Archive, Restorethe samenone
Formopening balance and "As at" boxes; account number wholeno opening balance boxes (it reads the balance as text on edit); account number wholeno form
Insert with an opening balancetakenFIN14FIN14 (never reached through the screen: the action refuses first)
Change of balance or date, delete of an account holding onetakenFIN14refused by the row policy (42501, or no row changed)
Insert without one, rename, archive, restoretakentakenrefused by the row policy (42501, or no row changed)
Account numbersreadreadnot read

An outsider, and a member who owes a second factor, are answered as a viewer: an insert with an opening balance is FIN14, anything else is 42501 or changes no row. An owner at aal1 with a verified factor also gets FIN14 on an insert with an opening balance, which is why the sentence for it does not say the person is not an owner.

What the database holds an account to (20261114110000_Kira_bank_account_rules):

  • An opening balance is an owner's or admin's. Adding an account with one, changing an account's opening balance or opening date, and deleting an account that has one are refused for any other writer (FIN14, from private.finance_bank_account_guard, which asks private.finance_may_sign_off). A member's form has no opening balance box and sends neither column; the server action refuses an input that carries one before anything is written. An opening balance is a number (23514 for NaN).
  • Bank or cash is chosen once. kind, org_id, id and created_at are not columns a signed-in person can update (42501); id and created_at cannot be inserted either.
  • An account is archived, not deleted, and not while a payment on it, money out or money in, is to be paid (scheduled) or waits for approval (pending_approval): FIN30. Drafts and posted payments do not hold it. The archive question warns only about money out that is to be paid (to_be_paid); the refusal covers the rest. An archived account keeps its movements and its balance, stays in Total cash, is no longer offered by any payment form, and cannot take or send a transfer. The screen offers no Delete; the database still lets an unused account be deleted, and refuses any other with 23503.
  • The account number is written with the row and read back only through finance_account_numbers, by someone who may write. The loader reads the numbers only for them; the table shows the last four characters of a number of eight or more (spaces and dashes not counted; a shorter one shows nothing), and the form the whole number. A viewer sees the bank's name.

The guard steps aside when nobody is signed in (a migration, the demo seeds) and, on a delete, when the workspace itself is being deleted.

No money on an archived account (20261116110000_Kira_archived_account_rule). The database refuses it for every signed-in person, by whatever path (FIN37, from private.finance_transaction_account_guard on two triggers of finance_transactions, finance_use_account_insert and finance_use_account_update):

  • a payment is not started on an archived account, not even a draft;
  • a payment is not moved onto one, whatever its status;
  • a payment whose account is archived is not sent for approval, approved, scheduled, posted or marked paid. A draft that was on the account when it was archived is still edited there, and is posted once the account is restored or the draft is pointed at an active account.

Voiding, rejecting and deleting go on working on an archived account, in both directions and for both sides of a transfer. The two triggers sort after every guard that was there (finance_transaction_guard, finance_transaction_insert_guard, finance_transaction_sign_off, finance_transfer_id_guard), so a single statement those refuse keeps its code, and before finance_void_stamp, which must stay the last BEFORE trigger. A member posting a draft, approving or marking paid on an archived account is therefore still answered FIN14, not FIN37. A function that inserts a draft before it posts is the exception: a member recording a payment in, a refund or a paid-now payment out on an archived account is answered FIN37, because the draft is refused before the sign-off rule is reached, where before this rule they were answered FIN14. A BEFORE trigger answers before the row policy: a viewer or an outsider who names an archived account of a workspace in an insert gets FIN37 where an active account gives 42501; nothing is written either way.

The same trigger locks the account's row (for share) until the payment's transaction ends, and an archive is an update of that row, so the two wait for each other: an archive that meets a payment being scheduled is refused (FIN30) once that payment is in, and a payment that meets an archive under way is refused (FIN37) once the account is archived. An archived account therefore never holds a payment that is scheduled or waits for approval. Renaming an account waits the same few milliseconds for a payment in flight. One statement that archives two accounts while a transfer between those two is being recorded can deadlock; Postgres ends one of them (40P01) and a retry works. The function runs as its owner so that it can take the lock whoever is writing; it steps aside when nobody is signed in.

money.ts, refunds.ts and cash-receipts.ts word FIN37 with one sentence from result.ts (ACCOUNT_ARCHIVED): "The account this is on has been archived. Restore it on Banking, or use another account, then try again." A person meets it when the account was archived after their form was opened, or when they post, from Cash Receipts, a draft whose account was archived after the draft was saved. A stage that adds a write of finance_transactions adds the code to its own map.

service_role holds no privilege on any finance table or view since the same migration. On hosted it had been left TRUNCATE, REFERENCES, TRIGGER and MAINTAIN on the 32 whose migrations revoked "from anon, authenticated" only; it never held a read or a write on them. A migration that adds a finance table revokes "from anon, authenticated, service_role".

On finance_statement_imports, created_by, id and created_at are the database's to write since the same migration (42501): an import is recorded as made by the person signed in, and "Last import" cannot be moved.

The transfer form

"Transfer" in Banking's header opens a form at the top of the page: From and To (two different active accounts, each shown with its balance; To leaves out the account chosen in From), an amount, a date (the person's own date when the form opens), a reference and notes. transfers.ts has one schema and one function per write: recordTransfer calls finance_record_transfer and then reads the TR- number back from one side (a transfer whose number could not be read back is still a success, without a number); voidTransfer calls finance_void_transfer. transfer-form.ts has the form's rules and every sentence the screen says. Nothing in the database changed for this: both functions are used as stage 6a left them.

Recording and voiding are an owner's or admin's. The loader hands the two actions (writer.transfers) to nobody else, so a member sees no "Transfer" button and no menu in the Transactions table; the actions refuse a member before anything is read ("Only an owner or admin can record or void a transfer between accounts. Ask one of them to do this."), and the database answers FIN14. The button is also absent while the workspace has fewer than two active accounts.

Owner, adminMemberViewer, demo
HeaderNew receipt, Transfer, Add accountNew receipt, Add accountnone
Transactions tablea menu on each side of a transfer: Void transferno Actions columnno Actions column
recordTransferAction, voidTransferActionrunrefused before anything is read"You do not have permission to make changes here."
finance_record_transferrecordedFIN16 for what is wrong with it first, then FIN1442501
finance_void_transfervoidedFIN14FIN11 (the rows are not theirs to see or lock)

FIN16 is three messages under one code, so the schema refuses the two a form can cause first (the same account twice; an amount that is not above 0) and FIN16 from the database is worded for what is left: "One of those accounts has been archived or no longer exists. Choose another, then try again." The archived-account rule on finance_transactions raises FIN37 when an account is archived in the moment between finance_record_transfer's own question (which answers FIN16) and the write, and transfers.ts gives it the same sentence as FIN16. FIN09 is "This transfer has already been voided." and FIN11 "That transfer no longer exists." Both leave the row, which the refresh after a refusal takes out of the table (it lists posted movements only), so a refused void is said at the top of the page in a note titled "Transfer not voided", and its question closes, as the refusal arrives (voidQuestionGone). The sentence is not kept in the row: a search or a filter that hid the row later would raise the note under the control being used.

A transfer for more than the From account holds is taken; the form says what it leaves the account at before it is recorded ("Main Bank holds RM 300.00. This transfer leaves it overdrawn by RM 200.00."). A transfer is not edited: it is voided and recorded again. "Void transfer" is asked in the row of either side and says what it does, including that a statement line matched to either side goes back to unmatched; both sides then leave the table, which lists posted movements only, and a note says so. A voided transfer keeps its number.

Both actions refresh TRANSFER_PATHS: Banking and the three ledger screens. In the books a posted transfer is one entry (debit the account it reached, credit the one it left) and a voided one is that entry and its reversal; Reports leaves transfers out of money in and money out, since nothing entered or left the business. A transfer is on no other screen.

Banking keeps one thing open at a time: the account form, the transfer form, an archive question or a void question. The header's buttons wait while any of them is open or a void is under way, a menu choice replaces a question with what it opens, and a void question whose row has left the table (a search or filter hid it, or the refresh took it away) is closed for good: it does not come back when the filter is cleared. A void that finishes closes only its own question. Who is given the transfer actions is bankingAccess in banking-access.ts: an owner or admin, never the demo workspace.

Receipts Inbox

A receipt is a photo or a PDF in the private storage bucket finance-receipts, and one row of finance_receipts that says what was read from it and what it backs. The screen uploads, opens, reviews, matches and deletes, and on a person's press sends one receipt to a model to be read ("Reading with AI", below). The migration creates new objects only: it replaces nothing the write guards or Banking defined, and no function here posts, numbers, pays or voids.

What a signed-in person may write. As on payments since the write guards, by column-level grants: on insert org_id, file_path and file_name; on update vendor, receipt_date, amount, sst_amount, status, expense_id, bill_id, extraction_error, extracted_at and updated_at. Everything else (mime_type, size_bytes, uploaded_by, created_at, id, and after the insert the workspace, the path and the name) is refused with 42501 before a trigger runs, and written by the guard. service_role has no right on the table.

The bucket. JPEG, PNG, PDF, HEIC and HEIF, 10 MB a file. An object's name is <workspace id>/<random uuid>.<jpg|png|pdf|heic|heif>, lower case, and nothing else. (The app is to turn a HEIC or HEIF into a JPEG in the browser whenever it can, so an original is stored only when that fails.) Three rules on storage.objects, each limited to this bucket:

  • finance_receipt_files_select: a member of the workspace named by the first folder reads (signs a link for) the file.
  • finance_receipt_files_insert: whoever may write in that workspace uploads, when the name has exactly that shape, they are signed up (not anonymous), the workspace is not the shared demo, and there is room (private.finance_receipt_room: under 1 GB for the workspace and under 20 GB for the bucket).
  • finance_receipt_files_mfa_required, restrictive: a session that owes a second factor gets nothing.

There is no update and no delete rule: nobody signed in replaces or removes a file. The server removes files, with the service client, and only the ones the database has let go.

Recording. finance_register_receipt inserts the row, and the guard (private.finance_receipt_guard) refuses it unless the object is in the bucket, uploaded in the last 30 minutes, not already a receipt, not waiting to be removed, and of the type its extension names (FIN24). That the path is under the row's own workspace is not the guard's check but the table's (finance_receipts_file_path_check, 23514). mime_type, size_bytes and uploaded_by are the database's to write. Someone who may not write in the workspace is answered 42501 before anything is said about a file.

After that the file, its name, type, size, uploader, upload time and workspace never change: 42501 from the grants for a signed-in person, and FIN23 from the guard for any write that gets past them. vendor, receipt_date, amount and sst_amount can be written at any time by anyone who may edit: finance_set_receipt_extraction is for a model's reading, writes only while the receipt is in review, and stamps extracted_at when the reading worked (a failed one clears it): the screen says "Read by AI" from that stamp alone, and only until it leaves review; finance_review_receipt is a person's and moves a receipt in review to unmatched.

Reading with AI. No migration: the function and the columns above are the whole of what the database has for it. "Read with AI" is in the row menu of a receipt that is in review and has a file, for anyone who may edit (never a viewer, never the demo). It opens a card that says what will be sent, to whom, and on whose key and credit; nothing is sent until "Send to be read" is pressed, and nothing else in the app calls readReceiptAction: no upload, page load, effect or scheduled run reads a receipt. The action, in this order, each refusal costing nothing and sending nothing:

  1. refuses the demo and anyone who may not edit, before a client is made;
  2. finds the model id in the environment: Kavio_MODEL_RECEIPTS, else Kavio_MODEL_WORKER, else "Reading receipts is not set up on this server." No model id is written in code for this feature, and the fallback ids of src/lib/ai/provider.ts are not used. The model must take images and PDFs;
  3. reads the receipt as the caller: gone, no longer in review, no file, or a stored HEIC or HEIF (never sent) each stop here;
  4. asks as the caller whether the workspace has an AI key (org_ai_key_status), then opens the sealed key on the server for the session's own workspace (readWorkspaceKey). The workspace's own key or nothing: the platform's key (OPENROUTER_API_KEY) and the free chat questions (consume_free_question) are never used for a receipt, so a workspace with no key is told to add one whether or not the person has free questions left;
  5. signs a one-minute link as the caller, fetches the whole file (10 MB at most, counted as it arrives) and checks its first bytes against the receipt's type;
  6. reads the workspace's active expense categories as the caller. Their names, the first sixty, go to the model as a numbered list; no id of anything is sent. If they cannot be read the receipt is read without a list;
  7. counts one turn of the person's daily 200 on the workspace's key (consume_ai_quota, shared with chat), last, so no refusal above uses a turn;
  8. calls the model once (generateText with Output.object): the instructions, one sentence, the category list and the file as a file part of the user's message. No tool, no history, no retry, 45 seconds at most. One log line with the call's size (agent=receipt-reader) and nothing of the receipt;
  9. checks the answer field by field (normalizeReading): a vendor cleaned and at most 200 characters; a real date from 2000 on and not after tomorrow; an amount above 0, to the sen, within the column; SST no more than the amount; a category number that is a whole number within the list. A field that fails is dropped; an answer with no vendor, date or amount is a failed reading. Anything else the model says is discarded;
  10. writes as the caller with finance_set_receipt_extraction: a reading's four details (stamping extracted_at), or for a failed reading the fixed sentence "The receipt could not be read." with the four values the receipt held sent back, because the function sets all four from what it is given and an error alone would clear what a person had typed. When the provider refuses the key (401) or says there is no credit (402), or cannot do the reading just now (busy, down, a model that takes no file, no connection, too slow), nothing is written: the receipt was not found unreadable, and the turn already counted is not given back, as a chat turn that fails is not. A model answer that is not the five fields, or has none of vendor, date and amount, is stored as a failed reading, except on a receipt a model has already read: there a failed new reading writes nothing, so the earlier figures, their "Read by AI" mark and the caution above the forms stay, and the person is told the earlier reading is still shown. If the function answers false (someone reviewed or matched the receipt during the call) the reading is dropped.

What was read is a suggestion. After a reading the review card opens on the receipt, with "Read by AI. Check the figures before you use them." above its boxes, and the receipt stays in "To review" until a person saves the review or makes an expense or a bill from it. The suggested category is not stored anywhere: the action answers with it (the server turns the model's number back into a category from its own list), the screen keeps it until the page is reloaded, the review card names it, and "Create expense" starts with it chosen and says "the category was suggested by AI". A draft bill is not given it. A receipt's own words cannot steer anything: the model has no tool, sees nothing of the workspace but category names, and only the five checked fields of its answer are used, each drawn as text.

Pressing "Read with AI" on a receipt that was sent before says so, and that it uses credit again, before it is sent again; a new reading replaces the old one while the receipt is in review.

Matching. A receipt names one expense or one bill of its workspace, or neither, and its status is matched exactly while it names one (a check constraint; the guard sets the status as the receipt is tied and untied). A document can have several receipts. finance_receipt_to_expense makes a new expense through finance_save_expense and ties the receipt to it in one transaction, so the expense waits for approval or is approved as any expense would be. finance_receipt_to_bill does the same through finance_save_bill and leaves a draft bill: nothing posts it. Any writer may make the draft; posting it is a sign-off (an owner or admin). A void bill takes no receipt (FIN25). Untying a receipt from an approved expense or a posted bill is for an owner or admin (private.finance_may_decide, FIN14); from a pending or rejected expense or a draft bill, for anyone who may edit. A matched receipt is untied before it is deleted (FIN23).

Matching, on the screen. receipt-match.ts holds the writes and the rules, receipt-match-cards.tsx the cards, and the inbox's row menu offers them. A receipt tied to nothing (in review or unmatched) offers "Create expense", "Create draft bill" and "Attach to expense or bill"; a matched one offers "Untie". None of this needed a migration.

Owner, adminMemberViewer, demo
Create expense from a receiptYes: approved in the same callYes: waits as Pending approvalNo menu; the action answers "You do not have permission to make changes here."
Create draft bill from a receiptYes, a draftYes, a draft; posting it on Supplier Bills is refused (FIN14)No
Attach to a pending or approved expense, or a draft or posted billYesYes; told that an approved expense or a posted bill keeps the receiptNo
Untie from a pending or rejected expense, or a draft or void billYesYesNo
Untie from an approved expense or a posted billYesNot offered; one line above the table says who can. A direct call answers FIN14: "Only an owner or admin can untie a receipt from an approved expense or a posted bill. Ask one of them to do this."No
Download a receipt from its expense's or bill's row (the paperclip)YesYesA viewer yes; the demo's receipts have no file
  • "Create expense" opens the expense form (ExpenseFormCard) started from the receipt (expenseFormFromReceipt: its date, or the person's own today; its vendor as a typed name, or as the contact when exactly one active contact has that very name; its amount and SST) and saves through finance_receipt_to_expense. The schema is the expense's own (saveExpenseInput), so NaN, an amount of 0 and SST over the amount are refused in the expense's sentences before the database is asked; an id is never sent.
  • "Create draft bill" opens the bill form (BillFormCard, with initial and draftOnly) with one line: unit price the receipt's amount, SST rate 0 (a receipt's amount has its SST inside it, a bill line's goes on top), the supplier chosen when exactly one active supplier has the vendor's name. It saves through finance_receipt_to_bill. Nothing made from a receipt is posted: the form has no second button (draftOnly on DocumentFormCard), and neither receipt-match.ts nor the actions file names a function that posts (a unit test reads both files).
  • "Attach to expense or bill" lists expenses that are pending or approved and bills that are not void (attachCandidates), those of the receipt's own amount first. A rejected expense is not offered though the database would take it.
  • A receipt in review can be matched without being reviewed first: the form is the review. The receipt keeps its own four details as they were.
  • Under "Matched to", a second line says how the document stands (matchedDocuments: Pending approval, Approved, Paid, Rejected, Draft, Posted, Posted, overdue, Void). For that the inbox's loader reads the workspace's expenses and bills for everyone, once the inbox holds a receipt.
  • Expenses and Supplier Bills each make one more read (listAttachedReceipts) and draw AttachedReceipts in the vendor's and the bill number's cell. The paperclip calls receiptLinkAction: the same one-minute link, signed as the person, after the server has read the file's first bytes, saved as a download.
  • When a pending or rejected expense or a draft bill is deleted, its receipts go back to Unmatched (the foreign key clears the column and the guard sets the status); the receipts and their files are kept. An approved expense and a posted bill cannot be deleted. A bill that is voided keeps its receipts, which anyone who may edit can then untie.

When the document goes. The two foreign keys are ON DELETE SET NULL (expense_id) and (bill_id): deleting a pending or rejected expense, or a draft bill, is never held up by a receipt, and the receipt goes back to Unmatched.

Removing files. A deleted receipt's path is written to private.finance_receipt_deletions by a trigger, whoever deleted it and however, a deleted workspace included. finance_receipt_queue_orphans adds every object in the bucket that no receipt holds and that is an hour old: a file can be recorded for 30 minutes after its upload, so the two never meet. finance_receipt_deletions_due and finance_receipt_deletion_queued read the list, and finance_receipt_deletions_done takes a path off it only when its object is really gone. These four are the only things granted to service_role in finance, and they are functions: it has no right on any finance table.

Uploading. The browser puts the file straight into the bucket as the signed-in person (src/components/finance/receipt-upload.ts), so the bucket's own rule decides and no file passes through the app's server; the server action body limit is not raised. Before anything is sent the browser reads the file's first bytes (sniffReceiptType in receipt-files.ts): the type is what the bytes say, never the name or what the device reports, and anything that is not a JPG, PNG, PDF, HEIC or HEIF is refused there. The path is <workspace id>/<uuid>.<ext>; the browser makes the uuid, the action refuses a path that is not of that shape in the caller's own workspace (isInboxReceiptPath), and the guard refuses one with no object behind it. The upload is a plain one, not a resumable one: there is no percentage, only "Uploading", and a dropped connection fails that file, which starts again when the person presses Try again. registerReceiptAction then records the row; called twice for one file it answers with the same receipt. Files go one at a time through a queue of at most twenty (queueAdd, queueNext, retryPlan), each with its own state and sentence. A file that was stored and not recorded is retried without a second upload for 25 minutes, and uploaded again after that.

HEIC. A HEIC or HEIF is turned into a JPG before upload (receipt-heic.ts): by the browser's own decoder when it has one (Safari), otherwise by heic-to (LGPL-3.0, libheif inside), whose heic-to/csp build is plain JavaScript and runs under the Content-Security-Policy without wasm-unsafe-eval. It is loaded with a dynamic import() the first time it is needed, about 0.8 MB compressed, and by nobody else. When neither works the original is stored and is a download only.

Opening a file. receiptLinkAction reads the path and signs a 60-second link as the caller, always with a download name of ours (receipt.jpg), so storage serves an attachment. Before it hands the link out it reads the file's first 64 bytes through a ranged request (storedFileCheck in receipt-files-server.ts) and refuses a file that is not the type its receipt says. A JPG or PNG is previewed in the review card from bytes the browser fetches and checks itself, as an object URL: no image, frame or new tab is ever pointed at a stored file. A download also goes through the page's own copy of the bytes (it does not check their first bytes again; the server did): the browser fetches them through the link, with a 60-second limit, and saves its own copy, typed application/octet-stream, from an object URL, so the page never navigates to storage. A PDF and a stored HEIC are downloads only.

Reviewing. reviewReceiptInput and finance_review_receipt: vendor (200 characters), a real date, an amount above 0 with its SST inside it, SST from 0 to the amount and not without one. Every field may be empty. A receipt in review becomes unmatched. Any writer may review; the screen offers it on a receipt that is tied to nothing.

Deleting, and the clean-up run. deleteReceiptAction deletes the row as the caller and gets the deleted row's path back in the same statement; the path stays on the server. removeReleasedReceiptFile then asks finance_receipt_deletion_queued for that path, removes the object with the service client and calls finance_receipt_deletions_done. If that fails the receipt is still deleted and the file waits for the run. POST /api/finance/receipt-cleanup (cleanReceiptFiles) queues orphans, reads up to 100 due paths, asks about each again immediately before removing it, and marks done only what the database sees is gone. It is authorized like the other scheduled endpoints (isTriggerAuthorized) and must be called by the operator's scheduler; it stops after 20 seconds and takes up the rest on the next call. It answers {"ok", "queued", "removed"}: how many unrecorded uploads it put on the list, and how many files it took off it. ok: false means something was left for the next call (the list could not be read, a file could not be removed, or the 20 seconds ran out); it is not an error to alert on unless it stays false for many calls in a row. A 401 means the secret is wrong, unset or shorter than 32 characters.

The demo has eight receipts with no file (a migration cannot upload), so has_file is false on them and the screen says so. Its guests are anonymous accounts, which the bucket refuses, so the demo has no upload area and a sentence saying why.

Exchange rates

Stage 1 of multi-currency. finance_fx_rates holds Bank Negara Malaysia's published rates, one row per currency, rate date and session, shared by every workspace; finance_fx_latest_rates is the newest 1700 rate of each currency (it reads as its caller); finance_fx_fetch_state is the route's memory (when it last asked BNM, and the next month each currency's back-fill will fetch). Every signed-in person reads the rates; nobody signed in does. Only service_role writes them, and only by INSERT: it has no update, delete or truncate, so a stored rate never changes under a document that used it. The three relations are the only finance ones service_role holds anything on (Kira_archived_account_check.sql names them as its exceptions).

POST /api/finance/fx-rates/fetch (fetchFxRates) must be called by the operator's scheduler, authorized like the other scheduled endpoints (isTriggerAuthorized; a 401 means the secret is wrong, unset or shorter than 32 characters). It reads nothing from the request, reaches BNM only when a rate is missing (the one network call is bnmGet), spends at most 20 seconds and 30 requests a call, and resumes its back-fill on the next call. It answers {"ok", "stored", "pending"}: how many rates it stored and how many currency back-fills are left. ok: false with status 200 means something was left for the next call; status 500 {"ok":false,"stored":0,"pending":null} means the state or the rates table could not be read or written. Neither is an alert unless it stays so for many calls in a row.

Retry rule: after 17:30 Malaysian time, Monday to Friday, at most once an hour until that day's rate is stored (a public holiday costs about seven calls); never on a Saturday or Sunday once rates exist. Call it every few minutes or hourly; it does nothing when nothing is missing.

The "Exchange rates" card on Banking is read-only and is drawn only for a workspace that deals in another currency (workspaceUsesForeignCurrency). Stage 1 adds no currency to any contact or document, so that is false for every workspace and the card is never drawn; stage 2 implements the check.

Invoices

A sales invoice is a finance_invoices row with its lines in finance_invoice_lines, which has the columns of supplier_bill_lines plus position, the order the lines were entered in. status is what a person sets: draft, posted or void. A draft has no invoice_no; posting gives it the next INV- number. The database checks this: a draft has no number, any other status has one, and a posted invoice has posted_at. A write that breaks it is refused with a plain check-constraint error (23514).

The view finance_invoice_totals adds the customer's name, subtotal, sst, total, paid (posted money in allocated to the invoice), credited (its issued credit notes), refunded (posted refunds, put against the invoice itself or against one of its credit notes), balance and display_status: draft, void, paid (the total is above zero and nothing is left to pay), overdue (posted and past due_date by the database's UTC date), otherwise sent. "Sent" means posted; no email goes out. A part-paid invoice stays sent or overdue with a lower balance.

balance is the total, less what was paid, less what was credited, plus what was refunded. This view is the one place it is worked out: the guards read their figures from it, and the screens read balance and never work it out again. A balance below zero means the customer is in credit: they have paid, or been credited, more than the invoice came to. Such an invoice reads paid. It takes no more money in, and a refund can pay the credit out, which brings the balance back to zero.

How a customer in credit shows: on Invoices the Balance cell reads "RM 240.00 in credit" (balanceText), never a negative amount, and the CSV has Credited and Refunded columns beside a Balance that is below zero. The payment-in form does not offer the invoice. Total receivable on Invoices and Receivable on Customers & Suppliers add up only invoices that are sent or overdue, so a customer's credit on one invoice is not taken off what they owe on another. Refunds has a card, "In credit", that adds up every invoice's credit, pending refunds included.

einvoice_status (none, pending, validated, rejected) and einvoice_uuid hold what a person has noted about the invoice's e-Invois at LHDN. Nothing is submitted from here. A draft has neither; they can be changed only while the invoice is posted. setEInvoice (setEInvoiceAction) writes them, from "Set e-Invois" on a posted invoice's row. The UUID is free text of up to 100 characters; with no status there is no UUID.

What a row's menu offers follows its status (invoiceActions): a draft is edited, posted or deleted; a posted invoice takes a payment while something is still owed, a credit note while its total is not yet credited in full, and a refund while its customer is in credit and pending refunds do not already hold all of that credit; it has its e-Invois set, and can be voided; a void invoice offers nothing. Each of the three is offered exactly when the form it opens would offer the invoice (receivableInvoices, creditableInvoices, refundTargets, put together by invoiceCan), so a writer's Invoices page also reads the credit notes and the refunds. If either of those two reads fails, the failure is logged and the page still shows, with no row offering "Refund": the lists are not taken as empty, which would offer a refund that no pending refund had been counted against. "Credit note" is still offered, since it is decided from the invoice rows and its card needs neither list. A refund card that was open on Invoices when a refresh lost the two reads is closed, not drawn with nothing to refund. Any other read that fails is the page's failure, as before, and a viewer's page reads neither. Voiding an invoice that has payments, credit notes or refunds is offered and refused with the reason, as on a paid bill. "Credit note" and "Refund" open their cards on Invoices with that invoice chosen; what is saved shows on Credit Notes or Refunds, and a note at the top links there.

Quotations

A quotation is a finance_quotations row with its lines in finance_quotation_lines, which has the columns of finance_invoice_lines. status is what a person sets: draft, sent, accepted or declined. "Sent" records that the customer has been given the quotation; nothing is emailed. Sending gives it the next QT- number and locks its header and lines. After that only the customer's answer is recorded (status, accepted_at), and it can be corrected from one answer to the other until an invoice is made from the quotation. There is no void: a quotation that is no longer wanted is marked declined. The database checks that a draft has no number, any other status has one with sent_at, and that accepted_at is set exactly when the status is accepted.

The view finance_quotation_totals adds the customer's name, subtotal, sst, total, display_status (expired for a sent quotation past valid_until by the database's UTC date, otherwise the status) and invoice_id and invoice_no: the invoice made from the quotation, if one stands. An accepted quotation stays accepted after its date; a sent one can still be accepted after it expired.

finance_convert_quotation makes a draft invoice from an accepted quotation: the same customer, notes and lines, dated with the date it is given (invoice_date), due after the customer's payment terms counted from that date, with quotation_id set. "Make invoice" sends the date on the person's own calendar (localIsoDate, as the forms' default dates are), so the invoice is dated today for the person who made it, at any hour. The database's own date is UTC, which before 08:00 in Malaysia is the day before; the function uses it only when it is given no date, which the app never does. The function puts no limit on the date: the invoice is a draft and its date can be changed on the invoice form before it is posted, and finance_save_invoice puts no limit on a date either. The app's schema (convertQuotationInput) takes only a real calendar date. A quotation has one invoice at a time (a unique index on finance_invoices); a void invoice does not count, so a quotation whose invoice was voided, or deleted as a draft, can be converted again. The invoice is an ordinary draft afterwards, and it stays tied to the quotation while its customer stays the same. Changing the invoice's customer removes the tie: the database clears quotation_id in the same update, the invoice carries on as a draft with no quotation behind it, the quotation shows no invoice, and it can be converted again. A quotation therefore never shows an invoice that belongs to another customer. The funnel on the screen has three steps, created, sent and accepted: nothing tracks a customer opening a quotation.

What a row's menu offers follows its status (quotationActions): a draft is edited, marked as sent or deleted; a sent quotation, expired or not, is marked accepted or declined; an accepted one is made into an invoice or marked declined; a declined one can be marked accepted; and once an invoice stands for the quotation the menu offers nothing. The form is the card bills and invoices use, with "Valid until" as its second date and "Save and mark as sent" as its second button. The question asked before "Make invoice" says the draft will be dated today. After "Make invoice" the screen stays on Quotations and a note at the top says a draft invoice was made and gives its date, with a link to Invoices, where it is checked and posted; the row's Invoice column links there too.

Credit notes

A credit note is one finance_credit_notes row: one amount against one posted invoice, a reason (returns, pricing_adjustment, overbilling, goodwill, other), and a status of draft, issued or void. Issuing gives it the next CN- number, and from that moment its amount is taken off what the invoice owes. All the issued credit notes of an invoice together never exceed the invoice's total. What has been paid does not limit a credit note: one issued on a paid invoice leaves the customer in credit, and a refund pays that out.

The view finance_credit_note_totals adds the invoice's number, the customer and refunded: posted refunds put against the note itself.

The form offers posted invoices, each for its total less what is already credited (creditableInvoices); a draft credit note is not counted, as the database does not count one until it is issued. It is saved as a draft, or saved and issued in one go (saveAndIssueCreditNoteAction, through saveThenPost). Edit opens from the list row, which carries everything a credit note has: there is no read action for one draft. What a row's menu offers follows its status (creditNoteActions): a draft is edited, issued or deleted; an issued credit note is voided, and refunded while something of it can still be; a void one offers nothing.

Supplier credit notes

20261123110000_Kira_supplier_credit_notes; the screen is /finance/supplier-credit-notes (see below). A supplier credit note is one finance_supplier_credit_notes row: one amount against one posted supplier bill, a reason (overpaid, returned_goods, short_delivery, other), the supplier's own reference, and a status of draft, issued or void. Issuing gives it the next SCN- number (finance_issue_supplier_credit_note). The supplier is the bill's supplier; it is not stored twice. There is no money in from a supplier, and no note without a bill. All the issued notes of a bill together never exceed the bill's total (FIN05).

Credit on a bill. finance_supplier_credit_applications says which bill took how much of a note (credit_note_id, bill_id, amount; one row per note and bill). On issue the note is applied to its own bill, up to what that bill still owes: its total, less payments (posted, or waiting to be paid) and credit already on it. What is left stays open on the supplier, and an owner or admin applies it to a later posted bill of the same supplier (FIN58 for another supplier's bill). The applications of a note never exceed the note, and the payments and credit on a bill never exceed its total (FIN05): the guard on applications checks the second from the credit side, and finance_allocation_guard_credit, a trigger that fires right after finance_allocation_guard, from the payment side, so a payment voucher cannot pay past the credit. Applying credit makes no ledger entry: the note already reduced Accounts Payable when it was issued. A row is never edited; it is taken off (deleted, by an owner or admin) and put on again.

Figures. supplier_bill_totals gains credited (last column). paid is still posted money out; balance and display_status take the credit off, so a bill the credit covers reads paid. finance_supplier_credit_note_totals adds the bill's number, the supplier, applied and open_amount (the part of an issued note on no bill yet). On finance_purchase_order_totals, billed_total, over_billed and billed_over_received take the issued notes on the order's bills off, so an order the supplier has corrected stops reading as over-billed. A draft note counts for nothing.

The books. An issued note debits Accounts Payable and credits the bill's category by its amount (finance_ledger_lines, source supplier_credit_note). A void posts a reversal on the day it was voided, as for the other documents.

Void. Issuing and voiding need an owner or admin (FIN14), as do putting credit on a bill and taking it off. A note is voided only while its credit is on its own bill alone; voiding takes that off again, so the bill owes it. If the credit has been used on another bill, take it off that bill first (FIN57). A bill cannot be voided while a draft or issued note is against it, or while credit is applied to it (FIN59).

With contra entries. supplier_bill_totals keeps its set_off column and takes both it and credited off balance. Payments, posted contra entries and applied credit together may not pass a bill's total (FIN05): finance_supplier_credit_application_guard, finance_allocation_guard_credit and finance_contra_guard_credit each count the other two.

The screen. Supplier Credit Notes (Purchases menu) is modelled on Credit Notes: figures (credited this month, open credit, issued, drafts), credited over time and by reason, and the table with No. (and the supplier's own number), Date, Supplier, Against bill, Reason, Amount, Applied (with the bills its credit is on), Open and Status. src/lib/finance/supplier-credit-notes.ts reads finance_supplier_credit_note_totals and the applications (and the numbers of bills credit was moved to) and writes through the two functions above, a status update to void, a delete for a draft, and an insert or delete on finance_supplier_credit_applications to apply credit or take it off.

  • The form offers posted bills in ringgit only (the notes have no amount_myr, and the guard does not check the bill's currency), labelled with what each has left to credit: its total less its issued notes.
  • Apply to a bill offers the note's supplier's posted ringgit bills that still owe something after payments not yet made, oldest due first, and not one already holding this note's credit; the amount starts at the smaller of the open credit and what the bill owes. One row per note and bill, so changing the credit on a bill is taking it off and applying it again: each is one write.
  • Take off BILL-… is offered once per later bill. A note's credit on its own bill is taken off only by voiding it.
  • Anyone who may write saves and deletes drafts; issuing, voiding, applying and taking off are an owner's or admin's (the menu offers them to nobody else, and the subtitle tells a member so). "Save and issue" by a member saves the draft and says who issues it.

Supplier Bills reads credited and shows it under the balance ("Credited RM x"), shows "RM y credit open from this supplier" on bills that still owe, offers Credit note on posted ringgit bills that can still be credited (the same form, that bill chosen), and words FIN59. Journals, ledger links and the audit trail now send a supplier credit note to this screen.

SST settings and tax type

20261124100000_Kira_sst_settings (database only; the screens come later). Stage 1 of the SST-02 return (#222): store what the return needs.

The table. finance_sst_settings holds one row per workspace (org_id is the key, and the row goes when the workspace is deleted): period_start_month (1 to 12, the month the first two-month taxable period starts), service_tax_no and sales_tax_no (the registration numbers: null, or 1 to 35 characters with no space at either end), service_tax_basis (payment, the default, when service tax is due on what the customer paid, or invoice, on what was invoiced), and updated_by / updated_at, which a trigger (finance_sst_settings_stamp) sets on every insert and update whatever the caller sent. Every member reads it; only an owner or admin inserts or updates it (private.finance_may_sign_off); there is no delete policy, and delete is not granted. It has the restrictive mfa_required policy like every finance table.

The columns. finance_products and finance_invoice_lines each gain tax_type (sales, service, none, or null) and sst_code (the item's SST code: null, or 1 to 20 characters with no space at either end).

The rule. private.finance_sst_tax_type_for_rate(rate) is the one mapping from a rate to a tax type: 5 and 10 are sales, 6 and 8 are service, 0 is none, and any other rate is null (a person picks the type). Existing products took their type from their rate when the migration ran.

The triggers. A product inserted without a tax_type takes its rate's (finance_product_tax_type, before insert or update). When an update changes a product's sst_rate and leaves its tax_type as it was, the type is proposed again from the new rate, but only if the old type was the old rate's proposal; a type a person chose (say sales at 6) stands, and an update that sends a new type keeps it. This is the same rule as the product form. An invoice line saved without a tax_type takes its product's tax_type and sst_code, or, with no product, its rate's type and no code (finance_invoice_line_tax, before insert or update). A line that charges SST is never stored as none from its product: when the product's type is none or null and the line's rate is a sales or service tax rate (5, 6, 8 or 10), the line takes its rate's type and no code. A taxed product on a 0% line keeps its type and code (an exempt service), and a product's own sales at 6 stays sales. A type sent on the line stands. Because finance_save_invoice deletes and re-inserts a draft's lines on every save, a draft re-saved after its product changed takes the product's new type. finance_invoice_line_tax fires after finance_invoice_line_guard and finance_invoice_line_rate (same timing, name order), so a line of a posted invoice is still refused first with FIN01. It runs as the owner and reads the product in the line's own workspace only. Lines saved before the migration keep a null type.

Refunds

The form offers what refundTargets works out from the invoices, the credit notes and the refunds, to the sen, and it is what the database allows. An invoice: what its customer is in credit there (balance below zero, which already has every refund that was paid out in it), less refunds that are pending against the invoice or against any of its credit notes. An issued credit note of such an invoice: the same figure, and no more than the note's amount less every refund standing against the note, paid out or pending. Choosing one fills the amount with all it can take. The refund is paid out now or saved to pay later.

What a row's menu offers follows its status (refundActions): a refund still to be paid is marked as paid, with the date it was paid out, or deleted; one that was paid out is voided; a void one offers nothing. A refund has no edit. A refund still being written (draft) reads "To be paid" and is counted as still to be paid; the app never leaves one in that state.

Money out that may be paid and has not been (status = 'scheduled') reads "To be paid" on every screen that shows it: the status badge, the card and the filter on Refunds ("Pending" before) and on Payments Out ("Scheduled" before), and the badge and filter on Payment Vouchers, with the same words in their CSV status column and in the questions and menus. The names in code (scheduled, isPendingRefund, the pending filter key) are unchanged. A supplier bill's own "Pending" and "Pending approval" are different states and keep their words.

Posted is locked

Triggers enforce it, so no caller can get round it:

  • A draft bill can be edited and deleted. A posted bill can only be voided, and not while it has payments. Its lines are locked with it.
  • A bill is always created as a draft. It can only be posted once its lines add up to more than zero, whichever way the status is changed.
  • A scheduled payment can be deleted or marked paid; the data layer has no function to edit one yet. It cannot be voided: only a paid payment is voided. A paid one can only be voided, which frees the bills it paid. Its split is locked with it.
  • A payment cannot exceed what is still owed on a bill, counting scheduled, draft and pending-approval payments. The bill row is locked while this is checked, so two payments at the same moment are checked one after the other.
  • Clearing a deleted user from a payment's created_by or approved_by is allowed on a posted payment, when the foreign key does it. The same update made by hand by a signed-in person is refused as any other change to a posted payment is (FIN09); see Approval.
  • An invoice follows the same rules as a bill: created as a draft, edited and deleted only as a draft, posted only once its lines add up to more than zero, voided only when posted and only while no payment or refund that is posted, scheduled or in progress is allocated to it. Its lines are locked with it. An invoice with a credit note that is a draft or issued cannot be voided either.
  • quotation_id on an invoice may only name an accepted quotation of the same customer, checked when the tie is made; a draft invoice can be untied. When a draft's customer is changed and the tie is left as it was, the guard on invoices clears quotation_id itself, so the tie never outlives the customer it was checked for.
  • A quotation is created as a draft, edited and deleted only as a draft, and sent only once its lines add up to more than zero. Sent, accepted or declined, nothing on it changes but the answer; once an invoice stands for it, the answer cannot change either.
  • A credit note is created as a draft against a posted invoice, edited and deleted only as a draft, and voided only when issued. It cannot be voided while a refund is allocated to it, or while the refunds on its invoice would no longer fit in the customer's credit without it.
  • A refund needs the customer to be in credit on the invoice and can be no more than that credit, counting refunds that are pending; against a credit note it is also never more than the note. The invoice row is locked while this is checked. When a refund that was not yet paid out becomes posted, the cap and the credit note's amount are both looked at again, counting the refund itself: the refund, plus the refunds of other payments that are still waiting, must fit in what the customer is in credit on the invoice (FIN05); a refund against a credit note needs the note to be issued still (FIN04) and, with every other refund standing against that note, must not exceed the note's amount (FIN05). The refund's own amount is counted whatever status it comes from, so a refund that was rejected, and so held no credit while another refund took it, cannot be paid out on top of that one. The credit notes and then the invoices are locked while this is read, and what the refund itself amounts to on a credit note or an invoice is read after that row is locked, so a split added to the payment while it is being paid out is counted too.
  • The guards on allocations, credit notes and payments read what an invoice owes from finance_invoice_totals. If the view gives them no row for the invoice, they refuse the write with FIN04 instead of letting it through unchecked.
  • On a posted invoice two columns may still change, einvoice_status and einvoice_uuid (with updated_at), and nothing else in the same update.
  • A payment in cannot exceed what is still owed on an invoice. The invoice row is locked while this is checked, so two payments at the same moment are checked one after the other. A payment in is posted at once, so it can only be voided, never deleted; voiding it frees the invoices it paid.
  • The guard on payments is replaced by the same migration that adds invoices. It refuses everything it refused before, in the same order, and still does not look at direction for posted, void or deleted payments. It adds one rule, for money out as much as money in: once a payment that is not yet posted has been split across documents, its direction cannot change and its amount cannot go below the sum of its split. Otherwise a split could be left larger than its payment, or pointed at the wrong kind of document, and then posted.
  • A contact with invoices or quotations cannot have Customer unticked, as one with bills cannot have Supplier unticked.
  • Clearing a deleted user from the created_by of an invoice, a quotation or a credit note is allowed whatever its status.
  • An expense is created pending. It is edited and deleted while pending, and deleted or edited back to pending once rejected; approved, it is locked, and can only be rejected, by an owner or admin, while no payment that is posted, scheduled, waiting or in progress is allocated to it (FIN02). Nothing else changes in the update that approves or rejects it.
  • A payment only goes against an approved expense (FIN04), only money out does, and never for more than is left of it, counting payments that are posted, scheduled, waiting or in progress (FIN05). The expense row is locked while this is checked, so two payments at the same moment are checked one after the other, and it cannot be rejected in between.
  • One payment pays one kind of document (FIN06): a second allocation that names another kind than the payment's first is refused.
  • A payment starts as a draft (FIN09), and the approval rules above hold whichever way a status is changed.
  • Changing a payment's split locks the payment. The guard on allocations reads the payment's row with a share lock on insert, update and delete, and keeps it until the transaction ends. Only two tests come before that read: whether the workspace itself is being deleted, in which case the guard steps aside, and whether the update changes org_id, which is refused (FIN09). The guard on payments runs with the payment's row already locked by its own update, and reads the split. So a change to a payment (paid, scheduled, submitted, approved, lowered) and a change to its split cannot both go through at the same moment, each having seen the other as it was: one waits for the other and then sees what it did. Without it, a voucher could be paid while its split was taken away, leaving a paid payment that settles nothing and that nobody approved.
  • Locks are always taken in the same order: the payment first, then the document. The guard on allocations locks the payment, then the credit note and the invoice, or the expense, or the bill. finance_mark_payment_paid and the guard on payments lock the payment, then for a refund its credit notes and its invoices. So a change to a payment and a change to its split, each sent as one request, queue and do not deadlock; this was run with two sessions for paying against deleting the split, lowering against adding to the split, and paying out a refund against adding to its split. It is not a promise about every direct write: see Known limits for two that can still deadlock. The functions the app records payments with insert the payment and its split in one transaction, so the row they lock is one no other session can see yet, and they wait for nothing. Deleting a payment deletes its split with it; the guard on allocations finds no payment row by then and locks nothing.
  • Clearing a deleted user from the created_by or approved_by of an expense is allowed whatever its status, when the foreign key does it, and does not move the expense's updated_at. By hand, approved_by on an approved or rejected expense is FIN01, and created_by is 42501 on any expense: the column is closed.
  • Each guard steps aside when the workspace itself is being deleted.

Contra entries

20261119100000_Kira_contra_entries (issue #223). A contact who is both a customer and a supplier owes us on invoices while we owe them on bills. A contra entry sets the two off against each other: it lowers what each chosen invoice and bill still owes, as a payment would, with no bank account involved. It is a draft, then posted (CT-0001), then, if need be, voided; a posted one is never edited. The owner's decisions: it is within one contact (is_customer and is_supplier); either side may be partial, and what is set off on the invoices equals what is set off on the bills and the contra's amount; a reference and a note are enough, no attachment; one contra may name several invoices and several bills.

No customer or supplier statement exists in the code (the only "statements" are the financial statements in reports.ts). Until one is built, a contra shows as its own line on the Contra Entries screen (stage 2), in the ledger, in each invoice's and bill's balance, and in Customers & Suppliers and the aged tables, which sum balance. A future statement reads finance_contra_lines joined to finance_contras (posted, and void with its reversal on voided_at).

Tables. finance_contras (contact, date, amount, reference, notes, status draft/posted/void, contra_no, posted_by, posted_at, voided_at) and finance_contra_lines (one line per invoice or bill: exactly one of invoice_id and bill_id, an amount; no update, the save function replaces a draft's lines). Every member reads both; only an owner or admin writes, for drafts too (owner's decision D1). contra_no, created_by, posted_by, posted_at and voided_at are closed to a signed-in person by column grants. Neither table is granted to service_role.

Functions (all security invoker; each begins by asking private.finance_may_sign_off, so anyone else gets FIN14):

FunctionDoesReturns
finance_save_contra(target_org, contra jsonb, lines jsonb)Creates a draft, or with contra.id replaces a draft's header and lines. contra: id, contact_id, contra_date, amount, reference, notes; each line: invoice_id or bill_id, amount. A draft may be unfinished.the id
finance_post_contra(target_org, target_contra)Posts a draft; every rule is private.finance_contra_guard'sthe number
finance_void_contra(target_org, target_contra)Voids a posted onenothing

What a post asks, in the guard's order: the contact is both a customer and a supplier (FIN31); at least one invoice and one bill, and each side adds up to the amount (FIN29); the invoices and bills belong to the contact (FIN31) and are posted (FIN04); no line is more than the invoice still owes after money in that is recorded but not posted (FIN05), or more than the bill is worth less every payment not rejected or void and every posted contra (FIN05). The number is then taken in the same transaction. A void asks that no refund depends on the set-off (FIN33), the rule an issued credit note has.

Error codes: FIN29 the amounts do not add up; FIN31 one contact who is both customer and supplier, and that contact's own documents; FIN32 an invoice or bill that a posted contra sets off is not voided; FIN33 refunds depend on this contra entry, so it is not voided. FIN01, FIN04, FIN05, FIN11 and FIN14 keep their usual meaning.

What it moves. finance_invoice_totals and supplier_bill_totals gained a last column, set_off (posted contra lines on the document); balance (and display_status) subtract it, and paid stays money only, so Nova Billings' "received" is still cash. The ledger gained the source contra (finance_ledger_lines, key contra:<id>): debit Accounts Payable, credit Accounts Receivable by the amount, and, once voided, the reverse on the day it was voided (the database's UTC date, as every void). Nothing is written to finance_transactions or finance_allocations, so no bank balance, Banking line or Cash Flow figure moves.

Triggers beside the live guards. finance_allocation_less_contra on finance_allocations (after finance_allocation_guard, before finance_allocation_still): a payment on a bill is never more than the bill less its posted contras (FIN05). finance_invoice_void_contra and finance_bill_void_contra (after the table's _sign_off, before finance_void_stamp): a document a posted contra names is not voided (FIN32). On finance_contras: finance_contra_guard and finance_void_stamp; on finance_contra_lines: finance_contra_line_guard. The lists now read finance_allocations: finance_allocation_guard, finance_allocation_less_contra, finance_allocation_still; finance_invoices: finance_invoice_guard, finance_invoice_sign_off, finance_invoice_void_contra, finance_void_stamp; supplier_bills: finance_bill_category, finance_bill_guard, finance_bill_po_link, finance_bill_sign_off, finance_bill_void_contra, finance_void_stamp.

Limits. A draft contra holds nothing: a payment can take what a draft named, and the post then answers FIN05. The credit note guard reads paid, which a set-off does not change, so it may refuse to void a credit note on a set-off invoice that refunds do not depend on, and never allows one it should refuse. Posting with nobody signed in (a migration) asks no rule, as for journals. No activity-log line is written on post or void.

The screen. Contra Entries (src/screens/finance/contra-entries.tsx, contras-view.tsx, contra-form-card.tsx, rules in contra-form.ts) lists every contra entry with search, a status filter and Export for everybody. An owner or admin also gets New contra entry and, by status, Edit, Post and Delete on a draft and Void on a posted one; a member reads and is told only an owner or admin records them; a viewer and the demo workspace read. The form offers each invoice for what it owes less money in recorded but not posted, and each bill for what it owes less payments not yet paid (receivableInvoices and payableBills), which is what a post asks. The five actions in src/app/(app)/finance/actions.ts refuse anyone who is not an owner or admin before anything is read; saving or deleting a draft refreshes Contra Entries only, and posting or voiding refreshes every screen that shows what an invoice or bill owes, Customers & Suppliers and the three ledger screens, not Banking. Invoices and Supplier Bills say "Set off RM x" under a balance a contra entry lowered, and the invoices export has a Set off column. The assistant reads them with listContraEntries; it has no tool that writes one.

Database functions

Most run as the caller, so RLS decides who may write. Eight run as their owner (security definer), because each writes something no signed-in person may write directly: a payment's number, or the counters. They are finance_record_payment_out, finance_mark_payment_paid, finance_record_payment_in, finance_record_refund, finance_post_cash_receipt, finance_record_voucher (20261030123000_finance_write_guards), finance_record_transfer (20261031090300_Kira_banking_write_guards_fit) and finance_next_number. Each begins by asking whether the caller may write in target_org and answers 42501 if not; each reads and writes by target_org only; and the guards and the sign-off trigger still see the person signed in, because that comes from the request and not from the role a function runs as. finance_account_numbers(target_org) runs as its owner too and returns the workspace's account numbers to a writer, and no row to anyone else.

FunctionDoes
finance_save_bill(target_org, bill, lines)Creates a draft bill or replaces a draft's header and lines. Returns the id.
finance_post_bill(target_org, target_bill)Posts a draft and returns its number.
finance_record_payment_out(target_org, payment, allocations)Records a payment; payment.status is posted (the default) or scheduled. Returns the id.
finance_mark_payment_paid(target_org, target_txn, paid_on)Scheduled money out to paid. Returns the number. Refuses money in (FIN09).
finance_save_invoice(target_org, invoice, lines)Creates a draft invoice or replaces a draft's header and lines, keeping the lines in the order given. invoice has id (to change a draft), customer_id, invoice_date, due_date, notes. Returns the id.
finance_post_invoice(target_org, target_invoice)Posts a draft and returns its number.
finance_record_payment_in(target_org, payment, allocations)Records money received from one customer, split across that customer's posted invoices, and posts it at once. payment has account_id, txn_date, method, reference, notes; allocations is a list of { invoice_id, amount }. The payment's amount is the sum of the allocation amounts, each rounded to the sen first, as each is when stored. Returns the id.
finance_save_quotation(target_org, quotation, lines)Creates a draft quotation or replaces a draft's header and lines, keeping the lines in the order given. quotation has id (to change a draft), customer_id, quote_date, valid_until, notes. Returns the id.
finance_send_quotation(target_org, target_quotation)Marks a draft as sent and returns its number.
finance_convert_quotation(target_org, target_quotation, invoice_date)Makes a draft invoice from an accepted quotation, dated invoice_date and due after the customer's payment terms counted from it. invoice_date may be left out or null, and the database's UTC date is then used. It is one function with a default, not two: a second function of the same name would make the two-argument call ambiguous. Returns the invoice's id.
finance_save_credit_note(target_org, note)Creates a draft credit note or replaces a draft. note has id (to change a draft), invoice_id, credit_date, reason, amount, notes. Returns the id.
finance_issue_credit_note(target_org, target_note)Issues a draft and returns its number.
finance_record_refund(target_org, refund)Records one refund against one invoice or one credit note. refund has invoice_id or credit_note_id, account_id, txn_date, method, reason, amount, reference, notes, and status: posted (the default) or scheduled. Returns the id.
finance_save_cash_receipt(target_org, receipt, allocations)Creates a draft cash receipt or replaces a draft's details and split. receipt has id (to change a draft), account_id, txn_date, method, reference, notes, and either nothing more, with allocations a list of { invoice_id, amount } for one customer's posted invoices, or category_id (an income category), amount, and contact_id or party_name, with no allocations. Returns the id.
finance_post_cash_receipt(target_org, target_txn)Posts a draft cash receipt and returns its RC- number.
finance_record_voucher(target_org, voucher, allocations)Records money out for approved expenses or for an expense category. voucher has account_id, txn_date, method, reference, notes, contact_id or party_name, status (posted, the default, or scheduled), and either nothing more, with allocations a list of { expense_id, amount }, or category_id and amount, with no allocations. A voucher with no allocations recorded by someone who is not an owner or admin is left pending_approval. Returns the id.
finance_save_expense(target_org, expense)Creates an expense or changes one that is pending or rejected. expense has id (to change one), expense_date, category_id, vendor_id or vendor_name, amount, sst_amount, notes. Saved by an owner or admin it is approved in the same call. Returns the id.
finance_register_receipt(target_org, file)Records an uploaded file as a receipt in review and returns its id. file has path and name. The guard checks the file.
finance_set_receipt_extraction(target_org, target_receipt, details)Writes what a model read (vendor, receipt_date, amount, sst_amount) or why it could not (error), only while the receipt is in review. Answers whether it wrote.
finance_review_receipt(target_org, target_receipt, details)Saves what a person typed; a receipt in review becomes unmatched.
finance_attach_receipt(target_org, target_receipt, target_expense, target_bill)Ties an unmatched or in-review receipt to an expense or to a bill: exactly one of the two.
finance_detach_receipt(target_org, target_receipt)Unties a matched receipt.
finance_receipt_to_expense(target_org, target_receipt, expense)Makes a new expense from a receipt and ties the receipt to it. expense is what finance_save_expense takes; an id in it is ignored. Returns the expense's id.
finance_receipt_to_bill(target_org, target_receipt, bill, lines)Makes a new draft supplier bill from a receipt and ties the receipt to it. bill and lines are what finance_save_bill takes; an id in bill is ignored. Never posts. Returns the bill's id.
finance_receipt_queue_orphans(), finance_receipt_deletions_due(p_limit), finance_receipt_deletion_queued(p_path), finance_receipt_deletions_done(p_paths)The clean-up run's. service_role only.
finance_record_transfer(target_org, transfer)Moves money between two active accounts of the workspace: two posted payments with one TR- number. transfer has from_account_id, to_account_id, amount, txn_date, reference, notes. An owner or admin only (FIN14); runs as its owner, and anyone who may not write in the workspace is 42501. Returns the transfer's id.
finance_void_transfer(target_org, target_transfer)Voids both sides of a posted transfer. An owner or admin only (FIN14, from the sign-off trigger); runs as the caller.
finance_import_statement(target_org, target_account, file_name, lines)Stores one statement for one account. lines is a list of { line_date, description, money_in, money_out } in the file's order, at most 1,000. Returns the import's id.
finance_next_number(target_org, doc)The next document number. Runs as its owner; the only writer of finance_sequences.
finance_account_numbers(target_org)The account numbers of the workspace, one row per account, for someone who may write there; nobody else gets a row.
finance_save_rfq(target_org, rfq, lines)Creates a draft RFQ or replaces a draft's header and lines, and returns the id. Since 20261116090000_Kira_rfq_source it writes rfq.source_rfq_id when it creates a draft. Saving a draft again leaves the link as it is, whatever is sent.

The same migration changes two more things. The RFQ guard (private.finance_rfq_guard, the trigger on finance_rfqs) sets the link when an RFQ is inserted: it follows a copy of a copy back to the first RFQ, and refuses a source it cannot find in the RFQ's own workspace (FIN11), so a new RFQ cannot name another workspace's RFQ, or itself. On an update from a signed-in caller it refuses any change to the link (FIN01); the one change it lets through is the foreign key clearing the link because the first RFQ was deleted. The view finance_rfq_totals lists its columns by name and ends with a new one, source_rfq_id; the columns before it are the same, in the same order.

Accepting or declining a quotation, voiding a credit note or a refund, and deleting a draft quotation, a draft credit note or a pending refund, are plain updates and deletes too. The data layer for all of it is in quotations.ts, credit-notes.ts and refunds.ts. The quotation writes are called by saveQuotationAction, sendQuotationAction, saveAndSendQuotationAction, acceptQuotationAction, declineQuotationAction, deleteQuotationAction and convertQuotationAction; the credit note writes by saveCreditNoteAction, issueCreditNoteAction, saveAndIssueCreditNoteAction, voidCreditNoteAction and deleteCreditNoteAction; the refund writes by recordRefundAction, markRefundPaidAction, voidRefundAction and deleteRefundAction.

Approving or rejecting an expense or a payment, voiding a posted receipt or voucher, and deleting a draft receipt, a pending or rejected expense, or a payment that was never paid, are plain updates and deletes as well. cash-receipts.ts calls finance_save_cash_receipt and finance_post_cash_receipt, and voids and deletes a receipt, each after asking finance_cash_receipts whether the payment is one. expenses.ts calls finance_save_expense, approves and rejects an expense with one update of status, and deletes one. expense-payment.ts calls finance_record_voucher, for one approved expense, paid now, and for nothing else. No function of src/lib/finance/ records a voucher for a category, schedules one, or approves or rejects a payment: cash-book.ts only reads, and each of those writes arrives with its form. The other voids and deletes that exist are those of Payments In, Payments Out and Refunds, and reach only what those screens show.

Voiding a bill, an invoice or a payment, and deleting a draft bill, a draft invoice or a scheduled payment, are plain updates and deletes; the guards decide whether they are allowed. A payment in is voided by the same update as a payment out, through its own function (voidPaymentIn in money.ts): it is kept to money in, answers in the sentences of a payment in, and its action refreshes the sales screens. A payment in has no edit and no delete.

Error codes

The guards and functions raise these SQLSTATEs. bills.ts, invoices.ts, money.ts, contacts.ts, quotations.ts, credit-notes.ts and refunds.ts turn them into sentences; the same code is worded for a bill, an invoice, a quotation, a credit note or a refund by the file that made the call.

CodeMeaning
FIN01The bill or invoice is posted or void and cannot be changed or deleted. Also: a bill or an invoice must be created as a draft, a draft cannot be voided (delete it), and only a draft can be posted; the lines of a posted or void bill or invoice are locked with it. On a posted invoice only einvoice_status and einvoice_uuid may change; changing anything else, alone or with them, is this code.
FIN02The bill or invoice has payments (posted, scheduled or in progress), so it cannot be voided; for an invoice a refund put against it counts as one. Also: a credit note cannot be voided while a refund is allocated to it, or while the refunds on its invoice would not fit in the customer's credit without it
FIN03The allocations add up to more than the payment. Also: a payment that has been split across documents cannot be changed to an amount below the sum of its split (money out or money in, while it is not yet posted)
FIN04A payment can only go against a posted bill or a posted invoice; only money out pays a bill, and only money in pays an invoice. Money out against an invoice is a refund, and is this code unless the customer is in credit on that invoice; a refund against a credit note needs the note to be issued, and money in never goes against a credit note. Also: a payment that has been split across documents cannot change direction (money out or money in, while it is not yet posted); a credit note can only go against a posted invoice; only an accepted quotation becomes an invoice, and an invoice can only be tied to an accepted quotation of the same customer
FIN05The amount is more than is still owed on the bill or the invoice, counting payments that are posted, scheduled, draft or pending approval. For a refund: more than the customer is in credit on the invoice, counting pending refunds, or more than is left of the credit note; also when a refund is paid out later and no longer fits in that credit or in its credit note, the refund itself counted. For a credit note: more than the invoice's total less what is already credited
FIN06No bill was chosen or found, or the bills belong to different suppliers; for money in, no invoice was chosen or found, or the invoices belong to different customers; for a refund, it names no document, or an invoice and a credit note at once, or one that is not in the workspace
FIN07The contact has bills, so Supplier cannot be unticked
FIN08The contact has invoices or quotations, so Customer cannot be unticked
FIN09The payment, money out or money in, is posted or void and cannot be changed or deleted, its split cannot be changed, or it is not scheduled and so cannot be marked paid; a payment that was never posted cannot be voided (delete it). Also: a payment or an allocation does not move to another workspace (an update that changes org_id)
FIN10The bill, invoice or quotation has no lines, or its lines total nothing; it cannot be saved without a line, and a bill or an invoice cannot be posted, nor a quotation sent, without an amount
FIN11The bill, invoice, quotation, credit note or payment no longer exists. finance_post_invoice, finance_send_quotation, finance_convert_quotation and finance_issue_credit_note also answer this to a viewer: each locks its row first, and a viewer may not, so to them it is not there. Five receipt functions (finance_review_receipt, finance_attach_receipt, finance_detach_receipt, finance_receipt_to_expense, finance_receipt_to_bill) answer it when the receipt no longer exists, or is another workspace's; a viewer of the same workspace is answered it too, because under the writer-only policies the functions see no row
FIN12The invoice has a credit note that is a draft or issued, so it cannot be voided. invoices.ts words it: "This invoice has credit notes. Void them on Credit Notes, or delete the drafts, before voiding it."
FIN13The quotation already has an invoice that is not void: it cannot be converted again, and its answer can no longer change. quotations.ts words it
FIN14Approval. Only an owner or admin approves or rejects an expense or a payment; a payment is submitted for approval only from a draft and rejected only before it is approved; money out that settles no document needs one of them before it is scheduled or paid; a payment waiting for approval, or rejected, cannot be changed; a rejected payment is final; an approved payment is paid as it was approved, with nothing but its status, number and date changing. Also: approved_by and approved_at on a payment or an expense are written by the database, and an update from a signed-in person that changes either by hand is this code, unless an earlier rule refuses it first (FIN09 on a posted or void payment, FIN01 on an approved or rejected expense whose status is left as it was); a scheduled payment out that nobody approved cannot be raised above its split, or made from scheduled money in, by someone who is not an owner or admin, and its split cannot be deleted or lowered below the payment by any signed-in person. Also a member's post or void of a cash receipt: Cash Receipts offers a member no Post and no Void, and if one is sent anyway the action answers "Only an owner or admin can post, pay or void. Ask one of them to do this." before reading or writing, and the database answers this code and uses up no number. Only an owner or admin unties a receipt from an approved expense or a posted bill.
FIN15A receipt, a voucher or an expense names the wrong thing: a receipt is for invoices or for an income category, a voucher for expenses or for an expense category, never both and never neither; a receipt for invoices that is being posted must be exactly its split and carry no category; an expense needs an expense category of its own workspace. cash-receipts.ts words it for a receipt: "A receipt needs either invoices that add up to its amount, or an income category that still exists. Edit it and try again." The voucher and the expense meet it with their forms
FIN23A receipt's state does not allow it: it starts in review, tied to nothing; it does not move to another workspace; its file, name, type, size, uploader and upload time do not change; a matched receipt is untied before it is deleted (the guard, for any delete), and finance_attach_receipt, finance_receipt_to_expense and finance_receipt_to_bill refuse a receipt that is already matched. A plain update of expense_id or bill_id by a writer is allowed by the grants, and the guard moves the receipt to the new document (with FIN14 when it leaves an approved expense or a posted bill and the writer cannot decide). receipt-inbox.ts words it for deleting a matched receipt.
FIN24The file of a receipt: there is none; it is not in the bucket; it was uploaded more than 30 minutes ago or is waiting to be removed; it is already a receipt; it is not the type its name says; its path names none of the five types. receipt-inbox.ts words it for recording an upload.
FIN25A void bill takes no receipt. receipt-match.ts: "A void bill takes no receipt. Choose another bill."
FIN28A payment that has left draft is held still (20261117090000): none of its direction, account, date, amount, method, reference, payee, category or notes changes (the date may, as a scheduled payment is marked paid); it does not go back to draft, whoever asks; and its split is not added to, taken from, moved or edited. Raised only for a scheduled payment nobody had to approve: every other payment that is not a draft answers FIN09 or FIN14 first. result.ts has a sentence for it (HELD_STILL); no data layer lists it, because no write of the app can meet it.
FIN29A contra entry's amounts do not add up (20261119100000): an amount above zero, at least one invoice and one bill, each side adding up to the amount. contras.ts: "Enter an amount above zero for the contra entry and for each invoice and bill." at save, "Before posting, choose at least one invoice and one bill, and make each side add up to the contra entry's amount." at post
FIN30An account with payments still to be paid or waiting for approval is not archived. bank-accounts.ts: "This account has payments that are still to be paid or waiting for approval. Pay, reject or delete them, then archive it." (FIN14 on an account is an opening balance from someone who is not an owner or admin, or an owner who has not given their second factor: "Only an owner or admin can set or change an opening balance. If you are one, sign in again with your second step." The guard counts payments in both directions; the screen's archive question warns about money out only.)
FIN31A contra entry is for one contact who is both a customer and a supplier, and sets off that contact's own invoices and bills. contras.ts
FIN32An invoice or bill that a posted contra entry sets off is not voided. bills.ts and invoices.ts: "A contra entry sets this off. Void the contra entry first."
FIN33Refunds on an invoice depend on a contra entry, so it is not voided. contras.ts
FIN37Money does not move on an archived account: a payment is not started on one, moved onto one, or sent for approval, approved, scheduled, posted or marked paid while its account is archived. result.ts has the one sentence (ACCOUNT_ARCHIVED), which money.ts, refunds.ts and cash-receipts.ts each list in their own map; transfers.ts words it as it words FIN16. A member recording a payment in, a refund or a paid-now payment out on an archived account is answered FIN37 (the draft is refused before the sign-off rule), where before this rule they were answered FIN14; posting, approving and marking paid by a member still answer FIN14.
FIN57A supplier credit note cannot be voided while its credit is used on another bill: take it off that bill first
FIN58Credit can only be applied to a bill from the same supplier as the credit note
FIN59A bill with a draft or issued supplier credit note against it, or with credit applied to it, cannot be voided

FIN23, FIN24 and FIN25 are not the Receipts Inbox's alone. The ledger migrations (20261108091500 to 20261108094500) raise FIN23 to FIN27 with meanings of their own, for journals and a bill's category, and are not in this table yet. A code is read by the function that made the call, so each data layer words the codes its own functions can answer; a sentence for one of these codes must never go in a map that both share.

For an RFQ's link to the RFQ it was copied from, the RFQ guard raises two of the codes above, and rfqs.ts words them. FIN11: the RFQ a new RFQ names is not there to copy. It was deleted, or it is another workspace's, or the caller may not read it, or the new RFQ names itself; all are answered alike. On saving a new copy rfqs.ts says "The RFQ this was copied from can no longer be found. Save again to keep this one on its own.", and the form drops the link; any other FIN11 on an RFQ stays "That RFQ no longer exists." FIN01: the link was changed after it was set, cleared by hand or pointed at the RFQ itself included. rfqs.ts has the RFQ's FIN01 sentence for it, but no screen can reach it, because finance_save_rfq does not write the link when a draft is saved again.

CodeMeaning (continued)
42501Row-level security refused the write: "You do not have permission to make changes here."
22003A number does not fit its column (quantity × unit price can overflow): "That amount is too large." An expense's amount or SST of Infinity answers it too (the schema refuses it first).
22023finance_record_refund was given a status other than posted or scheduled. The app always sends one of the two, so only a direct call reaches it; refunds.ts has no sentence for it. finance_attach_receipt answers it when it is given neither an expense nor a bill, or both: a receipt is tied to exactly one.
23514A check constraint refused the write. The one to know: an invoice's number must match its status (a draft has none, any other status has one, a posted invoice has posted_at); a quotation's and a credit note's number follow the same rule. Only a direct write can break it; the app never does. Others include the due date before the invoice date, a quotation valid until before its date, a line's quantity, price or SST rate out of range, a credit note of nothing, a refund reason outside the five, and an allocation naming more than one document, or none; invoices.ts has no sentence for them. One has a sentence: e-Invois set on a draft, which setEInvoice words as "e-Invois details can be recorded once the invoice is posted." Receipts: the table's checks refuse an amount that is not above zero or is NaN, an SST above the amount or below zero or NaN, a vendor longer than 200 characters, a file name with a slash, a backslash or a control character or longer than 200, a path outside the receipt's own workspace or of no known shape, and a receipt tied to two documents or a status that disagrees with its tie; finance_receipt_to_expense also answers it for an amount or SST of NaN. Not worded yet: no button reaches them.
22007, 22008, 22P02A date or a number that cannot be read: the receipt functions cast details and expense with ::date and ::numeric (finance_set_receipt_extraction, finance_review_receipt, finance_receipt_to_expense), so an unparsable receipt_date is 22007, an out-of-range one such as 2026-13-45 is 22008, and a bad amount or sst_amount is 22P02. The schemas of the parts that call them must refuse these first.
23502A required value is missing: finance_register_receipt without a name (or with a path that is missing or empty), since the table does not accept a null file name or path. The schema of the part that calls it must refuse this first.
23505A unique constraint refused the write. invoices.ts words it as "That invoice number is already used.". When a payment is recorded it can only be the same bill or invoice twice on one payment (numbers are taken under a lock), so recordPaymentOut and recordPaymentIn word it as "Each bill can appear once on a payment." and "Each invoice can appear once on a payment."; the schemas refuse it first, comparing ids without regard to capitals.

FIN01 is also raised for a quotation (created as a draft; one that has been sent is locked but for its answer, and its lines with it; a draft is not accepted or declined) and for a credit note (created as a draft; an issued or void one is locked; a draft is deleted, not voided). Their sentences are in quotations.ts and credit-notes.ts.

The sentence for FIN01 on a quotation is "That cannot be done to this quotation as it now stands. It may have been sent, accepted or declined since this page was loaded.". The row menu offers only what a status allows, so the code reaches a person only when the quotation changed in another tab: a draft that was sent meanwhile being saved, sent or deleted, or an answer being recorded that is already the answer. The sentence says what may have happened and not what did, so it is not false of a draft answered by a direct call either.

The sentence for FIN01 on a credit note is "That cannot be done to this credit note as it now stands. It may have been issued or voided since this page was loaded.", and for FIN09 on a refund "That cannot be done to this refund as it now stands. It may have been paid out or voided since this page was loaded.". Each was checked from every button that can meet it: Save, Issue, Void and Delete on a credit note; Mark as paid, Void and Delete on a refund.

FIN04 and FIN05 are worded twice for a refund, by the button. Recording one: "A refund needs a posted invoice, or an issued credit note, whose customer is in credit." and "That is more than the customer is in credit, or more than is left of the credit note.". Paying a pending one out (markRefundPaid only), where the same codes mean the credit has gone since it was recorded: "This refund can no longer be paid out: its invoice is no longer posted, or its credit note is no longer issued. Delete it." and "This refund no longer fits in what the customer is in credit, or in what is left of its credit note, so it cannot be paid out. Delete it. If the customer is still in credit, a new refund can be recorded for no more than that.". The second does not promise a new refund, because nothing may be left of the credit.

The sentence for FIN01 on an invoice is "This invoice has been posted or voided, so that can no longer be done.". The row menus offer only what a status allows, so the code reaches a person only when the invoice changed under them, and the sentence is true of every such case, voiding an invoice that was just voided included. FIN09 on voiding a payment in is "This payment cannot be voided as it now stands: it has already been voided, or it is still a draft.": the first for the same reason, the second for a draft receipt against invoices sent to voidPaymentIn by a direct call. A receipt has three sentences for the code, by what was being done (CASH_RECEIPT_MESSAGES: locked for a save or a post, cannotVoid, cannotDelete). Bills and payments out still say "Void it instead."; see Known limits.

The cash book reuses the codes above where the meaning is the same. FIN01: an expense is created pending, an approved or rejected one is locked, it is decided as it stands, an approved one is not deleted, who approved or entered an approved or rejected one is not blanked by hand, and an expense does not move to another workspace. FIN02: an approved expense with payments cannot be rejected. FIN04: a payment only goes against an approved expense, and only money out does. FIN05: more than is left of the expense, or a draft receipt for more than its invoice owes. FIN06: a voucher names an expense that is not there, a receipt names no invoice that is there or is for two customers, or one payment names two kinds of document. FIN09: a payment is created as a draft; a posted receipt cannot be saved or posted again; the split of a payment that is waiting, rejected or approved is locked; money in is not marked paid. FIN11: the receipt or the expense no longer exists, or is not the caller's to change. 22023: finance_record_voucher was given a status other than posted or scheduled. 23514: an expense with both a contact and a typed vendor or neither, with more SST than its amount, or of nothing; a payment with both a contact and a typed name. 23503: an expense that a voided or rejected payment still names cannot be deleted. For these cases none of the codes has a sentence yet: the files that word them arrive with the forms.

Banking adds two codes. FIN16 is a transfer: it is between two different, active accounts of the workspace and has an amount; a payment does not join or leave a transfer; once a side is posted, a transfer is two matching sides. transfers.ts words it for the one case its schema leaves ("One of those accounts has been archived or no longer exists. Choose another, then try again."). FIN17 has no sentence yet; it is a statement: an import has between 1 and 1,000 lines; a line starts unmatched and only its match changes; it is matched to a posted payment of its account, direction and amount; a matched line is unmatched before it is deleted; an import stays with its account. Banking reuses FIN14 (recording or voiding a transfer needs an owner or admin), FIN11 (the transfer no longer exists, or is not the caller's to void), FIN09 (only a posted transfer is voided), 42501 (finance_record_transfer called by someone who may not write in the workspace; a statement imported by one; and any direct write by a signed-in person that names a payment's number, so posting two sides by hand), 23505 (a second line on one payment, or a third side on a transfer), 23514 (a line with money in and money out, or neither; an amount of NaN on a payment or a line; a payment posted with no number; a draft side of a transfer given a number, which only the database owner can now try), 23503 (a statement for an account that is not the workspace's) and 23502 (a line with no date).

Known limits

  • Payment Vouchers: a voucher that waits or is "To be paid" can be deleted by any writer, whoever recorded or approved it: the guard on payments refuses a delete only once the payment is paid or void.
  • Payment Vouchers: the day a voucher was paid is the day the person enters. No rule refuses a day in the future, as on Payments Out and Refunds.
  • Payment Vouchers: a void cannot be taken back. A voucher voided by mistake is recorded again, and gets a new number.
  • Payment Vouchers: approval of a supplier payment is a choice. A member can still schedule one on Payments Out with no approval, so the Approve step is a second look for those who ask for one, not a control on those who do not.
  • Payment Vouchers: through the app a voucher for bills is always paid to the bills' supplier for exactly its split (finance_save_voucher sets both). By a direct update a writer can re-point a draft's payee, or raise its amount above its split, while it is still a draft (a draft is freely editable; the hold of FIN28 begins when a payment leaves draft), and no rule looks again when it is sent or approved. The approver's question shows the payee, the amount and the bills' numbers as they then are.
  • Payment Vouchers: sending a draft from its row is matched on the row's fields, not on its bills: a draft's split can be changed by any writer until it is sent. From then on nothing of it can change (FIN09 for its split, FIN14 or FIN28 for its fields).
  • Payment Vouchers: saving a draft that someone else changed meanwhile replaces their change, with no warning, as saving a draft receipt does on Cash Receipts: the form is not compared with the draft as it now is.
  • Payment Vouchers: an owner's or admin's draft is not approved by saving it; it is sent for approval and approved from its row, in two steps.
  • A payment that is to be paid is held still as a row, not as an intention (20261117090000). Anyone who may write can still delete a payment that is to be paid and record another in its place, for another amount or on another account, which is what Payments Out, Refunds and Payment Vouchers offer. The new row says who made it and when (created_by and created_at are the database's), and paying it is still an owner's or admin's. finance_mark_payment_paid still compares nothing with what the person was shown: it does not need to while the row cannot change between the list and the click, and a payment deleted and recorded again has another id. Whether a voucher for bills names the bills' supplier and is exactly its split is still only what the functions write, not something the database asks when it is sent for approval. A session with no signed-in user (a migration, the SQL editor) is not held. A change of transfer_id together with a held column on a scheduled payment is answered FIN28 now, where FIN16 answered it before, because the new trigger sorts first. The migration is applied as one call: it takes its table locks with nowait, so behind a payment writer, or an autovacuum of either table, it fails at once with 55P03 and nothing is created; send it again.
  • A scheduled payment cannot be edited: delete it and schedule it again.
  • Supplier Bills and Payments Out load every bill and payment and filter in the browser. The product picker on a bill line is a plain list with no search.
  • KPI figures and Overdue use the UTC date; the forms' default dates use the person's own calendar date.
  • Month-to-date figures use the UTC month, so on the 1st before 08:00 Malaysia time a payment dated today does not count yet.
  • A lost response cannot be told from a failed save. The screen says it could not confirm the change and asks the person to reload the page and check the list; it does not refresh by itself, which could reload the tab and lose the form. A retry after a save that did land can create a second bill, a second invoice, a second quotation, a second credit note, a second part-payment, a second receipt or a second refund, until writes carry a client-generated id (which needs a migration). A second credit note is refused when it is issued (by Save and issue, or by Issue) once the two together exceed the invoice's total; a second draft is not refused, since a draft is not counted. A second refund is refused once the two together exceed the customer's credit. Below those caps both go through. A second draft credit note or pending refund is deleted; a second issued credit note or paid refund is voided. A second draft quotation can be deleted; a second one that was marked as sent cannot, and is marked declined. Making an invoice from a quotation is not affected: a quotation has one invoice at a time, so a retry is refused.
  • Payment dates are not restricted: "pay now" accepts a future date and "schedule for later" a past one.
  • A payment form left open while its account is archived is refused when it is sent (FIN37), and so is posting a draft whose account was archived since: the person restores the account or picks another. The forms do not say an account is archived before that, except that a draft's own archived account is still listed in its picker. The balance of an archived account still shows on Banking while it is not zero.
  • A payment that was scheduled or waiting for approval on an account before 20261116110000 and whose account had been archived by a direct write is not approved or marked paid until the account is restored. The archive rule (FIN30) has refused such an archive since 20261114110000, so none is expected.
  • Voiding a paid bill is offered and refused until its payments are voided. The same goes for an invoice that has payments.
  • Posted and void bills, invoices and payments cannot be deleted, by design.
  • A bill's FIN01 sentence and a payment out's FIN09 sentence end "Void it instead.", which is wrong when the thing just tried was a void of something already void. Invoices and payments in have sentences that are true in that case too; the purchase side was left as it shipped.
  • A unique-violation (23505) while recording a payment is worded "Each bill/invoice can appear once on a payment." That is true while document numbers only ever come from the counter. Once a later stage lets a person change a prefix or reset a counter, a number collision would get that sentence wrongly and needs its own.
  • Approval is enforced for expenses and for money out that settles no document. A payment to a supplier and a refund are not held for approval; see Approval.
  • Posting goes through database functions that run as the caller. An editor writing to the tables directly, rather than through the app, could still post a bill, an invoice, or a payment that is exactly its split, with a number of their own choosing, or change the account of a scheduled payment that nobody had to approve. (Lowering the amount below the split, or changing the direction of a payment that is split, is refused. Raising the amount above the split is allowed while the payment is a draft, but an editor cannot then schedule it or pay it, and cannot raise a scheduled one: the part above the split needs an owner or admin.) A payment can no longer be created already posted. Numbers stay unique per workspace either way. Closing the rest needs posting functions that run with their own rights.
  • Contacts and products load the first 1,000 by name; search is in the browser. Only the contact list is capped this way: a contact's Payable is read by paging through every open bill, and its Receivable through every open invoice, 1,000 at a time, so neither is cut short.
  • A payment in cannot be edited or deleted, only voided, and a posted invoice only voided. To correct one, void it and record it again.
  • Invoices and Payments In load every invoice and payment and filter in the browser, as the purchase screens do.
  • "Largest open balances" on Customers & Suppliers charts what is owed to suppliers only; Receivable is in the card, the donut and the table.
  • An invoice's e-Invois status and UUID are what a person typed. Nothing checks them against LHDN, and the UUID's format is not checked. "Validated" does not require a UUID.
  • Saving an invoice does not check that its customer is ticked as a customer, as saving a bill does not check its supplier; the forms offer only the right contacts.
  • A bill's lines are read back by created_at then id, which for lines saved together is no particular order. Invoice lines store their position.
  • finance_mark_payment_paid refuses money in. It is used as it is for every kind of money out: a pending refund, a scheduled payment to a supplier and an approved voucher are all payment vouchers. The functions of each screen do not rely on that: markRefundPaid, voidRefund and deleteRefund read finance_refunds first and refuse anything it does not show, the Payments Out functions read finance_payments_out first in the same way, and voidPaymentIn reads finance_payments_in. Each is two requests, a read and then a write, not one statement: a transaction that stopped being what the view showed between the two would still be written. One payment pays one kind of document, and nothing in the app can make a payment stop being what the view showed.
  • A refund cannot be edited, pending or paid: delete a pending one and record it again; void a paid one.
  • An issued credit note cannot be edited: void it and write a new one. It cannot be voided while a refund depends on it; "Void" is offered all the same and refused with the reason.
  • The credit note form offers an invoice for its total less what is issued against it. Two drafts for the same invoice can together exceed that; the second to be issued is then refused.
  • "Refund" is offered on an invoice's row and on a credit note's row only while something can be refunded there. A customer is only ever in credit because of a credit note: a payment in cannot exceed what is owed.
  • A credit note or a refund made from an invoice's row is saved on its own screen; once the card's save succeeds, Invoices says so in a note with a link and does not go there by itself. There is one case with no note: Save and issue saved the draft but could not issue it, the card stays open with the reason, and the person then cancels. The draft is on Credit Notes and nothing on Invoices says so.
  • An id that is not an id sent to markRefundPaidAction, voidRefundAction or deleteRefundAction is answered "That payment no longer exists.": a refund's id is parsed by the schema a payment's is. Only a direct call can send one.
  • A draft cash receipt against invoices is on Payments In with the status Draft, and holds its part of each invoice until it is posted or deleted. It is made, changed, posted and deleted on Cash Receipts. Payments In offers nothing on such a row, and shows it under the status "All" only.
  • Nothing on Invoices says what a draft receipt holds of an invoice (the payment forms do, under each invoice), and nothing on a draft receipt's row says when its invoice was credited since and no longer owes what the draft holds.
  • A receipt against invoices can be voided on Payments In or on Cash Receipts. A receipt for income can be voided on Cash Receipts only.
  • Anyone who may write can change or delete any draft receipt, whoever saved it. created_by stays the person who first saved it, and nothing records who posted it. Two people saving the same draft at once: the later save replaces the earlier, with no warning.
  • A draft receipt keeps the date it was given when it is posted later: posting does not move it to the day it is posted. The Post question shows the date.
  • A draft receipt stays in the table in every period; once posted it is listed by its date, so posting a draft dated outside the chosen period takes it out of the table until a longer period is chosen. The screen says so when it happens, from the form's "Post receipt" and from a row's Post ("Receipt RC-0012 dated 30 Oct 2026 was posted. It is outside this month: choose a longer period to see it."; never for All time, which holds every date). It says too when a posted receipt is hidden by the Draft or Void status filter, and when a receipt is voided and the filter is "All except void" or Posted ("Receipt RC-0012 was voided. Choose Void in the status filter to see it."). A draft saved with a date outside the period needs no note: drafts show in every period. A draft is hidden by the Posted or Void status filter, by search text, or by the ten-row limit until "View all", and no note is given then (saving a draft is not posting it). A receipt just posted or voided that search text or the ten-row limit hides gets the note too ("Receipt RC-0012 was posted. Clear the search or choose View all to see it."). The note is worked out each time the screen draws, from the rows listed then, so changing the period, the filter or the search updates or removes it; Close and the next action remove it too. A receipt that was listed when it was posted or voided gets no note later: the first change of the period, the filter, the search or "View all" forgets it, so a note never turns up by itself under the control being used (Payment Vouchers and Expenses do the same).
  • A category cannot be deleted from the app, only archived, and the card does not say how many expenses, bills or payments are filed under one.
  • The Categories card is closed to a member: they see a category only in a picker or on a row, and an archived one that nothing they can see uses, not at all.
  • Renaming a category renames it on everything already filed under it, posted documents and past reports included: a document stores the category's id, never its name as it was.
  • The database lets an owner or admin archive the category invoices post to and the one bills default to by a direct call (invoices and bills go on posting to them: the default is found by its mark, not by active). The app refuses, in setCategoryActive, after reading the mark: a read and then a write, not one statement.
  • Two people renaming or archiving the same category at once: the last write stands. Nothing compares what the person was shown.
  • The Categories card does not show whether a category is in use, so archiving one gives no warning about what is filed under it; what is filed under it keeps it and goes on posting.
  • Chart of Accounts can archive a category's ledger account by itself, and nothing then archives the category: it stays active in the pickers, and the card goes on saying "Active".
  • A payment voucher draft whose category was archived since opens its form with that category in the box, marked "(archived)" (voucherCategoryChoices); a new voucher is offered the active categories only.
  • A category name that ends in a tab, or is made of a newline, is accepted: the rule trims spaces only (finance_categories_name_check), and the Categories card's schema trims spaces only and counts the 80 characters by code point, as the table does, so the card takes the same names.
  • An expense whose typed vendor is an empty string can be made only by a direct write to the table, and paying it records a voucher to nobody. A later rule should refuse an empty vendor name.
  • A sent quotation cannot be edited: mark it declined and write a new one. "Mark as sent" is offered on a draft whatever its total, and refused with the reason when its lines total nothing, as Post is on an invoice.
  • A new quotation is valid for thirty days until the person changes the date; the thirty is not a setting.
  • Making an invoice from a quotation does not open that invoice: the screen says a draft was made and links to Invoices, where the draft carries the person's own date of that day and so is among the newest rows. The link does not single the draft out.
  • "Make invoice" takes the date from the browser's clock, as every form's default date does. A device whose clock or time zone is wrong dates the draft wrongly; the note says which date it was given, and the date can be changed on the invoice form before the invoice is posted.
  • A credit note is one amount with a reason. It has no lines and does not say how much of it is SST.
  • A quotation has no void. One that was sent by mistake is marked declined, and then counts as declined in the conversion figure. The question asked before marking one declined says that a quotation cannot be voided.
  • A customer's credit on one invoice is not taken off what they owe on another, in any total. To use it against another invoice, refund it and record a payment in.
  • Voiding a payment in after its invoice was credited and refunded is allowed: the invoice then shows the customer owing again, the refund included, which is what is true. A refund that was still pending at that moment can no longer be marked paid (FIN05); delete it.
  • That second look at a pending refund locks the invoice, so two refunds paid out at the same moment are checked one after the other. Voiding a payment in does not take that lock, so a payment in voided at the very moment a pending refund on the same invoice is marked paid can slip past it; the invoice then shows the customer owing.
  • A rejected payment is final: no update moves it to another status, so one that held nothing while another payment took its place cannot be brought back on top of that one. A rejected refund that is paid out by a direct update is still refused by the refund checks first (FIN04, FIN05), then by this rule (FIN14).
  • finance_mark_payment_paid locks the payment and then, for a refund, its credit note and invoice. A direct insert of an allocation on that same payment at that moment used to lock the invoice first and then wait for the payment, so the two could deadlock. The guard on allocations now locks the payment first, so the insert waits for the payment and is then refused, because the payment is paid (FIN09). The app never adds an allocation to a payment that already exists.
  • Two direct writes that each add an allocation to the same existing payment at the same moment both hold a share lock on it and do not wait for each other. Each is checked against its own document under that document's lock, but the two together can exceed the payment (the check that a split is no more than its payment reads the other allocations without a lock). The payment is then less than its split, never more, so no money leaves that was not approved. Not possible through the app, which splits a payment only in the transaction that inserts it.
  • An approval is sent by id, and approves the row as it is at that moment, not as the approver last saw it. A pending expense can be edited in place by anyone who may write, up to the moment an owner or admin approves it: if the edit lands first, the approval is of the edited amount. A waiting payment cannot be edited, but someone who may write can delete it and record another under the same id. The parts that add the Approve buttons, for expenses and for vouchers, should send what the approver saw (the amount at least) with the update, and approve only if it still matches.
  • Two direct transactions that each add an allocation to the same existing payment and then update that payment can deadlock, each holding a share lock the other's update waits for. An allocation moved from one payment to another locks both, the old one first, so two direct updates that swap allocations between the same two payments in opposite directions can deadlock too. Postgres ends one of the two with an error. Neither can be sent as one request through the API, and the app does neither.
  • When a scheduled payment is paid, its amount is measured against its split once more. No signed-in person can now build a scheduled payment that fails that test, so it is a safeguard for rows that were scheduled before these rules and for a split changed by a session with no signed-in user; the check script reaches it that way.
  • A session with no signed-in user (a migration, a seed) may delete or lower the split of a scheduled payment out, which a signed-in person may not. The payment it leaves is not approved, and is refused when an editor pays it.
  • finance_record_payment_out adds up the amounts it is given before rounding, and each allocation is rounded when it is stored. A direct call with fractions of a sen can leave the payment a sen above its split: from an editor it is refused (FIN14), and from an owner or admin the payment is recorded as approved by them and then locked. The app rounds each amount first, so it cannot meet this.
  • The rule that a row never changes workspace is on payments, allocations and expenses only. The older finance tables (bills, invoices, quotations, credit notes, their lines, contacts, accounts, categories, products) have no such rule; none of them carries an approval.
  • A split can no longer be added by a direct write to a refund at the very moment it is paid out: the insert waits for the payment's row and is then refused, because the refund is paid (FIN09). Added just before, it is committed by the time the refund is paid out and is counted, whichever invoice or credit note it names.
  • A refund is to one customer against one invoice or one credit note. One payment out that refunds several invoices is not recorded as one refund.
  • The reason of a refund is one of five words; anything more is in the payment's notes. The Refunds table shows neither the notes nor the account; the CSV has the account.
  • A void invoice does not hold its quotation: the quotation can be converted again, and the void invoice keeps quotation_id as a record of where it came from.
  • Editing the draft invoice made from a quotation can change its customer. The invoice is then untied from the quotation without a word to the person editing it; the quotation shows no invoice and can be converted again.
  • The demo workspace's quotations, credit notes and refunds were dated from the day they were seeded, so its Sent quotations turn Expired as time passes and its month-to-date figures empty out.
  • The database tests for this stage, tests/Kira-followons.test.ts, run against the hosted database. The one that converts a quotation passes a date, so it needs 20261027100000_Kira_convert_quotation_date.sql applied there first. They create their users and workspaces through tests/helpers/live-workspaces.ts and delete them when the file finishes.
  • The demo workspace's invoices were dated from the day they were seeded, so its Sent examples turn Overdue as time passes, as its bills do.
  • The checks between a payment and its split used to read each other without a lock, so two direct writes at the same moment, one lowering the payment and one adding to its split, could both pass and leave a payment smaller than its split. The guard on allocations now locks the payment's row (see Posted is locked), so one of the two waits and is refused with FIN03.
  • Paying the same invoice twice in one payment is refused by the payment form's schema, whatever the capitals of the two ids. A direct call to finance_record_payment_in gets a unique violation (23505), which the data layer words the same way, unless the two amounts together exceed what is owed, in which case it is FIN05.
  • Stock and revenue per item are not tracked.
  • A contact's Payable counts bills that are pending or overdue, and the Payable card is totalled from every open bill, not only the contacts loaded. Receivable is the same for invoices that are sent or overdue.
  • "Mark paid" on Expenses records a voucher for one approved expense, paid now. A wrong one is undone on Payment Vouchers, not on Expenses: an owner or admin voids that voucher from its row, and the expense reads Approved again.
  • A member is offered nothing on an approved expense: scheduling a payment of it is the voucher form's.
  • A payment from an account archived after the Mark paid card was opened is refused by the database (FIN37), and the card shows the shared sentence for it: "The account this is on has been archived. Restore it on Banking, or use another account, then try again." The card offers active accounts only.
  • Two part payments of one expense made at the same moment that together fit what is left both go through: the database checks each payment against what is left when it runs (FIN05), so too much together is refused, but a deposit paid twice for a total that has room for both is not caught. The second person finds the list changed once it refreshes.
  • Any writer may change or delete any pending or rejected expense, whoever entered it: the database does not look at created_by, and neither does the screen.
  • An owner or admin who saves over someone else's pending expense approves it. To change it and leave it waiting is not possible for them.
  • A rejection carries no reason: the table has no column for one.
  • Two people changing one expense at once: the later save wins without a warning. An expense approved meanwhile answers that it is locked.
  • An expense's id and org_id can still be chosen by whoever inserts it, and approved_by and approved_at are still granted to the authenticated role: the guard, not the grants, refuses a hand-written value there.
  • A decision on an expense, and a payment of one, do not compare the names shown: a contact renamed after the screen was drawn is approved or paid under its new name. (A category can no longer be renamed by a member.)
  • An expense's category can be one that was archived since; the form keeps it in its picker for that expense only. finance_save_expense accepts an archived expense category from a direct call, for a new expense too: it checks the category's kind, not whether it is active. So an expense form that was open when an owner or admin archived its category on the Categories card still saves under it; the receipt, voucher and bill functions check the kind only, in the same way.
  • A rejected expense that a voided payment still names cannot be deleted (the allocation's foreign key); it stays in the list.
  • A payment to a supplier or a refund recorded by someone who is not an owner or admin is not held for approval. The owner decides whether it should be.
  • A voucher that settles no document, recorded by someone who is not an owner or admin, waits for approval even when it was asked to be paid now. Once approved it is scheduled, and is then marked paid with its date.
  • A payment waiting for approval cannot be edited: it is approved, rejected or deleted, and a deleted one is recorded again. A rejected one cannot be reopened: delete it.
  • An expense's amount has its SST inside it, where a bill line has its SST on top. An expense has one category and no lines.
  • An approved expense cannot be edited. An owner or admin rejects it, while nothing has been paid against it, and it is then edited back to pending.
  • An expense that a voided or rejected payment still names cannot be deleted: the voided payment keeps its split as a record, and a rejected one keeps its split until it is deleted.
  • An expense can be paid in parts; it reads Approved until posted money out covers it. One voucher can pay several expenses, and they need not share a vendor: who is paid is what the voucher says.
  • A cash receipt for income and a voucher that settles no document need not name who paid or was paid.
  • A draft cash receipt is not looked at again when it is posted. If its invoice was credited in the meantime, which a credit note's cap allows whatever a draft holds, posting it leaves the customer in credit by the overlap, as paying first and crediting afterwards does. A refund pays that out.
  • Whether a receipt names invoices or a category, and whether a category is an income or an expense one, is checked by the database functions, not by the guards. A direct write to the tables can give money out an income category, or post a receipt that is partly split and partly not. The guards enforce what protects money and approval; the shape of a document is the functions' business.
  • "This year" on Cash Receipts and Payment Vouchers is the calendar year: there is no financial-year setting.
  • Cash Receipts, Payment Vouchers and Expenses load every row and filter in the browser, as the other finance screens do. A chart names its four largest slices and adds the rest up as one.
  • On Payment Vouchers the Kind of a supplier payment or a refund links to the screen that changes it; the link does not single the row out there.
  • The sample shown with no database has its own four supplier payments on Payment Vouchers; they are not the rows of the Payments Out sample.
  • The demo workspace's cash book was dated from the day it was seeded, so its month-to-date figures empty out as time passes. Its seeded approvals name nobody: approved_by is empty on them.
  • The database tests for this stage, tests/Kira-cash-book.test.ts, run against the hosted database as owners only. What an editor and a viewer may not do is proved by supabase/tests/Kira_cash_book_check.sql.
  • Banking can add and change accounts and move money between them, but no statement can be imported until its form is built; the database function is there and nothing calls it. A statement exists only in the demo workspace.
  • Only an owner or admin can record a transfer between accounts, and only an owner or admin can void one. Nothing waits for approval: anyone else is refused (FIN14), and a member has no way to ask for either and is shown neither.
  • A voided transfer is on no screen but the ledger's: Banking's table lists posted movements only. A transfer cannot be edited, and no other row of that table has a menu or a link to the screen that recorded it.
  • A transfer's date is not restricted: a future date moves both balances at once, as it does for any payment.
  • finance_account_balances has no account number. A writer reads account numbers with finance_account_numbers(target_org); a viewer cannot read them at all. The Banking loader calls that function for a writer only.
  • finance_record_transfer runs as its owner, so row-level security does not check what it does: its own first test (may the caller write in target_org) and the workspace named in each of its statements do. A change to it must keep both. The same is true of the six functions that number a payment and of finance_next_number.
  • A bank charge recorded by someone who is not an owner or admin waits for approval, though the bank has already taken the money.
  • An account's balance counts every posted movement, whatever its date. The opening date is a label. A movement dated before it still counts.
  • A movement is reconciled only by a statement line matched to it. Cash accounts, and bank accounts that have never had a statement imported, show a dash instead of a reconciliation status, and the "Reconciled" and "Unreconciled" filters leave those rows out. One bank line that covers two payments, or two lines for one payment, cannot be matched.
  • The same statement can be imported twice; nothing warns.
  • Sides of a transfer that are all drafts are not checked: a direct write can leave a lone draft side. It moves no money, shows on no screen, and cannot be posted.
  • Banking loads every movement and filters in the browser. The running balance orders two movements on one day by when they were recorded, which need not be the order the bank had them in.
  • The reconciliation gauge counts posted movements dated in the current month on bank accounts that have had a statement imported. It reads 99% while one is unmatched and 1% while one is matched, however close the figures are to all or none. The cash position chart is today's total with later movements taken back out, so a changed opening balance moves every month.
  • No signed-in person can post the two sides of a transfer by hand: the number column is closed to them. The database owner still can (a migration or the SQL editor, as the demo seed does), may give the two sides any number, one another kind of payment already has included (RC-0001), and may use an archived account; the pair check is the only rule such a write meets. Rows are keyed by id, and no code looks a payment up by number.
  • An opening balance is an owner's or admin's to set, change or remove, but no record is kept of a change: the account's updated_at moves and nothing says what the balance was before or who changed it.
  • Any writer may rename, archive or restore any account, an owner's included, and two people editing one account at once: the later save wins.
  • A bank account and its ledger account are kept in step one way only: a rename, archive or restore of the bank account is followed by the ledger account, but a rename or archive done to the ledger account in the Chart of Accounts leaves the bank account as it was. Sign-up gives the seeded accounts no opening balance, and must keep doing so: the guard would refuse one (FIN14), because the new user is not yet an owner while the seed runs.
  • An account's kind cannot be corrected once it is added. An account made as the wrong kind is archived (or deleted by hand if nothing used it) and added again.
  • The 1,000-line limit is the import function's, not the tables': a direct insert can add more lines to an import. Nothing reads the count for a rule.
  • An account, a category and a contact that nothing refers to can still be moved to another workspace by someone who writes in both; the two statement tables refuse it.
  • The database tests for Banking, tests/Kira-banking.test.ts, run against the hosted database as owners, with one member in one test and the demo's viewer in another. What an admin, an editor and a viewer may and may not do, at length, is proved by supabase/tests/Kira_banking_check.sql, which needs every finance migration up to 20261031090300 applied. Two parts of it run as the database owner with a person's id in the claims, because posting two sides by hand is no longer something a signed-in person can try.
  • Reading a receipt with AI sends the whole file, with whatever a phone wrote into it (the question says so), and the names of up to sixty expense categories. It is one call with no retry: a model slower than 45 seconds is told as "could not do the reading just now", nothing is stored, and the press has still used a turn of the daily limit and possibly credit. What is bounded today: the file (10 MB at most, a JPG, PNG or PDF; a PDF over 4 MB, RECEIPT_PDF_MAX_BYTES, is not sent, decided from the stored size before the key is opened, the file fetched or a turn counted, and again on the bytes if no size was recorded), the answer (300 tokens), the time (45 seconds), and the person's 200 turns a day on the workspace's key, shared with chat. What is not: a PDF's pages are not counted, so a many-page PDF under 4 MB costs more input than a photo, and a workspace has no ceiling of its own across its people. How well the configured model reads a Malaysian receipt is the model's. A PDF is read only by a model that takes PDFs natively: the request names OpenRouter's native file-parser engine (RECEIPT_PDF_ENGINE), so OpenRouter's own parsers, one of which is billed per page even when the model is free, are never used. A model without PDF input answers with an error (400, 404 or 422, also when it comes in the body of a 200), told as "The AI model chosen on this server did not accept this file…" with nothing written. A busy or unreachable provider is told as "could not do the reading just now", and an answer that is not the five fields as "nothing could be read".
  • A reading's answer is shown only if the person still has that receipt's question open when it arrives. If they opened something else meanwhile, nothing new opens; what was stored shows on the row, and a refusal is said in a note above the table.
  • A suggested category is kept on the screen only. Reloading the page, or opening the inbox elsewhere, forgets it; the four details are stored. Storing it would need a column on finance_receipts, so a migration. If someone else has the same receipt read again meanwhile, this screen keeps showing the category from its own reading until it is reloaded.
  • The inbox's route file is generated and carries no maxDuration, so on a host that limits how long a server action may run, a reading must fit that host's default. The production host is a persistent server with no such limit.
  • While a receipt is being read (up to a minute), the other actions of that browser tab may wait: the framework today sends a page's server actions one at a time, so a download, a review or an upload's recording started meanwhile begins when the reading ends. The card says so.
  • Matching: a receipt matched while still in review keeps whatever its four details held, so a matched row can show dashes. The picker lists eight documents at a time and searches what the page loaded. An expense's row has no "Attach receipt" item: attaching starts from the receipt. Two people matching one receipt at the same moment: the second is told "This receipt is already matched. Untie it first." A receipt can stay matched to a rejected expense or a void bill until someone unties it; the second line under "Matched to" is what shows it. The inbox's loader reads every expense and every bill of the workspace once the inbox holds a receipt.
  • The browser's check of a file's first bytes can be skipped by someone who calls the storage API themselves. Such a file is stored under one of the five types; the server's own check then refuses to open it, and it is never previewed. Nobody is shown it as a page: it is only ever a download.
  • No virus scan. A PDF is opened by the person's own viewer.
  • An upload refused by the bucket's rule says the workspace may be out of room or the person may no longer write: storage gives one answer for both.
  • A second tab does not see what the first one added or deleted until it is reloaded. Closing or reloading the tab while files are on their way asks first; moving to another screen of the app does not, and the queue keeps going in the background. Files that were stored and not recorded are removed by the clean-up run.
  • A JPG or PNG is uploaded as the person's device made it, so its metadata stays in it, a phone's location included. Only a HEIC that was converted to a JPG loses its metadata, because that JPG is drawn afresh from the pixels.
  • There is no upload percentage, and a dropped connection fails that file; Try again then uploads it from the beginning (resumable uploads were left out on purpose).
  • "Try again" on failed files can put more than twenty in the queue: a failed file does not count toward the twenty, and the retry does not ask for a place. Files still go one at a time and nothing is dropped.
  • The download name on a signed link is added to the link by the client and is not covered by the link's signature: whoever holds a link can drop it, and storage then serves the file inline with its declared type. Nothing in the app relies on "a link is always an attachment" as a guarantee. What protects a file is that the bucket only stores the five declared types, none of which a browser runs as a page or script, and that it is served from the storage origin, where the app has no session.
  • A signed-up member of the demo workspace (not a guest: an account the operator made a member) can review and delete the demo's sample receipts, as with every finance table. Uploading there is refused for everyone.
  • An image larger than about 10 MB cannot be stored, so a preview fetches at most that; nothing makes thumbnails.
  • Permissions-Policy: camera=() is sent on every page. "Take photo" is a file input that opens the phone's own camera app, which that policy does not govern; it is checked on a phone in the smoke test.
  • The guard reads what the storage service writes about an object: created_at, and metadata (size, mimetype). If storage leaves metadata empty the receipt is recorded with no size and its type is taken from its extension alone. What is inside a file is not looked at by the database: a file is whatever the uploader's browser said it was, within the five types.
  • Room (1 GB a workspace, 20 GB the bucket) is a soft limit: the upload rule is asked before the bytes are stored, so uploads in flight at the same moment can each pass, and the overshoot is the number of them times 10 MB.
  • A member can untie a receipt from a pending expense or a draft bill while an owner's approval of that expense, or posting of that bill, is in flight, and both succeed: the guard reads the document's status without locking it. It is the same outcome as untying a moment before the approval. Locking the document in the guard would add a deadlock with "delete the pending expense".
  • A stored type is the type the uploader declared (the Content-Type of the upload); the storage service and the database check that the extension, that type and the bucket's list agree, and the server reads the first bytes when the app opens the file (storedFileCheck), not when it is recorded.
  • A signed link to a file is served by the storage service with the stored type and no X-Content-Type-Options: nosniff header (seen on the hosted project on 2026-10-12), so nothing tells a browser not to guess at the bytes. With a download name the link is served as an attachment. The part that lets a person open a file must therefore always ask for the download (or serve the bytes itself with nosniff), never link to the file inline.
  • A deleted receipt's path cannot be recorded while it is waiting to be removed. Once the server has removed the file and taken the path off the list, the same name can be uploaded and recorded again: that is a new file.
  • If the service key removes a file behind a receipt, the row stays with has_file true and its link fails. Nothing signed-in can cause it.
  • finance_expenses.amount accepts NaN through finance_save_expense (on main before this stage); finance_receipt_to_expense refuses it itself. The expense table wants its own one-line constraint.
  • Anyone who may write in a workspace can record a colleague's upload as a receipt during the 30 minutes after it was uploaded, if they learn its path. It is recorded in the same workspace either way.
  • Someone who may write in workspace A and names a path under workspace B when recording a receipt is answered FIN24 when no such file is there and 23514 when one is. A path is a random uuid, so there is nothing to guess; a check of the folder before the file is looked up would close it.
  • A receipt stays matched to a bill that is voided afterwards (by an owner or admin: voiding is a sign-off). Nobody unties it for them.
  • extracted_at and extraction_error are writable by anyone who may edit, directly as well as through finance_set_receipt_extraction: the function runs as the caller, so the columns cannot be closed without making it run as its owner, and it could then be called by hand all the same.
  • finance_set_receipt_extraction stamps extracted_at for any reading given with no error, an empty one included. Whoever calls it must report a reading that found nothing as a failure; the reader does (normalizeReading answers nothing, and the action writes a failure).
  • finance_set_receipt_extraction sets all four details from what it is given, error or not, so a failure written with the error alone clears what a person typed. The action sends the values the receipt held back with the error; any other caller must do the same.
  • finance_set_receipt_extraction runs as the caller, like every finance function, so someone who may edit can call it by hand and a receipt would then read "Read by AI". The app only calls it with a model's answer.
  • A HEIC or HEIF that could not be turned into a JPEG is stored as it is. No browser but Safari draws one, so it is a download, and it is never sent to a model: "Read with AI" on one says to type the details in or upload it again as a JPG or PNG.
  • A bill made from a receipt is a draft with no number, so the inbox reads "Draft bill" until it is posted.
  • The demo's receipts have no file.
  • A file put in the bucket through the API and never recorded stays until the clean-up run takes it, an hour after its upload. That needs the operator's scheduler to call /api/finance/receipt-cleanup; until it does, only the file of a receipt deleted through the app is removed.
  • A receipt that leaves review without a person's review (tied by finance_attach_receipt, or turned into an expense or a bill) stops saying "Read by AI", so a model's figures can sit on a matched row with no caution. Stage 7d makes this the usual path for an expense or a bill made from a receipt, where the person has just checked the figures in the form; for a plain attach nobody has. The forms that make an expense or a draft bill from a receipt say "These figures were read by AI: check them against the receipt." while the reading still waits for a person; a plain attach copies no figure into the document and says nothing.
  • The room limits (1 GB a workspace, 20 GB the bucket) add up every object in the bucket on each upload. That is one index scan today; it wants a stored total if the bucket grows to hundreds of thousands of files.
  • The database tests for this stage run as owners only, and their owners are confirmed email users, not anonymous ones, because the bucket refuses anonymous accounts. They remove their own files; a run that is killed half-way can leave files in the bucket, which the clean-up run takes after an hour.
  • An RFQ can be compared only with the RFQs copied from it, directly or through another copy, with Copy to another supplier after 20261116090000_Kira_rfq_source, and those copies with each other. RFQs copied before it, and RFQs typed separately for the same request, carry no link to each other, and nothing joins them afterwards.
  • Compare quotes matches lines by unit and pack size as they were typed. A carton of 12 is not compared with singles: they are two rows, each with one price and no mark. pcs and pc are two rows as well; a capital or a stray space is not a difference.
  • Deleting the first RFQ of a group, which only a draft allows, clears the link on its copies for good: they cannot be compared with each other after that. The delete question says so when RFQs were copied from the draft. A copy made afterwards from one of them starts a new group of those two.
  • Viewers and the demo cannot open the comparison, although it only reads: getRfqComparisonAction answers a person who may edit, and the row menu it is opened from is drawn only for them.
  • 20261116090000_Kira_rfq_source must be applied before this code is deployed. The RFQs list reads source_rfq_id from finance_rfq_totals, and without the column the screen does not load. The other order is safe: the code before it sends no link and reads the view's columns by name.
  • Invoices in another currency (20261120100000): a rate marked as Bank Negara Malaysia's is accepted for any BNM 17:00 selling rate dated on or before the date of supply (or the invoice date), not only the newest one (FIN39 checks that the figure is BNM's for its own date, and that the date is not after the date of supply). The form offers the newest and asks again whenever the date changes, and says when the rate it holds is more than seven days old; a direct call can still post at an older BNM rate.
  • Payments in on invoices in another currency (20261120110000, stage 3b): Cash Receipts offers ringgit invoices only, but a draft cash receipt naming an invoice in another currency can still be saved by a direct call (before 3b that save was refused with FIN41). It holds its part of the invoice until it is deleted, and posting it is refused with FIN45: such an invoice is paid with Record payment on Invoices, which takes the ringgit received.
  • Credit notes and refunds on invoices in another currency (20261120120000, stage 3c): FIN41 is no longer raised. A credit note is in its invoice's currency at its invoice's rate (the database writes currency, fx_rate and, when it is issued, amount_myr, whatever the caller sent), so it makes no exchange difference. A credit note is the one movement that can take an invoice past zero; on a paid invoice it takes its amount at the rate, and on one that owes less than the amount it takes exactly what is open plus the rest at the rate (private.finance_fx_carried). Its amount_myr may be 0.00 (a few units of a currency worth very little). A refund on such an invoice, or on a credit note of one, is paid from a ringgit account with the ringgit paid out (FIN46 without it) and is paid out now: asked as scheduled it is refused with FIN47. The finance assistant records credit notes and refunds on ringgit invoices only. A payment in on such an invoice is not voided while a credit note issued (or dated) after it stands on that invoice (FIN48): void the credit note first, then the payment, and issue the credit note again, which then takes the closing figure, so Receivable ends at 0.00.
  • Supplier bills in another currency (20261121100000, stage 4a): a bill takes invoices' columns (currency, fx_rate, fx_rate_source, fx_rate_date, fx_rate_provider, fx_rate_reference) and its lines a copy of the rate with generated amount_myr and sst_amount_myr. The rate is the one rule's (Bank Negara Malaysia's 17:00 selling rate, newest on or before the bill date: private.finance_sst_fx_rate), or one typed by hand with its source (FIN43); posting needs a rate (FIN38), and a rate marked as BNM's must be the rule's for a date not after the bill date (FIN39). The ledger posts the bill's ringgit (the category debited and Payable credited with the sum of its rounded ringgit lines; SST stays part of the cost), and every payable figure (Overview, Supplier Bills KPIs, Contacts, the payables ageing, the assistant's payables) adds balance_myr. Nothing pays a foreign bill in this stage: a payment out or a payment voucher naming one is refused with FIN41 (the payment form and the voucher picker offer ringgit bills only), and a contra entry never sets one off (FIN42, which now names bills too, through all of stage 4). A bill raised against a purchase order is in the order's currency (FIN49); every order is in ringgit until orders carry a currency. The finance assistant saves bills in ringgit only, and a bill made from a receipt is a ringgit draft. finance_ledger_lines already carries the payment-out realised line the next stage needs; it reads only allocations' amount_myr/carried_myr, which nothing writes on a bill allocation yet, so it is 0.
  • Payments out of bills in another currency (20261121111000, stage 4b): FIN41 is no longer raised. A foreign bill is paid from a ringgit account on Payments Out, now: finance_record_payment_out takes the ringgit that left the account as payment->>'amount_myr' (FIN46 when it is missing, not above 0, or NaN), refuses one asked as scheduled (FIN47), and asks one currency per payment before anything is written (FIN44). The split stays in the bills' currency; private.finance_fx_settle shares the ringgit over it (in proportion, to the sen, the remainder to the largest, ties to the lowest document id) and writes each allocation's amount_myr and carried_myr, which private.finance_fx_carried_bill works out: the part at the bill's rate, never more than the ringgit still open, or, for the payment that brings the bill to zero, exactly the ringgit still open, so a bill paid in full leaves Payable at 0.00. Carried less paid out is the realised gain (above 0) or loss, posted to Realised Exchange Gain or Loss. A payment voucher never names a foreign bill (FIN47; the voucher picker offers ringgit bills only), so a member cannot pay one. Every Payments Out figure (Paid this month, the trend, the method donut, To be paid) and Supplier Bills' Paid this month add paymentOutRinggit. The finance assistant pays ringgit bills only (recordPaymentOutInRinggit). Lines of 20261121111000 that supabase/tests/Kira_fx_payments_out_check.sql cannot pin (a single session cannot observe them), kept by review of the text:
    • bill_id in finance_fx_settle's tie key (inserts are in bill-id order).
    • The up-front for update bill lock in finance_record_payment_out.
    • The for update read of the bill's currency in finance_allocation_currency_guard (a plain read is not observably different alone).
    • The bill lock in finance_fx_settle (nearly equivalent: the allocation guard has already locked the bills).
    • The FIN04 arm of finance_fx_carried_bill (unreachable: it runs only on a posted bill, which always has a rate).
  • Bank and cash accounts in another currency (20261122100000, stage 5a): an account's currency is chosen when it is added and never changes (granted for insert only: 42501 on a change). Its opening balance is in that currency and carries opening_rate (the one rule, BNM's 17:00 selling rate for the opening date, marked bnm with its date, or typed, manual), set by an owner or admin like the balance itself (FIN14); a balance in another currency with no rate is FIN38, a bnm rate that is not the rule's figure or is dated after the opening date FIN39. opening_balance_myr is generated, and the opening entry posts it. finance_transactions carries currency, fx_rate, fx_rate_source, fx_rate_date and amount_myr (null on a ringgit account's movement, meaning the amount; every reader uses coalesce(amount_myr, amount)), written only by finance_transaction_to_currency and by functions that run as the owner. No money moves on an account in another currency yet: any movement inserted on one, or a draft moved onto one, is FIN50, and every money picker lists ringgit accounts only. finance_account_balances appends the currency, the rate and balance_myr (the ringgit book value), and finance_bank_transactions the movement's currency, rate and ringgit; Banking shows each account in its own currency and adds ringgit for Total cash and the trend.
  • Money on an account in another currency (20261122110000, stage 5b): Record payment on Invoices, Payments Out and Refunds take an account in another currency for documents in that currency (FIN51 for any other, an expense included), and a category voucher and an income receipt (the new finance_record_cash_receipt, record and post in one call) are paid from or into one. The movement is in the account's currency, with its rate (the one rule's for its date, or typed; FIN38 without one, FIN39 for a bnm rate that is not the rule's figure or is dated after the movement), paid now by an owner or admin (FIN47 for scheduled or waiting for approval). The trigger writes amount_myr = round(amount x rate, 2); private.finance_fx_settle shares that ringgit over the documents as for a ringgit account, so a document paid in full leaves Receivable or Payable at 0.00 and the realised difference posts on fx_realised. FIN03 and "money out that settles no document" compare a document in the payment's own currency with it. finance_transactions.fx_rate has no default any more: a draft on such an account (the draft functions write no rate) is FIN50, as is a draft moved between accounts in different currencies and, until 5c, a side of a transfer; a ringgit row still gets rate 1 from the trigger. The five payment and cash-book views append the movement's currency and ringgit (transaction_currency, transaction_amount_myr; finance_payment_vouchers currency, amount_myr), and every cash-book total adds ringgit.
  • Transfers between a ringgit account and an account in another currency (20261122120000, stage 5c): finance_record_transfer takes to_amount, what arrived in the receiving account's currency (FIN16 when it is missing or not above 0; between two ringgit accounts it is left out, or must equal the amount). Both sides hold one ringgit figure, the ringgit side's amount: the other side keeps it as its amount_myr and its rate is round(ringgit / foreign, 10), source transfer, which finance_transaction_to_currency keeps as written (never recomputed). The pair check holds two sides in one currency to one amount and a ringgit side and a foreign side to one ringgit figure (FIN16). The ledger posts that figure on both lines and no gain or loss; a conversion at another rate than book leaves a remainder in the account's ringgit book value for stage 6. Two accounts not in ringgit, the same currency included, are FIN52 (finance_record_transfer and the pair check); FIN50 no longer names a side of a transfer. The form asks for both amounts when the currencies differ, shows the rate they say, and hides foreign accounts from "To" when "From" is foreign; Banking names the other side's amount on a transfer row.
  • Month-end FX revaluation (20261123100000, stage 6a): every workspace has Unrealised Exchange Gain or Loss (fx_unrealised, 4910, income). At a month end E (the last day of a month, on or before today in Malaysia: FIN53) private.finance_fx_revaluation_items works out every foreign item in the books on E (an open foreign invoice, an open foreign bill, a foreign bank or cash account, archived ones included) from the stored figures, with the ledger's own rule for what is in the books on a day, voids included (private.finance_fx_weight): its foreign balance, the ringgit it carries (an account's book value includes posted journals on its ledger account), what it revalues to at the closing rate (round(balance x rate, 2); 0 for a balance of 0, which needs no rate), the difference, what earlier runs for E already posted, and what this run posts. The closing rate is BNM's 17:00 selling rate per one unit, the newest on or before E (private.finance_fx_closing_rate, never the stored middle rate), or typed (manual); a bnm rate that is not the rule's figure and date is FIN39, a missing or unusable one FIN38. finance_fx_revaluation_preview (any member) shows the items and fills BNM's rate where none is sent; finance_post_fx_revaluation (an owner or admin, FIN14) works them out again under a lock and posts them only if every item (the preview's digest of each item's figures), the number of lines and the net gain are what the caller saw (FIN55), never for an earlier month end than one already posted (FIN54) and never with nothing to post (FIN56; no number is used). A run (FX-0001) with its rates and lines is written whole and never changed or deleted (FIN01, even by the table owner); the three tables grant the signed-in select only. finance_ledger_lines posts each run net per ledger account (Receivable, Payable the other way round, each revalued bank account) with one Unrealised line that balances it, dated E, and the same lines swapped on E + 1 (fx_revaluation:<id>:reverse); Cash Flow shows a bank account's line as "Exchange rate changes on foreign cash (no money moved)". A second run for E posts only the change, item by item.
  • The FX Revaluation screen (stage 6b, no migration; on 20261123100000) is back in the navigation and live. It offers every month end from the first foreign item in the books (readFxBooks) to the last on or before today in Malaysia, previews the newest, and lists the items by kind (foreign balance, rate, carried, revalued, difference, earlier runs, to post, gain or loss; a fully converted account reads "Converted: no USD left; …"), the closing rate per currency with BNM's date and the stale note, and the posted runs (who posted, the net, the lines, a CSV each). Every member previews; an owner or admin also types closing rates and posts. A typed rate is worked out at once in the browser (withTypedRates, the bigint twin) and by the server half a second later; Post sends only what a preview returned (postPayload: its rates back in the box's unit, which is per 100 for JPY and the like, and its digest, lines and net gain unchanged), so a figure the server has not shown cannot be posted, and FIN55 reloads the preview. A ringgit-only workspace is told "No foreign-currency invoices, bills or accounts. Nothing to revalue." Reports adds a note under the Balance Sheet when its month end had foreign items in the books and no posted run (approximate: a run made stale by a later entry is not noticed there). Ledger entries of source fx_revaluation link to the screen; the assistant's balance sheet and ledger tools say foreign balances are at booked rates except at a revalued month end; Banking's exchange-rates card also shows for a workspace with a foreign supplier bill or account.