Skip to content

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:

  1. 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.
  2. Commit — once every red issue is cleared, the UI sends the same payload to commitOpeningBalanceImport. The backend re-validates and, if clean, writes:
  3. One skeletal Invoice for each accounts-receivable row (so the AR aging report picks them up), and bumps the matching ClientBalance.
  4. One skeletal SupplierInvoice for each accounts-payable row (for the AP aging report), and bumps the matching SupplierBalance.
  5. One LedgerEntry tagged OPENING_BALANCES covering 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 InvoiceDetail rows; the aging query never loads them.
  • Are flagged InvoiceGenerationMode.PROFORM to keep them out of PAC submission.
  • Do not create their own ledger entry. GL posting is owned exclusively by the single OPENING_BALANCES ledger 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:

  1. Upload — drag/drop CSV or XLSX, parse client-side (SheetJS), fill an editable grid.
  2. Review — call previewOpeningBalanceImport on every edit (debounced ~400ms). Cell coloring:
  3. red border on cells whose issue.field matches that column and severity == ERROR
  4. yellow border on WARNING
  5. tooltip = issue.message
  6. sticky footer shows totals.totalDebits, totals.totalCredits, totals.difference, and a red banner when !totals.isBalanced.
  7. Commit — call commitOpeningBalanceImport; if the response carries isValid: false, drop the user back to the review step with the new issues. On success, link to:
  8. The GL entry list filtered to sourceType: OPENING_BALANCES for the full posting.
  9. 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 dedicated OpeningBalanceImport aggregate (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."