Opening balance import¶
Backend domain: app/graphql/opening_balances/
Migration: none — reuses existing ledger_entries, invoices, supplier_invoices and their balance tables. Adds SourceType.OPENING_BALANCES to the existing enum.
RBAC Path: not yet wired — opening balances inherit the journal/ledger permissions.
Overview¶
The opening balance import lets a tenant load the snapshot of every GL account at the start of an accounting period. This is the first thing a customer does when migrating onto Cifras from another system.
The flow is a two-step wizard:
- Preview — user uploads / fills a sheet, the UI sends it to
previewOpeningBalanceImport, gets back per-row issues and aggregate totals, and renders the grid with cell-level error highlighting plus an equity-check panel. - Commit — once every red issue is cleared, the UI sends the same payload to
commitOpeningBalanceImport. The backend re-validates and, if clean, writes: - One skeletal
Invoicefor each accounts-receivable row (so the AR aging report picks them up), and bumps the matchingClientBalance. - One skeletal
SupplierInvoicefor each accounts-payable row (for the AP aging report), and bumps the matchingSupplierBalance. - One
LedgerEntrytaggedOPENING_BALANCEScovering every row.
If validation fails, the response carries the same validation payload with journal = null and the database is untouched.
Preview and commit run the exact same validation pass. There is no partial write — either every row passes and everything posts, or nothing does.
Service boundary¶
OpeningBalanceImportService— orchestration, validation, totals, and the single GL ledger entry. Owns no domain models directly.OpeningInvoiceService— creates the skeletal AR invoice + balance and updates the client balance.OpeningSupplierInvoiceService— same for the AP side.InvoiceBalanceRepository.add_opening_balance/SupplierInvoiceBalanceRepository.add_opening_balance— detail-less balance constructors for the two skeleton-invoice paths.
The opening-balance import service is the only entry point; the two invoice services live inside the invoices module so balance/persistence rules stay co-located with the invoice repositories.
Why skeletal invoices¶
The AR and AP aging services read from Invoice + InvoiceBalance (and SupplierInvoice + SupplierInvoiceBalance) — not from raw ledger entries. To make opening AR/AP show in those reports, we create real (but detail-less and PAC-skipped) invoice records keyed on Invoice.invoice_number = "OB-{document_number}-{invoice_id}". The skeletal invoices:
- Carry no
InvoiceDetailrows; the aging query never loads them. - Are flagged
InvoiceGenerationMode.PROFORMto keep them out of PAC submission. - Do not create their own ledger entry. GL posting is owned exclusively by the single
OPENING_BALANCESledger entry, which keeps the trial balance and the GL audit trail consistent.
GraphQL surface¶
Mutations¶
previewOpeningBalanceImport(
openingBalanceInput: OpeningBalanceImportInput!
): OpeningBalanceValidationResponse!
commitOpeningBalanceImport(
openingBalanceInput: OpeningBalanceImportInput!
): OpeningBalanceCommitResponse!
Input¶
input OpeningBalanceImportInput {
entryDate: Date!
rows: [OpeningBalanceRowInput!]!
memo: String
}
input OpeningBalanceRowInput {
rowNumber: Int! # 1-based row in the user's sheet; echoed back in results
accountCode: Int # either accountCode OR accountNumber must match an account
accountNumber: String
debitAmount: Decimal # exactly one of debit/credit must be set, > 0
creditAmount: Decimal
contactName: String # required for AR/AP rows; must resolve to a real client/supplier
documentNumber: String # required for AR/AP rows
documentDate: Date # required for AR/AP rows
dueDate: Date # optional; drives the payment_terms inference for AR rows
description: String # free-form note carried into the ledger line memo
}
Response¶
type OpeningBalanceValidationResponse {
isValid: Boolean!
totals: OpeningBalanceTotals!
rowResults: [OpeningBalanceRowResult!]!
globalIssues: [OpeningBalanceRowIssue!]!
}
type OpeningBalanceTotals {
totalDebits: Decimal!
totalCredits: Decimal!
difference: Decimal! # debits - credits
isBalanced: Boolean! # |difference| <= 0.01
}
type OpeningBalanceRowResult {
rowNumber: Int!
issues: [OpeningBalanceRowIssue!]!
}
type OpeningBalanceRowIssue {
severity: OpeningBalanceIssueSeverity! # WARNING | ERROR
field: OpeningBalanceIssueField! # ACCOUNT | AMOUNT | CONTACT | DOCUMENT | DATE | GENERAL
message: String!
}
type OpeningBalanceCommitResponse {
journal: Journal # reserved for a future Journal-backed import; null in v1
validation: OpeningBalanceValidationResponse!
}
Validation rules¶
| Severity | Field | Trigger |
|---|---|---|
| ERROR | ACCOUNT |
Neither accountCode nor accountNumber matches an account in the tenant. |
| ERROR | AMOUNT |
Both debit and credit set, both unset, amount <= 0, AR row supplied as credit, or AP row supplied as debit. |
| ERROR | CONTACT |
AR row with a name that resolves to no client (suppliers don't count); AP row with a name that resolves to no supplier. |
| WARNING | CONTACT |
Non-AR/AP row names a contact that matches no client or supplier — informational only. |
| ERROR | DOCUMENT |
Row points at an ACCOUNTS_RECEIVABLE or ACCOUNTS_PAYABLE account but is missing contactName, documentNumber, or documentDate. |
| ERROR | DATE (global) |
entryDate falls inside a hard-closed accounting period (or soft-closed for non-admins). |
| ERROR | GENERAL (global) |
rows is empty. |
| n/a | totals.isBalanced |
false when |totalDebits - totalCredits| > 0.01; blocks commit. |
isValid is true only when there are zero ERROR-level row issues, zero ERROR-level global issues, and totals.isBalanced is true. commitOpeningBalanceImport short-circuits and writes nothing when isValid is false.
Contact resolution is account-type-aware: AR rows look up Client.name; AP rows look up Supplier.name. The two lookups are independent — a name shared by a client and a supplier resolves to the appropriate side per row.
UI shape¶
A three-step wizard on top of the chart-of-accounts page:
- Upload — drag/drop CSV or XLSX, parse client-side (SheetJS), fill an editable grid.
- Review — call
previewOpeningBalanceImporton every edit (debounced ~400ms). Cell coloring: - red border on cells whose
issue.fieldmatches that column andseverity == ERROR - yellow border on
WARNING - tooltip =
issue.message - sticky footer shows
totals.totalDebits,totals.totalCredits,totals.difference, and a red banner when!totals.isBalanced. - Commit — call
commitOpeningBalanceImport; if the response carriesisValid: false, drop the user back to the review step with the new issues. On success, link to: - The GL entry list filtered to
sourceType: OPENING_BALANCESfor the full posting. - The AR and AP aging reports for verification — every AR/AP opening row now shows up there as an outstanding invoice.
What's NOT in v1 (call out to PM before shipping)¶
- No edit / re-import flow. v1 commit posts the ledger entry, the skeletal invoices, and the client/supplier balance bumps. Reversing requires a manual journal entry and manual cancellation of the affected
OB-*invoices. A dedicatedOpeningBalanceImportaggregate (with reverse-and-replace mutations that also unwind the invoices and balances) is planned for v2. - No chart-of-accounts or contacts bundled import. Those are separate features; the wizard's intro screen should link to them.
- No CSV/XLSX parsing on the server. The frontend is responsible for sheet parsing and template downloads. The backend mutation only accepts already-structured
rows.
Error states¶
| Backend behaviour | When | Frontend message |
|---|---|---|
validation.isValid == false on commit |
Any ERROR or unbalanced totals | Stay on review step; re-render issues. |
PeriodClosedError (surfaced as a global DATE issue) |
entryDate is in a hard-closed period |
"The selected date is in a closed accounting period. Pick a date in an open period." |
| Generic 500 | Unexpected DB / ledger failure | "We couldn't post your opening balances. Try again, or contact support if it keeps failing." |