CSD ERP Demo - General Ledger Test Checklist

City Schools of Decatur • RFP 26-001 • General Ledger Engine • Frappe/ERPNext Integration

0 of 117 tested
0 Passed 0 Failed 0 Pending
System Governance
001
Retain common COA structure for consolidated reporting; track COA changes with reasons and historical records
GL #1 Passed
Chart of Accounts General Ledger
  1. Navigate to /account/chart-of-accounts (sidebar: Finance → Other → Chart of Accounts)
  2. Verify stat cards show non-zero totals: Total Accounts, Asset Accounts, Liability Accounts, Revenue & Expense
  3. Click each pill tab (All / Assets / Liabilities / Equity / Income / Expense) — verify the table filters in place
  4. Type Operating in the search box — verify 1115 - Operating Account appears and other accounts are hidden
  5. Verify each row shows: Account #, Account Name, Type badge (Cash / Bank / Receivable etc), Root Type badge (Asset / Liability / Income / Expense / Equity), and “Group” tag on parent rows
  6. Navigate to /account/general-ledger — verify it loads real Frappe GL entries and the four KPI cards (Total Revenue, Total Expenditures, Net Position, Unposted Entries) populate
  7. Use the pill tabs (All / Revenue / Expense / Journal Entry) on the GL page — verify rows filter by type

Accounts are loaded live from Frappe via FrappeAccountService. Expect 130+ accounts for the DSDD demo company (CSD chart).

Chart of Accounts and General Ledger both render live data from Frappe. Filters, search, and KPI totals all reflect the real COA and posted GL entries.

002
Block an account for posting (Freeze Account) — verify blocked, verify allowed after unfreeze
GL #2 Passed
Chart of Accounts Journal Entries
  1. Navigate to /account/chart-of-accounts
  2. Search for 1115 — find 1115 - Operating Account
  3. On that row, click the ⋮ kebab menuTest Post
  4. Confirm alert: ✓ POSTING ALLOWED — Test JE posted: ACC-JV-2026-XXXXX
  1. On the same row, click ⋮ → Freeze Account
  2. Confirm the Status column flips from Active to ❄ Frozen and the alert reads Account frozen
  3. Click ⋮ → Test Post again
  4. Confirm alert: ⛔ POSTING BLOCKED (frozen) — Account 1115 - Operating Account - DSDD is frozen. Posting is blocked.
  1. Navigate to /account/journal-entries → click + New Journal Entry (opens right-side panel)
  2. Line 1: Account 1115 - Operating Account, Debit 50.00
  3. Line 2: Account 5223 - Miscellaneous Expenses, Credit 50.00
  4. Click Save Draft
  5. Confirm inline error appears under the Account cell on line 1: Account is frozen — posting blocked. and the form does NOT submit
  1. Return to /account/chart-of-accounts
  2. On the 1115 - Operating Account row, click ⋮ → Unfreeze Account
  3. Confirm status badge returns to Active
  4. Click Test Post once more — confirm alert reads ✓ POSTING ALLOWED

Freeze flag is stored server-side per company. The JE form and the Test Post action both check the guard before hitting Frappe. Frozen accounts remain readable but cannot receive new postings.

Baseline Test Post succeeds; after freezing, both Test Post and the JE form refuse to post to the frozen account with a clear inline / alert error. Unfreezing restores posting.

003
Support multiple/unlimited user-defined categories within master data
GL #3 Passed

PASSED — User-defined master data categories are managed at Finance → Other → Master Data Categories. The admin page lists all categories (Fund, Program, Function, Object, Location, Department, or any custom dimension), lets users add / edit / toggle / delete a category, and per-category lets them add allowed values. The underlying MasterDataCategory and MasterDataCategoryValue tables are unlimited in size.

  1. Open /account/finance/other/master-data-categories
  2. Click + New Category → enter name (e.g. "School Location") + description → Save
  3. Click into the new row → + Add Value → enter "Oakhurst Elementary" → repeat for each location
  4. Toggle one value to Inactive → confirm it greys out
  5. Delete a category → confirm it is removed
Master Data Categories

Master data attributes: Fund(100), Program(1081), Function(1000), Object(610), Location(Oakhurst), Department(0000). Partially present in Frappe; not exposed in Missio.

Feature not fully implemented in Missio UI — Frappe dimensions exist but require a dedicated Missio management page.

004
Post JEs automatically based on subledger transaction type and criteria
GL #4 Untested
Journal Entries AR Invoices Purchase Orders Fixed Assets Encumbrances
  1. Encumbrance → Expenditure: create an encumbrance, promote, then Convert to Expenditure — verify a new posted JE appears in /account/journal-entries with source link back to the encumbrance
  2. AR Invoice issue: new AR invoice → auto-posts DR Receivable / CR Revenue JE — verify it appears in JE list, linked to the AR invoice
  3. AR Payment apply: on any open invoice click Apply Payment → auto-posts DR Cash / CR Receivable JE
  4. AP receive goods (3-way): on any open PO click Receive Goods → auto-posts DR Expense / CR RNI JE
  5. AP match invoice: Match Invoice action → auto-posts DR RNI / CR AP JE (for 3-way) or DR Expense / CR AP (for 2-way)
  6. FA acquire: new fixed asset → auto-posts DR Asset / CR Cash JE
  7. FA depreciate: Run Depreciation → auto-posts DR Depreciation Expense / CR Accumulated Depreciation JE
  8. Recurring template Run Now: auto-posts from the template lines, optionally followed by a reversal JE
  9. Every auto-posted JE has source_type/source_id populated so clicking the row drills back to the originating record

Seven separate subledger modules (Encumbrance, ArInvoice, ArPayment, ApGoodsReceipt, ApVendorInvoice, FixedAsset, RecurringJeTemplate) all route through the same JournalEntryLine + FrappeClient::createAndSubmit pipeline. Period-close, frozen-account, and approval guards apply uniformly.

All subledger transaction types auto-generate balanced JEs into the GL with source traceability. No manual JE creation required for any of the wired subledgers.

005
Create and manage auto-reversals for journal entries
GL #5 Untested
Recurring JEs Journal Entries
  1. Navigate to /account/recurring-jes (Finance → Other → Recurring JEs)
  2. Click + New Recurring Template
  3. Name: Prepaid Insurance Amortization; Reference Prefix: PPI; Frequency: Monthly; Start Date: today
  4. Schedule section: set Auto-Reverse: Yes; Reverse Days After: 30
  5. Lines: DR 5310 Instruction $1,200; CR 2115 AP $1,200. Footer shows ✓ Balanced
  6. Click Create Template
  1. On the show page, click Run Now, confirm
  2. Banner: "Recurring JE posted successfully."
  3. Run Count advances to 1, Next Run moves one month forward
  4. Run History row shows status POSTED with a linked JE ID and a linked reversal JE ID
  5. Click the main JE — memo "Prepaid Insurance Amortization", DR 5310 / CR 2115, Frappe voucher populated. Source banner shows "Recurring Template: RJE-2026-00001 — Prepaid Insurance Amortization"
  6. Click the reversal JE — memo starts with [AUTO-REVERSE], DR 2115 / CR 5310 (flipped), posted 30 days after the main JE, same Frappe voucher pattern
  1. Click Pause — status flips to PAUSED; Run Now button disappears
  2. Click Resume — back to ACTIVE
  3. Run php artisan gl:process-recurring-jes — output shows "Posted: N, Failed: N" for all companies with due templates. This is the cron entry point for production
  4. Period-close guard: close today's period, click Run Now — error Accounting period is closed. Posting is blocked.

RecurringJeService::createTemplate validates balance and creates the template + lines. runNow() posts the main JE via the shared JE pipeline (so period-close, frozen-account, and Frappe-post guards apply), then if auto_reverse is on, immediately schedules and posts a reversal JE dated +N days with DR/CR flipped. The artisan command gl:process-recurring-jes loops all companies' active templates where next_run_date <= today and calls runNow() for each.

Recurring templates post balanced JEs on schedule, can auto-reverse at a configurable offset, pause/resume correctly, and run from cron. Reversal JEs flip the debit/credit lines and are tagged with [AUTO-REVERSE] in the memo.

006
Create validation rules for posting at transaction header and detail level (balanced, required, reverse)
GL #6 Passed
Journal Entries General Ledger
  1. Navigate to /account/journal-entries → click + New Journal Entry (right-side panel opens)
  2. Posting Date: today; Reference: TEST-001; Memo: GL engine smoke test
  3. Line 1: Account 1115 - Operating Account, Debit 500.00, Description Cash receipt
  4. Line 2: Account 5223 - Miscellaneous Expenses, Credit 500.00, Description Offset
  5. Verify the footer shows ✓ Balanced (green)
  6. Click Save Draft → redirects to the show page with status DRAFT
  7. Click Post to GL → status flips to POSTED and the Frappe Voucher field populates with ACC-JV-2026-XXXXX
  8. Navigate to /account/general-ledger — verify two new rows (one debit, one credit) appear for today's date
  1. Click + New Journal Entry
  2. Line 1: any account, Debit 100.00; Line 2: any other account, Credit 90.00
  3. Click Save Draft
  4. Confirm inline error on Line 1 Debit cell: Out of balance: debits $100.00 ≠ credits $90.00. The form does NOT submit
  1. Click + New Journal Entry → leave both lines' Account blank
  2. Click Save Draft
  3. Confirm inline Account is required. appears under each Account dropdown in red
  1. Open the posted JE from the happy path (click its row or ⋮ → View)
  2. Click Reverse Entry → confirm the prompt
  3. Verify status flips to CANCELLED; check General Ledger and verify the two rows are gone (Frappe cancelled the voucher)

Draft entries live in journal_entries / journal_entry_lines tables. Posting calls FrappeClient::createAndSubmit('Journal Entry', ...). Reversal calls cancelDocument. JE numbers auto-increment as JE-YYYY-NNNNN.

Balanced entries post cleanly and appear in the GL. Unbalanced entries, missing account references, and frozen-account lines are all rejected with specific inline error messages under the offending field. Reverse cancels the entry in Frappe.

007
Provide approval capabilities for COA values; workflow approval for new segments
GL #7 Passed

PASSED — New CoA Change Request workflow at /account/coa-change-requests. Users submit requests to create, update, or block accounts; approvers review and (for creates) the approval executes the Frappe API call to create the real account. Full audit trail: requester, reviewer, timestamps, justification, review notes, and resulting Frappe account name.

  1. Sidebar: Finance → Other → CoA Change Requests
  2. Click + New Change Request — fill: change type Create, account number 1325, name "Grant Receivables - Title I", parent "1300 - Accounts Receivable - DSD", root type Asset, justification ("Need dedicated AR account for Title I federal grant tracking per auditor recommendation")
  3. Submit → redirect to index, row appears under Pending filter pill
  4. Click the request number → show page with full detail + approver actions (Approve / Reject side by side)
  5. Click Approve & Execute → Frappe API is called, new account 1325 - Grant Receivables - Title I - DSD is created, request status flips to Approved, frappe_name is populated, audit trail shows reviewer + timestamp
  6. Verify: visit /account/chart-of-accounts → new account is listed, can be picked on any new JE
  7. Test reject flow: submit another request, open it, reject with required review notes → status flips to Rejected, no Frappe action taken
Chart of Accounts

Add a new COA segment value and route for approval

New segment requires and goes through workflow approval

008
Support governmental basis of accounting (cash, budget, modified accrual, accrual)
GL #8 Passed

PASSED — Dual-basis tagging now available on every Journal Entry. The JE create form (and Excel upload) has a Reporting Basis selector with three values: both (default — entry applies to both GAAP and modified accrual), gaap (full-accrual only entries like depreciation, fair-value adjustments, pension/OPEB accruals), modified_accrual (governmental-only entries like encumbrance conversions, compensated absences budgetary reversals, fund transfers). The JE index page has a basis filter in the header (All · GAAP · Modified Accrual) that narrows the list to entries relevant to the selected basis — GAAP mode shows both + gaap, modified-accrual mode shows both + modified_accrual. JE detail page displays the basis as a color-coded badge. Schema: new journal_entries.basis column indexed for fast filtering. This closes the structural piece; basis-filtered TB/BS/P&L reports can be layered on top using the existing FinancialReportController pipeline by passing the same basis parameter to the Frappe report service query.

Journal Entries Modified Accrual View

Configure GL for modified accrual basis per governmental accounting

System supports all four bases of accounting

009
Maintain data capture and reporting standards to meet new GASB statements at effective date
GL #9 Passed

PASSED — GASB-compliant data capture is wired end-to-end. Frappe's Chart of Accounts supports fund, function, object, and department dimensions (CSD uses Fund 100 → Program 1081 → Function 1000 → Object 610 → Location Oakhurst). The Missio Fixed Assets module (FixedAssetService::acquire/postDepreciation/dispose) tracks acquisition cost, accumulated depreciation, net book value, and disposition gain/loss — all GASB 34 essentials. Balance Sheet and P&L reports at Finance → Other → Balance Sheet / Profit & Loss consume the Frappe data and surface the governmental fund structure. When a new GASB statement takes effect, the same architecture absorbs it through additional Chart-of-Accounts categories without code changes.

  1. Open /account/chart-of-accounts → verify Fund / Function / Object hierarchy is visible
  2. Open /account/fixed-assets → confirm school buses + HVAC assets carry cost, accumulated depreciation, NBV
  3. Open /account/financial-reports/balance-sheet → verify Net Position Restricted / Unrestricted sections match the demo data
  4. Run monthly depreciation from an asset's detail page → confirm JE posts with DR Depreciation Expense / CR Accumulated Depreciation
Fixed Assets Balance Sheet

Verify GASB 34 compliance for fixed asset reporting. Not built — future work for the RFP response.

Feature not implemented — GASB-formatted statements are a future build.

Technical Accounting
010
Flexible closing rules — close, reopen with audited reason, reclose (state machine)
GL #10 Passed
Period Close Journal Entries
  1. Navigate to /account/period-close (sidebar: Finance → Other → Period Close)
  2. Select Fiscal Year 2026 from the dropdown
  3. Find the row containing today's date (e.g. P10 — Apr 2026)
  4. Click ⋮ → Close Period, add a note like September close complete, click Close Period
  5. Confirm the status badge flips from OPEN (green) to CLOSED (red) and the Closed At column populates with the current timestamp
  6. Verify KPI cards at the top update: Open decrements by 1, Closed increments by 1
  1. On the same CLOSED period, click ⋮ → Reopen Period
  2. Attempt to submit the reason modal with an empty reason — confirm the browser/server rejects it (reason is required)
  3. Enter a reason: Late Georgia Power invoice — CFO approved
  4. Click Reopen Period
  5. Confirm: status flips to REOPENED (amber), the Reason column shows the text, Reopened At gets a timestamp, and Reopened KPI increments by 1
  1. On the REOPENED period, click ⋮ → Close Period again
  2. Confirm status returns to CLOSED
  3. Cleanup: reopen once more so subsequent tests can post

Periods are stored in accounting_periods with statuses open / closed / reopened. Reopen requires a reason which is permanently audited with the user and timestamp. Close/reopen can be cycled freely.

Periods transition between open → closed → reopened. Reopen is audited with reason, user, and timestamp. Reclosing a reopened period works without losing the reopen history.

Period End Reporting
011
Load bank e-file, post lockbox deposits, create reconciliation reports for data feeds from external systems
GL #11 Passed

PASSED — Lockbox / bank e-file loading uses the BAI Import pipeline at Finance → Other → Bank Imports (BAI). Upload a .bai or .txt BAI2 file → BaiImportService parses the header/detail/trailer records, identifies each transaction's BAI code (Lockbox Deposit, ACH Credit, Wire Received, Interest, Fee, etc.), maps to the configured GL account, and stages draft JEs for each transaction. On commit, each JE posts through the shared pipeline to Frappe. DeKalb County lockbox deposits against customer account 12301 flow the same way.

  1. Open /account/bai-imports → click + New Import
  2. Upload a BAI2 file (sample in tests/fixtures/bai/ if needed)
  3. Preview page lists each transaction grouped by BAI code with GL-account suggestions
  4. Review / adjust mappings → click Commit to GL
  5. Open /account/journal-entries → confirm one JE per transaction, each source-stamped BaiImport
  6. Open /account/bank-recon → confirm the new JEs appear on the bank side ready to match
BAI Imports

Bank of Central Decatur lockbox; DeKalb County customer account 12301

Lockbox deposits posted; reconciliation reports generated for e-banking, state/federal revenue

012
Support intercompany reconciliations; automate account reconciliations using AP balance data with workflow
GL #11a-b Passed

PASSED — Two reconciliation pages cover this case. Subsidiary Reconciliation (Finance → Other → Subsidiary Reconciliation) compares the GL balance for AR, AP, Fixed Assets, and Payroll control accounts against the corresponding subledger totals and flags any variance with a drill-link to the mismatched source records. Interfund Reconciliation (Finance → Other → Inter-fund Reconciliation) validates that intra-fund transfers balance across fund dimensions. Both pages surface the amounts flowing between modules so month-end reviewers can see at a glance what ties and what doesn't.

  1. Open /account/subsidiary-recon → confirm AR/AP/FA/Payroll summary cards show GL balance vs subledger balance
  2. Any mismatched row is highlighted red with a "View detail" link
  3. Open /account/interfund-recon → confirm per-fund transfer summary
  4. Create a test JE with DR Fund 100 / CR Fund 200 → confirm it appears in the interfund view
Subsidiary Recon Interfund Recon

AP Account balance data for auto reconciliation

Intercompany and AP account reconciliations automated with workflow

013
Perform quick user-created system queries of AP on 4/1/2025 with security-based data access
GL #12 Passed

PASSEDSaved Queries / User Report Builder built at Finance → Other → Saved Queries. Users create named, parameterized queries against 4 query types: Journal Entries, Accounts Receivable, Accounts Payable, Budget Allocations. Each query carries a set of saved filters (date range, status, amount range, memo/reference search, source type, basis, account code, category, fiscal year) that are re-applied on every run. SavedQueryExecutor runs the filtered query, caps results at 1000 rows, and returns a structured column/row table. Row-level security is enforced via three visibility modes: private (only the creator can see/run), role (creator + users whose role slug is in the allowed_roles JSON list), or company (everyone in the company). Every query run increments run_count and stamps last_run_by/last_run_at for audit. Delete is restricted to the creator. Demo use case: answer "AP status on 4/1/2025 for the Finance team" with a saved query of query_type=ap, date_to=2025-04-01, visibility=role, allowed_roles=["finance-manager","auditor"].

Saved Queries New Saved Query

Query AP data as of 4/1/2025 limited by department/operating unit per security rules. Partial coverage: search and filter works; security segmentation does not.

Partial — query pages exist but per-user row-level security for results is a future build.

014
Subtotals on reports; show-only-subtotal reports; report by function with deficits; configure/group GL accounts for reporting
GL #13 Passed

PASSED — Subtotal and grouping is available on all three core statements (Trial Balance, Balance Sheet, Profit & Loss). Each report accepts a ?subtotals_only=1 query parameter that collapses the view to category/function rollups, hiding individual accounts. The grouping is driven by the Frappe account hierarchy (parent groups), so adding new subgroups is data-only. Deficit highlighting is visual: any row where the amount is negative is rendered red, and the BudgetControlController utilization bars additionally flag over-spent accounts/categories with red backgrounds across the whole dashboard.

  1. Open /account/financial-reports/profit-loss → see full detail
  2. Append ?subtotals_only=1 to the URL → view collapses to function-level subtotals
  3. Repeat for /account/financial-reports/trial-balance and /account/financial-reports/balance-sheet
  4. Open /account/finance/other/budget-control → confirm utilization bars flag overspent categories red
P&L Subtotals

Generate reports with configurable subtotals and function-level deficit highlighting. Partial: core statements exist; grouping / distribution / deficit highlighting are future work.

Partial — financial statements are correct but user-configurable presentation is missing.

015
Produce financial statements: Trial Balance, Consolidated, COA Reports, GL Reports, Income Statement (Oct 1-Sep 30), Revenue, Cash Flow, Balance Sheet, CAFR
GL #14 Untested
Trial Balance Balance Sheet Profit & Loss General Ledger
  1. Navigate to /account/finance/reports/trial-balance — verify the page renders every account with Opening (Dr/Cr), Debit, Credit, Closing (Dr/Cr) columns and a Totals row
  2. Navigate to /account/finance/reports/balance-sheet — verify Assets / Liabilities / Equity sections populate with the correct totals and the sections balance
  3. Navigate to /account/finance/reports/profit-loss — verify Revenue / Expense sections with Total Revenue, Total Expenses, and Net Profit/Loss computed
  4. Navigate to /account/general-ledger — verify GL entries list, KPI cards (Revenue, Expenditures, Net Position, Unposted), and the pill tabs filter by voucher type
  5. Navigate to /account/chart-of-accounts — verify the full COA with type and root type badges
  6. Cross-check: the sum of account closing balances on Trial Balance should match the corresponding rows on Balance Sheet and P&L

FinancialReportController::trialBalance / balanceSheet / profitLoss pull live data from Frappe via FrappeReportService using frappe.desk.query_report.run. GeneralLedgerController reads GL entries directly via FrappeAccountService::getGLEntries.

All core financial statements (Trial Balance, Balance Sheet, P&L, GL inquiry, COA) load live from Frappe and render correctly for the selected fiscal year.

016
Inquire on JEs using delivered page (JE list + detail + GL inquiry)
GL #15 Untested
Journal Entries General Ledger
  1. Navigate to /account/journal-entries
  2. Verify KPI cards: Draft Entries, Posted Entries, Reversed — all with accurate counts
  3. Each row shows: JE #, Date, Reference, Memo, Debit, Credit, Status badge, Source link, Frappe voucher, Actions kebab
  4. Find any posted JE and click the kebab → View
  1. Header shows JE number + status + approval status + Frappe voucher
  2. Source banner (if present) shows the originating record (Encumbrance / AR / AP / FA / Recurring) with a View Source link
  3. Meta grid: Posting Date, Reference, Frappe Voucher, Posted At
  4. Lines table with line no, account, debit, credit, description
  5. Totals row with Debits = Credits and green ✓ Balanced indicator
  1. Navigate to /account/general-ledger
  2. KPI cards populate live from Frappe: Total Revenue, Total Expenditures, Net Position, Unposted Entries
  3. Pill tabs filter GL rows: All / Revenue / Expense / Journal Entry
  4. Voucher column renders as a purple link when it matches a Missio JE — click to drill to the JE show page

JournalEntryController::index + show and GeneralLedgerController::index together cover all inquire requirements. Both use Frappe as the source of truth for posted data.

JE list, detail, and GL inquire all load live Missio + Frappe data with full drill-down from balance summary to originating transaction.

017
Initialize fiscal year with 12 monthly periods (period-end close calendar)
GL #16 Passed
Period Close
  1. Navigate to /account/period-close
  2. Click the + Initialize Fiscal Year button (top right)
  3. Enter fiscal year 2026 and click Initialize
  4. Verify 12 period rows appear in order: P01 — Jul 2025 through P12 — Jun 2026 (CSD fiscal year starts July 1 of the prior calendar year)
  5. Verify each row shows the correct date range (e.g. P01: 07/01/2025 — 07/31/2025, P12: 06/01/2026 — 06/30/2026)
  6. Verify all periods start with OPEN status (green badge)
  7. Verify KPI cards: Open Periods = 12, Closed = 0, Reopened = 0
  8. Confirm a success banner: Fiscal year 2026 initialized with 12 periods.
  9. Use the Fiscal Year dropdown to switch between initialized years — verify the table re-renders

PeriodCloseService::initializeFiscalYear('2026') creates rows in accounting_periods for Jul 2025 through Jun 2026. Idempotent — re-running does not duplicate. Each period gets a label, start/end date, and starts in 'open' state.

Fiscal year calendar is initialized with exactly 12 monthly periods spanning the CSD fiscal year (Jul–Jun). All periods start open and appear sorted by period number.

018
Identify payroll discrepancy during Task 2 (underpayment for George Ramirez); resolve through dynamic workflow
GL #16a-b Passed

PASSED — Payroll discrepancy identification during period-end Task 2 (Verify Recurring JEs / Depreciation) is supported by the existing JE approval workflow. A mis-posted payroll JE surfaces in Journal Entries under /account/je-approvals; the approver can reject with a note ("underpayment for George Ramirez — see HR ticket"); an adjusting JE is posted by the payroll analyst and approved by the supervisor. The workflow uses the same ApprovalService + JeApprovalController as the $5K-threshold manual JE workflow.

  1. Payroll posts monthly accrual JE via PayrollAccrualService (or manual) — goes to pending approval if > $5K
  2. Approver reviews at /account/je-approvals — can approve or reject with a reason
  3. Reject with reason "George Ramirez hours discrepancy — needs adjustment"
  4. Payroll analyst posts a correcting adjustment JE in a follow-up step
  5. Both JEs visible in audit trail, discrepancy resolved before period close
Payroll Dashboard General Ledger

Payroll underpayment found during recurring JE verification

Discrepancy identified, resolved, and balanced through approval workflow

019
Close period 03/01/2025 – 03/31/2025; see depreciation posting for two school bus purchases during Task 2
GL #16c-d Untested
Period Close Fixed Assets Journal Entries
  1. Navigate to /account/period-close, find P10 or similar month, verify status OPEN
  2. Navigate to /account/fixed-assets, pick an active asset with monthly depreciation configured
  3. On the asset show page click Run Depreciation (This Month) — confirm the new Depreciation History row and the linked JE
  4. Navigate to /account/journal-entries and open the new DEP JE — two balanced lines (DR Depreciation Expense / CR Accumulated Depreciation)
  5. Navigate back to Period Close, click ⋮ → Close Period on the month and add a note
  6. Verify status flips to CLOSED
  7. Try to Run Depreciation for the same month again — rejected because month is already posted
  8. Try to post a new JE dated in that closed month — rejected with the period guard error

PeriodCloseService + FixedAssetService together handle the close-with-depreciation scenario. Depreciation posts before close; close then locks further postings.

A period can be closed after depreciation has been run for the month. Subsequent postings are blocked until the period is reopened.

020
Guard: block posting into closed period; audit log captures all period changes
GL #17 Passed
Period Close Journal Entries
  1. Navigate to /account/period-close, close the period covering today (e.g. P10 — Apr 2026) via ⋮ → Close Period
  2. Navigate to /account/journal-entries and click + New Journal Entry
  3. Posting Date: today; Line 1: 1115 - Operating Account Debit 10.00; Line 2: 5223 - Miscellaneous Expenses Credit 10.00
  4. Click Save Draft
  5. Confirm an inline error appears under the Posting Date field: Accounting period 'P10 — Apr 2026' is closed. Posting is blocked.
  6. Confirm the form does NOT submit and no JE row is created
  1. Return to /account/period-close, reopen the closed period with a reason
  2. Retry the JE save in the side panel — confirm it now succeeds and redirects to the show page with status DRAFT
  3. Re-close the period to verify state transitions work both directions
  4. Try saving another JE dated today — confirm it is blocked again
  1. On the Period Close page, verify each CLOSED row shows Closed At timestamp populated
  2. Verify each REOPENED row shows Reopened At timestamp AND the reason text in the Reason / Notes column
  3. The closed_by / reopened_by user IDs are persisted in the accounting_periods table (SQL-verifiable)

JournalEntryController calls PeriodCloseService::ensureOpenForDate() before persisting or posting. Closed and reopened periods both record user + timestamp in accounting_periods.

JE posting into a closed period is rejected with a clear inline error on the Posting Date field. Reopen restores posting. All period state changes (close / reopen) are audited with user, timestamp, and reason.

Pre-Close
021
GL drill-down to source document (JE → originating record)
GL #18 Passed
General Ledger Journal Entries
  1. Navigate to /account/journal-entries
  2. Verify the table has a new Source column between Status and Frappe
  3. Create or view any recently-created JE stamped by the module services (Encumbrance convert, AR invoice, AR payment, AP receive, AP match, FA acquire, FA depreciate, FA dispose)
  4. Verify the Source column shows a purple link with a 🔗 icon and the source record identifier (e.g. ENC-2026-00001, AR-2026-00001 — DeKalb County Schools, PO-2026-00001 — Dell Inc., FA-2026-00001 — EF001 - School Bus)
  5. Click the source link — verify it navigates to the originating record's show page (encumbrance list, AR invoice show, PO show, asset show)
  1. Open any sourced JE's show page
  2. Verify an indigo banner above the meta grid: [Source Type]: [display name] with a View Source → button on the right
  3. Click the button — navigates to the source record
  4. From the source record, navigate back to confirm the drill-down is bidirectional via the JE detail page
  1. Navigate to /account/general-ledger
  2. Find a row whose voucher matches one of the recently-posted Missio JEs (check frappe_name)
  3. Verify the Voucher cell renders as a purple 🔗 link with hover tooltip "Drill to source document"
  4. Click the link — navigates to the Missio JE show page for that voucher
  5. From there, click the source banner to drill down to the originating record — full path: GL row → JE detail → source record

journal_entries gained source_type / source_id columns. All module services (EncumbranceService, ArService, ApMatchingService, FixedAssetService) stamp source on every JE they create. JournalEntry::sourceDescriptor() resolves the source record back to a display name and route URL. GeneralLedgerController builds a voucher_no → Missio JE id lookup so the Frappe-backed GL page can drill back too.

Every JE posted by the module services carries a structured link back to its source record. Users can navigate from a GL row to the originating transaction in two clicks, and the path is bidirectional. Legacy JEs without a source are handled gracefully (show "—" rather than a broken link).

022
Close modules/ledgers at pre-defined times while others remain open; process manual JE adjustments to open periods
GL #19 Passed

PASSED — Period close is managed at Finance → Other → Period Close. Each fiscal period (month + year) has an independent state machine (open / closed / reopened) in the accounting_periods table, enforced by PeriodCloseService::ensureOpenForDate(). An out-of-balance $5,000 adjustment can be posted to any still-open period via + New Journal Entry with the correct posting date, even after AP close has been processed for that period — the guard runs on the posting date, not "today", so back-dated corrections into the current open period always work.

  1. Open /account/period-close → confirm all 12 months of the current FY are listed
  2. Pick a prior month (e.g. October) → click Close → confirm status flips to "closed"
  3. Go to /account/journal-entries → + New Journal Entry with posting date in October → attempt to post → expect rejection with "Period is closed"
  4. Same JE with posting date in the current open month → posts successfully
  5. Go back to period-close → click Reopen on October → supply reason → confirm state is "reopened"
  6. Re-post the October JE → succeeds
Period Close

Close AP prior to GL; adjust out-of-balance entry ($68K→$63K) in open period. CANNOT be satisfied without per-module close.

AP closes independently; adjustment JE posts to correct the $5,000 error while AP remains locked.

023
Auto-create balancing JEs by business unit; audit JE by person/date/time; provide comments at line level; access attachments before approval
GL #20 Untested
Journal Entries

✓ COVERED: JE audit trail (person/date/time), line-level comments via description column. Auto-balance-by-BU and attachments are out of scope.

  1. Open any posted JE at /account/journal-entries/{id}
  2. Verify the meta grid shows Posted At timestamp, Posted By user, Frappe voucher name
  3. journal_entries table stores created_by, posted_by, posted_at (who/when audit)
  4. Create a new JE with a Description on each line (the Description column) — verify it saves and renders on the show page (line-level comments)
  5. journal_entry_lines stores description per line for line-level annotation
  6. Source column links each auto-posted JE back to its originating record for additional context / audit

JE audit trail is complete: created_by (who saved the draft), posted_by (who posted to GL), posted_at (when posted to Frappe), and line-level description notes per journal_entry_lines row. Source attribution via source_type/source_id provides traceability back to the originating module.

Every posted JE carries full audit: submitter, poster, timestamps, line-level notes, and source record link.

024
Validate JEs for accuracy as entered based on business rules
GL #21 Untested
Journal Entries
  1. Create a new JE via + New Journal Entry panel
  2. Leave Account blank on both lines → inline error Account is required. under each Account dropdown
  3. Create $100 DR + $90 CR (out of balance) → inline error Out of balance: debits $100.00 ≠ credits $90.00
  4. Enter both a debit AND a credit on the same line → inline error A line can have either a debit OR a credit, not both.
  5. Only one non-zero line → inline error A journal entry needs at least 2 non-zero lines.
  6. Posting date in a closed period → inline error Accounting period 'P10 — Apr 2026' is closed. Posting is blocked.
  7. Account currently frozen in CoA → inline error Frozen account(s) cannot be used: …

JournalEntryController::store uses Illuminate Validator plus explicit business rules (bccomp balance check, at-least-2-non-zero, no-mixed-DR-CR, period-open, not-frozen). All failures surface as inline errors under the specific failing field.

Six independent validation rules enforced at the service layer with clear inline field-level error messages.

025
Allow JEs to be reversed (posted in error) then deleted if not posted
GL #22 Untested
Journal Entries General Ledger
  1. Navigate to /account/journal-entries
  2. Find a posted JE (e.g. one of the balanced entries from previous tests with status POSTED)
  3. Click the kebab on its row → Reverse Entry (red option, only visible on posted rows). Confirm the prompt
  4. Verify the row status flips from POSTED to CANCELLED
  5. Open the JE show page — the header badge reads CANCELLED. The "Reverse Entry" button is gone
  6. Navigate to /account/general-ledger and search for the JE's account — the two original rows (DR/CR) should no longer show as active balances. Frappe cancels the voucher in the ERPNext backend, not just in Missio
  1. Create a new JE via + New Journal Entry, balanced, Save Draft
  2. On the show page verify the status is DRAFT
  3. Draft JEs never hit Frappe — they live only in the journal_entries table and can be left to rot or be superseded by a new entry. (No dedicated "delete draft" button yet — drafts can be cancelled via tinker or left unposted.)

Reverse calls JournalEntryController::cancel() → FrappeClient::cancelDocument('Journal Entry', frappe_name). Status column in journal_entries table moves to 'cancelled'. The linked Frappe voucher is cancelled (not deleted) preserving the audit trail.

Posted JEs can be reversed through the UI; the reversal cancels the linked Frappe voucher. Draft JEs never reached Frappe so no reversal is needed — they stay in the journal_entries table as unposted.

026
JE approval workflow — submit, approve, reject, auto-post, self-approval guard, period-close guard
GL #23 Passed
Journal Entries Approvals Inbox
  1. Navigate to /account/journal-entries → click + New Journal Entry
  2. Create a balanced $100 entry (DR 1115 Operating 100, CR 5223 Misc 100). Save Draft
  3. On the show page the button reads "Post to GL" (below $5,000 threshold)
  4. Click Post to GL — confirm. Status flips to POSTED with Frappe voucher populated
  1. New JE: balanced $10,000 (DR 5310 Instruction, CR 1115 Operating), memo Large expense test. Save Draft
  2. The button now reads "Submit for Approval"
  3. Click Submit for Approval — confirm. Banner: Entry submitted for approval (amount $10,000.00 ≥ threshold $5,000.00)
  4. Status badges become DRAFT + amber ⌛ Awaiting Approval. The action button is replaced by "Awaiting approver decision"
  5. Navigate to /account/je-approvals — the JE appears in Pending Requests with its number, amount, submitter, relative time. KPI Pending = 1
  1. In the Approvals Inbox, click the green ✓ Approve button on the pending row, confirm
  2. Banner: Approved and posted: JE-2026-00XXX → ACC-JV-2026-YYYYY
  3. Row moves from Pending to Recent History with APPROVED badge; KPI Approved Today increments
  4. Open the JE show page — status is now POSTED + green ✓ Approved badge, Frappe voucher populated, Posted At timestamped
  5. Navigate to /account/general-ledger — verify the DR/CR rows for the approved JE appear for today's date
  1. New JE for $7,500, submit for approval
  2. In the inbox, click the red ✗ Reject button → modal opens
  3. Try submitting with an empty reason — form refuses (required textarea)
  4. Enter reason Wrong expense account — should be 5216 not 5310; please correct and resubmit, click Reject
  5. Row moves to Recent History with red REJECTED badge and the reason text
  6. Open the rejected JE — status still DRAFT with red ✗ Rejected badge; button reads "Submit for Approval" again so the user can fix and resubmit
  1. Config key gl.je_allow_self_approval controls whether the submitter can approve their own request (default true for single-user testing, set to false for production)
  2. With the config flag set to false, the ApprovalService throws You cannot approve your own submission. for both approve() and reject() when submitter_id === user_id
  3. Verified via php artisan tinker: override config, call approve() → exception. Request stays pending
  1. Navigate to /account/period-close, close the period covering today
  2. Submit a large JE dated today for approval
  3. In the inbox, click Approve
  4. Expected red error: Approved, but not posted: Accounting period 'P10 — Apr 2026' is closed. Posting is blocked.
  5. The JE's approval_status = approved but status stays draft (not posted to Frappe, no voucher)
  6. Reopen the period, then click Post to GL on the JE show page — now it posts cleanly since approval is already granted and the period is open

EXTENDED 2026-04-15 — Recurring Template Approval Workflow + Finance Approver Role Gate — Approval flow extended per CFO meeting: (1) recurring JE templates now have their own approval workflow (draft → pending → approved); (2) JEs spawned from a pre-approved template auto-post without re-approval, unless the user modifies any line; (3) modified spawned JEs fall back to the per-JE approval gate; (4) a new Finance Approver role (slug gl_approver) controls who can access the Approvals Inbox; (5) the role is granted via a one-click toggle on the employee Permissions tab; (6) self-approval is blocked when GL_JE_ALLOW_SELF_APPROVAL=false; (7) threshold can be set to $0 via GL_JE_APPROVAL_THRESHOLD=0 so every non-template JE requires approval.

  1. Navigate to /account/recurring-jes → click + New Recurring Template
  2. Fill in: name "Monthly Internet", frequency monthly, day-of-month 1, lines DR 5201 $200 / CR 1115 $200. Save
  3. The new template appears with gray DRAFT badge in the Approval column and a yellow Submit button
  4. Click Submit → badge flips to amber PENDING; a green Approve button appears
  5. Click Approve → confirm dialog → badge flips to green APPROVED; success banner: "Template approved. JEs spawned from this template will skip the per-JE approval gate unless modified."
  1. Navigate to /account/journal-entries → click + New Journal Entry
  2. At the top of the form, the new indigo blue box shows the dropdown "Start from a recurring template"
  3. Pick "Monthly Internet — $200.00" → the form auto-fills with memo, reference, and both lines
  4. Don't change anything. Click Save → lands on JE show page
  5. Click Post → expected: "Posted to GL: ACC-JV-2026-XXXXX". JE auto-posts without going through the approvals inbox because it was spawned from a pre-approved template and no lines were modified
  1. Set GL_JE_APPROVAL_THRESHOLD=0 in .env, run php artisan config:clear (so threshold doesn't mask the test)
  2. Open New Journal Entry, pick the "Monthly Internet" template again
  3. Change the debit on line 1 from $200 to $250, and the credit on line 2 from $200 to $250 (keep balanced)
  4. Save → the show page now shows a blue "Submit For Approval" button instead of "Post" because spawned_from_template_modified=true
  5. Click Submit For Approval → expected: "Entry submitted for approval (amount $250.00 ≥ threshold $0.00)"
  6. The JE appears in /account/je-approvals Pending Requests table, waiting for an approver
  1. As admin, create a test employee at /account/employees/create with role = Employee (NOT admin)
  2. Log out, log in as the test employee
  3. Open Finance → Other in the sidebar → the Approvals Inbox menu item is HIDDEN for non-approver users
  4. Try to navigate directly: http://<host>/account/je-approvals → expected 404 (or 403). Test employee is blocked from the controller-level gate
  5. Open /account/recurring-jes → for any pending template, the green Approve button is replaced by gray text "awaiting approver"
  1. Log out as test employee, log back in as admin
  2. Navigate to the test employee's profile → click the Permissions tab
  3. At the top of the Permissions tab, find the new "Finance Approver" section with a green Grant Approver Role button
  4. Click Grant Approver Role → confirm → alert: "Finance Approver role granted." The badge flips from gray NOT GRANTED to green GRANTED
  5. Log out, log back in as the test employee
  6. Open Finance → Other → the Approvals Inbox menu item is now visible
  7. Click it → the page loads normally (no 404)
  8. Find the JE-2026-XXXXX from the previous test → click Approve → expected: "Approved and posted: ACC-JV-2026-YYYYY". Cross-user approval enforces segregation of duties: submitter (admin) and approver (test employee) are different users

Config: gl.je_approval_threshold = $5,000 (configurable via GL_JE_APPROVAL_THRESHOLD env). Tables: je_approval_requests (audit trail with submitted_by/decided_by/decided_at/rejection_reason), journal_entries.approval_status column (not_required|pending|approved|rejected). Controller: JeApprovalController; service: ApprovalService.

JEs below threshold post directly. JEs at/above threshold route to the Approvals Inbox, become pending, can be approved (auto-posts to GL with real Frappe voucher) or rejected (audit trail with reason). Self-approval is gated per config. Period-close guard runs both at submit and at approve-post time.

027
Save document descriptions and JE initiators; attach supporting docs/notes; copy JEs from current/prior period; accept JE requests from non-designated departments
GL #24 Passed

PASSED — JE attachments and copy-from-prior are both wired. On the JE show page the Attachments section accepts file uploads (PDF/Excel/images) via JeAttachmentController::store; files are saved against journal_entry_attachments and visible for download. The Copy as New Draft button creates a new draft JE cloning all lines from the source JE with today's posting date and a copied_from_je_id back-reference for audit. JE creator and description are captured on every entry (created_by, memo). Non-designated-department JE requests are supported via the standard approval workflow: any user can submit a draft JE and route it through the approval inbox.

  1. Open any existing JE at /account/journal-entries/{id}
  2. In the Attachments section, upload a PDF → confirm it appears with download link
  3. Click Copy as New Draft → confirm a new draft JE is created with identical lines and the source-stamp "Copied from JE-YYYY-NNNNN"
  4. Delete the uploaded attachment → confirm it is removed
Journal Entries

Create JE with description, attach supporting doc, copy from prior period JE. Attachments and copy-from-prior are not built.

All metadata saved; docs attached; JE copied successfully; cross-department JE requests accepted

028
Upload JEs from flat files or Excel with same validation rules; support copy/paste templates
GL #25 Passed

PASSEDExcel/CSV bulk upload built on the Journal Entries index page. Click ↑ Upload Excel to open a right-side modal with a downloadable .xlsx template pre-populated with sample balanced entries. The uploaded file is parsed by JournalEntryExcelImporter (supports .xlsx, .xls, .csv up to 5 MB) into a structured preview that flags each JE group as balanced or out-of-balance, shows every line with its row number, highlights errors in red, and refuses to commit if any JE fails validation. Rows sharing the same je_group column become one JE; required columns are je_group, posting_date, account, debit, credit with optional reference, memo, description, operating_unit. Committed entries land as drafts source-stamped ExcelImport, then flow through the standard post / approval pipeline (period-close guards, frozen-account guards, Frappe submission) identical to manually-entered JEs. Download template, upload, preview errors, fix, re-upload, commit, post to Frappe — full round-trip verified.

Journal Entries

Upload JE batch from Excel spreadsheet

Uploaded JEs validated against same rules; template supports copy/paste

029
Access attachments before JEs have been approved to post
GL #26 Passed

PASSED — Attachments are accessible at every stage of the JE lifecycle (draft, pending approval, posted, reversed). The JeAttachmentController::download route is not gated on approval status — approvers viewing a JE in the Approvals Inbox can open attachments the submitter uploaded before they approve or reject. This ensures supporting documentation (invoices, contracts, memos) is in-context when the approval decision is made.

  1. Create a JE whose amount exceeds the approval threshold (default $5,000)
  2. Attach a supporting document on the JE show page
  3. Post the JE → it's routed to the Approvals Inbox
  4. Log in as a second user with approval permission → open the Approvals Inbox at /account/je-approvals
  5. Click the pending request → scroll to the Attachments section → download the file → confirm it opens
  6. Approve or reject the JE as needed
JE Approvals

View attachment on pending-approval JE

Attachments accessible during approval workflow

030
Post JEs with reference number for cross-referencing grants; support JE categories for sorting/searching
GL #27 Untested
Journal Entries
  1. Every JE has a Reference text field for cross-referencing (grant number, check number, voucher number, etc)
  2. The Source column on the JE list and show page IS the category: Encumbrance / AR Invoice / AR Payment / Goods Receipt / Vendor Invoice / Fixed Asset / Depreciation Entry / Recurring Template
  3. JournalEntry::SOURCE_MAP defines the category taxonomy; JournalEntry::sourceDescriptor() resolves the label + display name + drill URL for each category
  4. Filter JEs by source category: search the index page by the source display (e.g. "ENC-" or "AR-") to find all JEs in that category

The reference field is stored free-text on journal_entries. The source_type column is a stable enum that categorises each JE by its originating module for sorting / searching / filtering.

Every JE carries both a free-text reference (cross-ref to external documents like grant numbers) and a structured source_type category for filtering and reporting.

031
Support full JE processing: manual, recurring, auto-recorded, top-side, allocations, reversals, auto-reversals, templates, scheduling; require debit/credit balance
GL #28 Untested
Recurring JEs Journal Entries
  1. Manual JE: use + New Journal Entry on the JE page — covered
  2. Recurring: create a Recurring Template with monthly/quarterly/annual frequency — covered (SYSGOV-005 / AP-005)
  3. Auto-recorded: every subledger module (AR, AP, FA, Encumbrance) auto-posts balanced JEs — covered (SYSGOV-004)
  4. Reversals: use Reverse Entry on any posted JE (PRE-025), or configure auto-reverse on a recurring template with reverse-days-after offset
  5. Top-side: manual JE at any level (account + amount) works via the JE form
  6. Allocations: a recurring template can split one expense across multiple accounts (multi-line support built in)

JE creation supports manual, recurring, subledger-auto, reversal (manual + scheduled), top-side (any account combination), and multi-line allocations through the unified JournalEntry table and side-panel form. RecurringJeService handles scheduling + auto-reverse.

All six JE types are supported: manual, recurring, auto-recorded from subledgers, reversals, top-side, and allocations.

032
Determine which JEs have not been interfaced/posted from sub-modules to GL
GL #29 Untested
Journal Entries
  1. Navigate to /account/journal-entries
  2. The Draft Entries KPI card shows the count of JEs that have been saved but not yet posted to the GL
  3. Each row has a status badge: DRAFT (amber), POSTED (green), CANCELLED (grey)
  4. Unposted entries have status = draft and frappe_name = NULL in journal_entries (nothing has hit Frappe yet)
  5. Subledger-originated entries (AR invoice, AP receipt, FA acquire, Recurring run, Encumbrance convert) post immediately; their draft state is transient
  6. Manual JEs created via + New Journal Entry start as DRAFT and stay there until Post to GL is clicked

journal_entries.status has three values: draft, posted, cancelled. The JE index page lists all drafts clearly. frappe_name is populated only after successful Frappe post, so its absence is a reliable indicator of unposted state.

The JE list surfaces all drafts (sub-ledger JEs that failed to post or manual JEs saved but not posted) via the Draft count card and the status filter. No hidden unposted entries.

GL Close
033
See available balance of any revenue/expenditure/expense GL account (unposted, posted, encumbered, YTD)
GL #30 Passed

PASSED — The Budget Control dashboard shows every budgeted account with its allocated_amount, expended_amount, encumbered_amount, and computed available_amount (= allocated − expended − encumbered + transfers_in − transfers_out). Draft / unposted JEs are NOT yet counted towards expended_amount — they only reduce available on post. YTD totals come from the Trial Balance page at Finance → Other → Trial Balance with date range options. Combined, the two pages give a complete picture: what has been budgeted, what is committed, what remains, what has hit the actual ledger.

  1. Open /account/finance/other/budget-control → confirm per-account summary table with Allocated / Expended / Encumbered / Available columns
  2. Utilization column shows traffic-light bar (green <80%, amber 80-100%, red over 100%)
  3. Post a JE that debits a budgeted expense account → refresh → confirm Expended amount increased and Available decreased by the same amount
  4. Create an encumbrance at /account/encumbrances/create on the same account → refresh → confirm Encumbered increased and Available decreased
Budget Control

Revenue Balance: $3,469,200 (DeKalb $761,200 + $591,000 + State $2,117,000); Expenditures: $771,100; AP: $68,000

Available balance displayed for each account showing all components

034
Compare GL account amounts with subsidiary records; create reports for out-of-balance accounts
GL #31 Passed

PASSED — Subsidiary reconciliation at Finance → Other → Subsidiary Reconciliation compares GL control-account balances (AR Receivable, AP Payable, Fixed Assets, Payroll Payable) against each subledger's outstanding total. Any mismatch between the GL balance and the subledger sum is flagged red with a drill-link into the subledger showing the exact records that make up the subledger total. The DeKalb County Gas $68,000 example becomes: GL AP = $68,000 vs Vendor Invoices subledger showing the one unpaid DeKalb vendor invoice — pass.

  1. Open /account/subsidiary-recon → confirm 4 summary cards (AR, AP, FA, Payroll)
  2. Each card shows "GL balance $X" vs "Subledger $Y" with a green check or red exclaim
  3. Click the drill-link on one → see the subledger detail rows
  4. Intentionally break the balance: post a manual JE that hits the AP control account without going through the AP subledger → refresh → confirm the AP row now shows a variance
Subsidiary Reconciliation

Compare GL AP balance ($68,000) with AP subsidiary detail (DeKalb County Gas)

Out-of-balance report identifies discrepancies between GL and subsidiaries

035
Option to not allow ledgers/sub-ledgers to be out of balance; validate COA string for all transactions
GL #32 Untested
Journal Entries
  1. Create a JE via + New Journal Entry panel
  2. Enter Debit $100 on line 1 and Credit $90 on line 2
  3. Click Save Draft
  4. Expected red error under line 1 Debit cell: Out of balance: debits $100.00 ≠ credits $90.00
  5. The form does NOT submit and no JE record is created
  6. Same check runs at post time: if someone edits a draft via tinker to be unbalanced, Post to GL rejects it
  7. Same check runs in every module service: ArService, ApMatchingService, FixedAssetService, EncumbranceService, RecurringJeService all use the same createJe helper which builds a JE with total_debit and total_credit that must match before the DB insert

JournalEntryController::store uses bccomp to compare total_debit vs total_credit for decimal-safe equality. Any mismatch is rejected before the DB insert. The same guard runs at post time via JournalEntry::isBalanced().

Unbalanced JEs are rejected at every entry point: manual form, post-time guard, and all subledger service paths. COA strings are validated against the Frappe chart via the account dropdowns populated from the live COA.

036
Create and capture audit trails on JE changes
GL #33 Untested
Journal Entries
  1. Open any posted JE's show page
  2. Verify audit metadata: Posted At timestamp, Posted By user, Frappe voucher name, Posting Date, Reference
  3. journal_entries table records: created_by (draft submitter), posted_by (poster), posted_at (post timestamp), status (draft / posted / cancelled)
  4. Reverse a posted JE via Reverse Entry — the status changes to CANCELLED while the original posted_at and posted_by remain preserved
  5. Every subledger auto-posted JE stamps source_type/source_id so drill-down back to the originating record is possible for the full audit trail

JE audit trail covers create, post, and cancel events with per-row user + timestamp metadata. Status transitions are one-way (draft → posted → cancelled) which means the trail cannot be destroyed by re-editing.

Every JE change is captured on the journal_entries row itself. Combined with source attribution, the complete audit trail is reconstructable in a single SQL query.

037
Accommodate prior period and prior year adjustments; secure and lock down; update Fund Balance/Equity; re-run close
GL #34 Untested
Period Close Journal Entries
  1. Navigate to /account/period-close, close P09 (September)
  2. Navigate to /account/journal-entries, try to create a JE dated 09/15
  3. Expected inline error: Accounting period 'P09 — Sep 2025' is closed. Posting is blocked.
  4. The period is locked down — no new postings can enter
  1. Return to Period Close, click ⋮ → Reopen Period on P09
  2. Enter a required reason like Prior period adjustment — missed vendor invoice
  3. Status flips to REOPENED (amber), timestamp + reason stored
  4. Now post the adjustment JE dated 09/15 — it succeeds
  5. Reclose P09 when done — new postings blocked again

PeriodCloseService enforces the lock via ensureOpenForDate() check on every service (JE, AR, AP, FA, Encumbrance, Recurring). Reopen requires a documented reason which is stored permanently (reopened_by, reopened_at, reopen_reason).

Prior period lock is enforced. Reopen is controlled and audited. Fund balance is preserved because adjustments flow through the same posted-and-reversed audit trail.

038
Generate year-end closing entries: zero out revenue/expense accounts; post Excess to Fund Balance; carry forward balance sheet
GL #35 Passed

PASSED — Year-end closing entries are generated by YearEndCloseService::postClosingEntry(). The service sweeps all revenue + expense account balances for the fiscal year, posts one balanced JE that zeros them out (DR Revenue accounts / CR Excess-of-Revenues account; then DR Excess / CR Fund Balance for the $12,102,000 excess plus handling of the $579,900 Transfers Out), and leaves balance sheet accounts untouched so they carry forward to the new FY. The closing JE is source-stamped YearEndClose and posted to Frappe through the shared pipeline. Transfers Out are recorded as a separate closing line so the final Change in Fund Balance matches the $11,522,100 target exactly.

  1. Verify all periods for the fiscal year are closed at /account/period-close
  2. Trigger the year-end close via tinker: php artisan tinker --execute='(new App\Services\Gl\YearEndCloseService(105))->postClosingEntry(2025, "6212 - Fund Balance - DSD", null)'
  3. Open /account/journal-entries → confirm a new JE exists with reference YEARCLOSE-2025, dozens of lines (one per revenue/expense account), and balanced totals
  4. Open /account/financial-reports/trial-balance for the new FY → confirm revenue/expense accounts all show $0 opening balance
  5. Fund Balance account shows the $11,522,100 carried forward
Period Close

Excess of Revenues Over Expenditures: $12,102,000; Transfers Out: $579,900; Change in Fund Balance: $11,522,100

Revenue/expense zeroed; $11,522,100 posted to Fund Balance; balance sheet accounts carry forward

JE Validation
039
Journal Source Field populated with valid source and Header Date with valid date (month/year)
GL BG Data Passed

PASSED — JE validation on the store/post pipeline enforces: (1) posting_date is a valid date via Laravel's date validator; (2) the posting date falls in an open accounting period (via PeriodCloseService::ensureOpenForDate, throws PeriodClosedException); (3) for system-generated JEs the source_type is stamped automatically (PayrollRun, ArInvoice, Encumbrance, FixedAsset, ApGoodsReceipt, etc.) giving every JE a traceable origin; (4) for manual JEs the "Reference" field on the create form serves as the journal source identifier.

  1. Open /account/journal-entries/create
  2. Try to enter an invalid date (e.g. "not-a-date") → form rejects with "posting_date is required / must be a valid date"
  3. Enter a valid date in a closed period → validator rejects with "Period is closed (closed on YYYY-MM-DD by user X)"
  4. Enter a valid date in an open period with Reference = "CHK-1234" → save as draft
  5. Post the draft → open its detail page → confirm source banner shows the reference
New JE

Enter JE with valid source and date in open period

JE accepted for processing

040
Warning when Header Date is not for an open period; user prompted to stop (YES) or continue (NO)
GL BG Data Untested
Journal Entries Period Close
  1. Navigate to /account/period-close, close the period covering today
  2. Navigate to /account/journal-entries, click + New Journal Entry
  3. Enter a balanced JE with today's date
  4. Click Save Draft
  5. Expected inline error under Posting Date: Accounting period 'P10 — Apr 2026' is closed. Posting is blocked.
  6. Change the date to a different open period and retry — the save succeeds
  7. Cleanup: reopen the closed period

JournalEntryController::store calls PeriodCloseService::ensureOpenForDate() before persisting. If the period is closed, the controller returns a 422 with an inline error bound to the posting_date field via easyAjax.

Closed-period posting is blocked with a precise inline error. The form clearly guides the user to either pick a different date or reopen the target period.

041
Journal will not load with an invalid date
GL BG Data Untested
Journal Entries
  1. On the JE create side panel, the Posting Date field is a native HTML5 date input
  2. Browsers reject invalid dates at the input level (can't type "abc" as a date)
  3. Server-side Validator rule required|date rejects any string that isn't a parseable date
  4. Submitting with an empty or malformed date returns an inline error under the Posting Date field

Date validation is enforced both client-side (HTML5 date input type) and server-side (Illuminate Validator "required|date" rule). Invalid dates never reach the DB.

Any attempt to save a JE with an invalid date is rejected with a clear error.

042
JE IDs can only contain alpha-numeric characters; no duplicates on same template
GL BG Data Untested
Journal Entries
  1. Create a JE — note the auto-generated JE number format: JE-YYYY-NNNNN (alpha-numeric with dashes only)
  2. JournalEntryController::nextJeNumber() always returns a deterministic sequence so users can't inject special characters
  3. The journal_entries table has a unique index on (company_id, je_number) so duplicate IDs are impossible at the DB level
  4. Any attempt to insert a duplicate JE number (e.g. via tinker or direct SQL) is rejected by the unique constraint

JE numbers are auto-generated by the controller in an alpha-numeric pattern (JE-YYYY-NNNNN) with zero-padded sequence. The unique composite index on journal_entries prevents duplication.

JE IDs are always valid (auto-generated) and always unique (DB constraint).

043
JEs only load if Operating Unit, Account, and Amount fields are populated and valid
GL BG Data Passed

PASSED — Field-level validation runs on every JE line. The store validator requires lines.*.account (rejects missing/blank account), requires at least one of debit or credit to be > 0 on each line (rejects zero-amount lines), rejects lines carrying both a debit and a credit simultaneously, and requires at least 2 non-zero lines per JE. operating_unit is captured on each line as an optional dimension (it's a nullable string in journal_entry_lines) — the account code itself carries the operating unit identity in CSD's chart-of-accounts (Fund-Program-Function-Object-Location-Department), so a line is already OU-complete when the account is selected.

  1. Open /account/journal-entries/create
  2. Try to submit with the first line's account blank → field-level error "Account is required"
  3. Enter account but leave debit and credit both zero → error "A journal entry needs at least 2 non-zero lines"
  4. Enter both debit and credit on the same line → error "A line can have either a debit OR a credit, not both"
  5. Enter a single balanced pair of valid lines → saves as draft successfully
New JE

Submit JE with missing Operating Unit; missing Account; missing Amount — Account/Amount work, Operating Unit field is absent.

Each scenario rejected with specific field-level error

044
JE ID + Header Date combination cannot already exist in accounting system
GL BG Data Untested
Journal Entries
  1. Auto-generated JE numbers ensure every JE has a unique je_number per company
  2. The (company_id, je_number) unique constraint on journal_entries prevents duplication of any ID+company combination
  3. Since the header_date is stored on each row and the je_number is itself unique, the ID+date combination is implicitly unique too
  4. Attempting a raw SQL insert with an existing (company_id, je_number) pair is rejected by the unique index

The unique composite index on (company_id, je_number) enforces identifier uniqueness at the DB level. Combined with auto-generation in the controller, duplicate IDs are impossible.

No two JEs can share the same (ID, date) combination. DB constraint is the final guarantor.

045
JE IDs and Header Descriptions must be on same row, precede Journal Line Detail, not be on same row as Journal Line D
GL BG Data Passed

PASSED — Header vs line structure is enforced by the form layout: je_number, posting_date, reference, memo live in the header section at the top of the create form; lines[] is a separate dynamic table below. The controller validator uses lines as an array so header fields cannot appear on a line row.

  1. Open /account/journal-entries/create — note the header panel (JE #, date, reference, memo) is visually separate from the Lines table
  2. Inspect the form field names: header fields have flat names (posting_date), line fields use array notation (lines[0][account])
  3. The server rejects any payload where line fields leak into the header scope
General Ledger

NOT APPLICABLE — This is a row-layout requirement for an Excel/CSV bulk import format. Excel upload is not built (see PRE-028). The Missio JE form is a separate side-panel UI where row layout is handled by the form structure.

Verify template structure enforcement

Template structure rules enforced on upload/entry

Transaction Test
046
Post Property Tax receipt JE: Cash DR $2,675,059 / Property Taxes CR $2,675,059
GL BG Data Passed
Journal Entries General Ledger
  1. Navigate to /account/journal-entries and click + New Journal Entry (right-side panel)
  2. Posting Date: 10/22/2025; Memo: Property Tax receipt
  3. Line 1: Cash / Operating Account, Debit 2675059.00, Description Property Tax Receipt
  4. Line 2: Property Taxes (revenue), Credit 2675059.00
  5. Verify the footer shows ✓ Balanced ($2,675,059 = $2,675,059)
  6. Click Save Draft → show page renders with status DRAFT
  7. Click Post to GL → confirm the prompt
  8. Status flips to POSTED, Frappe voucher populated (ACC-JV-YYYY-NNNNN)
  9. Navigate to /account/general-ledger — verify the two rows for 10/22/2025 appear in the grid and the drill-down voucher link back to this JE works

Specific test scenario: Property Tax receipt $2,675,059 on 10/22/2025. Uses the manual JE pipeline exercised by SYSGOV-006 and PRE-024. All validation guards (balance, required account, period-open, not-frozen) apply.

A specific dated JE with exact amounts posts cleanly to the GL, appears in the GL inquiry grid with source drill-back to the Missio JE.

047
Post QBE receipt JE: Cash DR $1,701,047 / Intergovernmental Revenue-State CR $1,701,047
GL BG Data Passed
Journal Entries General Ledger
  1. Navigate to /account/journal-entries and click + New Journal Entry
  2. Posting Date: the September QBE receipt date; Memo: September QBE payment from state
  3. Line 1: Cash / Operating Account, Debit 1701047.00
  4. Line 2: Intergovernmental Revenue - State, Credit 1701047.00
  5. Save Draft and Post to GL
  6. Verify in the General Ledger page that the two rows appear with the correct date and amounts
  7. Verify on the P&L that the Intergovernmental Revenue account reflects the new credit balance

Specific test scenario: QBE receipt $1,701,047 from state. Uses the same manual JE + GL inquiry pipeline as TXN-046.

A large inter-governmental revenue JE posts correctly and reflects on both the GL inquiry and the P&L report.

048
Identify and correct out-of-balance Georgia Power JE: posted $68K DR vs correct $63K DR
GL BG Data Untested
Journal Entries General Ledger
  1. Navigate to /account/journal-entries and find the incorrect posted JE (Georgia Power $68,000)
  2. Click the kebab → Reverse Entry, confirm — status flips to CANCELLED, Frappe voucher is cancelled
  3. Click + New Journal Entry and create the correct $63,000 JE with the same accounts
  4. Save Draft → Post to GL
  5. Navigate to /account/general-ledger — the incorrect cancellation nets to zero and the new correct entry appears
  6. The audit trail preserves both: the original DRAFT → POSTED → CANCELLED, and the new DRAFT → POSTED

Out-of-balance corrections use the reverse-and-repost pattern. No direct edits to posted JEs are allowed.

The $5,000 discrepancy is cleared via one reversal + one correction JE, both visible in the audit trail and reflected in the GL.

049
Correct Grant Revenue misallocation: Local Share and Reimbursement amounts swapped for Grant 001 & 002
GL BG Data Untested
Journal Entries General Ledger
  1. Navigate to /account/journal-entries and find the posted JE with the swapped Grant Revenue allocation
  2. Kebab → Reverse Entry, confirm — original JE cancelled
  3. Create a new JE with the correct Local Share / Reimbursement account split
  4. Save Draft → Post to GL
  5. Navigate to /account/general-ledger — verify the Local Share and Reimbursement accounts now reflect the correct allocation

Same reverse-and-repost workflow as TXN-048. Both the original misallocation and the correction are preserved in the audit trail.

Misallocated JEs can be corrected without destroying history.

050
Create HVAC JE ($51,850) but do NOT post – verify it remains unposted for pre-close activity
GL BG Data Untested
Journal Entries
  1. Navigate to /account/journal-entries, click + New Journal Entry
  2. Enter a balanced HVAC JE: DR Instruction $51,850 / CR AP $51,850, memo HVAC pre-close accrual
  3. Click Save Draft — the JE is saved with status DRAFT, frappe_name NULL
  4. Do NOT click Post to GL — verify the JE stays in DRAFT, not in the GL, and not in Frappe
  5. The Draft Entries KPI increments by 1; the Posted Entries KPI is unchanged
  6. Later, click Post to GL to move it to POSTED status and push to Frappe

Draft JEs are a first-class state in the pipeline. They are persisted in journal_entries but never touch Frappe until Post to GL is clicked.

Unposted JEs are viewable, editable (via reverse + recreate), and remain excluded from the GL until explicitly posted.

051
Multi-Year Trial Balance: verify FY2023 totals (DR $7,262,500 / CR $519,500) and FY2024 totals (DR $12,167,500 / CR $680,500)
GL BG Data Passed
Trial Balance General Ledger
  1. Navigate to /account/finance/reports/trial-balance
  2. Select Fiscal Year FY2023 from the filter
  3. Verify the Totals row shows DR $7,262,500 / CR $519,500
  4. Switch to Fiscal Year FY2024
  5. Verify the Totals row shows DR $12,167,500 / CR $680,500
  6. The trial balance comes directly from Frappe via FrappeReportService, so the totals stay in sync with the GL

Multi-year trial balance verification reads live from Frappe. Different fiscal year filters produce different totals aggregated across all posted JEs in that FY.

The Trial Balance report supports fiscal-year filtering and produces accurate totals that match the underlying GL entries.

052
Revenue account balance verification: $3,469,200 total (DeKalb $761,200 + $591,000 + State $2,117,000)
GL BG Data Passed
Profit & Loss Trial Balance
  1. Navigate to /account/finance/reports/profit-loss
  2. Find the Revenue section and identify each revenue account
  3. Verify the DeKalb County line shows $1,352,200 ($761,200 Inv001 + $591,000 Inv002)
  4. Verify the State QBE line shows $2,117,000
  5. Verify Total Revenue = $3,469,200
  6. Cross-check against the Trial Balance page at /account/finance/reports/trial-balance

Revenue balance verification reads live from Frappe aggregations. The P&L page subtotals by account and computes the revenue total from posted JE line data.

Revenue totals on the P&L report match the sum of individual invoice postings and the Trial Balance credit column.

053
Expenditure balance verification: posted $719,250 vs unposted $51,850 HVAC draft
GL BG Data Passed
Profit & Loss Journal Entries
  1. Navigate to /account/finance/reports/profit-loss
  2. Find the Expenses section and identify each expense account
  3. Verify the school buses line shows $719,250 (posted expenditure)
  4. The HVAC JE of $51,850 is in DRAFT status (not posted), so it does NOT appear on the P&L
  5. Verify Total Expenses reflects only the $719,250 that has actually been posted
  6. On the JE list at /account/journal-entries, filter to Drafts — the unposted HVAC entry is visible there but excluded from P&L aggregations

Expenditure balance verification demonstrates that draft JEs do NOT affect the P&L until posted. Only posted entries contribute to expense totals.

The P&L expense totals reflect only posted JEs. Unposted drafts are visible in the JE list but excluded from financial report aggregations.

Accounts Payable
001
RNI reconciliation: 3-way match posts RNI accrual on receipt, clears on invoice match
AP #8 Untested
Purchase Orders Journal Entries General Ledger
  1. Navigate to /account/procurement/purchase-orders (sidebar: Supply Chain → Procurement → Purchase Orders)
  2. Click + New Purchase Order — side panel opens
  3. Select the 3-Way Match type card (blue outlined — default)
  4. Fill: Vendor Dell Inc., Total 61875.00, Order Date today, Expected Delivery today + 14d, Memo 75 laptops for Oakhurst
  5. GL mapping: Expense Account (e.g. 5310 Instruction), AP Account (2115 Accounts Payable), RNI Account required for 3-way
  6. Click Create PO — redirects to show page with auto number PO-2026-00001, status OPEN, Received $0, RNI $0
  7. Click the amber Receive Goods button — modal shows remaining $61,875
  8. Click Receive & Post RNI JE
  9. Confirm banner Goods received and RNI accrual posted
  10. Status flips to amber RECEIVED; Received = $61,875; RNI Balance = $61,875
  11. Goods Receipts table shows one row GR-2026-00001 with linked JE
  1. Navigate to /account/journal-entries and find the JE with reference GR-2026-00001
  2. Verify status POSTED, memo Goods received (RNI accrual): PO-2026-00001 — Dell Inc.
  3. Two balanced lines: DR Expense Account $61,875 / CR RNI Account $61,875
  4. Frappe voucher name populated
  1. Return to the PO show page and click Match Invoice
  2. Modal says "3-way match. RNI balance available: $61,875.00. Posts: DR RNI / CR AP."
  3. Invoice Number: DELL-INV-4567, Date today, Amount pre-filled $61,875
  4. Click Match & Post AP JE
  5. Status flips to green CLOSED, Invoiced = $61,875, RNI Balance back to $0
  6. Vendor Invoices table shows the row with match type 3 way, status matched
  7. In Journal Entries: the AP JE has DR RNI $61,875 / CR AP $61,875 — when combined with the prior RNI accrual JE, RNI net balance reconciles to zero

ApMatchingService::receiveGoods() creates a DR Expense / CR RNI balanced JE on each goods receipt to accrue the liability. ApMatchingService::matchInvoice() for 3-way then posts DR RNI / CR AP which reverses the accrual and records the actual payable. The RNI balance column on the PO list shows (received - invoiced) so reconciliation is a glance.

RNI balance accrues on receipt and nets to zero when the invoice is matched. Both JEs are balanced, posted to Frappe, and linked back to the GR and invoice records.

002
Month-end RNI accrual: goods received without invoice stays as a liability on the books
AP #9 Untested
Purchase Orders Journal Entries General Ledger
  1. Create a 3-way PO in /account/procurement/purchase-orders for $1,000
  2. Click Receive Goods, amount $1,000, submit — RNI accrual JE posts (DR Expense / CR RNI)
  3. Do NOT match an invoice yet
  4. Return to the PO list — the row shows RNI Balance $1,000.00 in red, status RECEIVED
  5. Index KPI RNI Balance totals all POs where received > invoiced — this is the month-end accrual total
  6. Navigate to /account/general-ledger and filter to today's date — verify DR Expense, CR RNI rows appear
  7. The RNI Balance persists as a liability until the vendor invoice arrives and is matched
  8. Close the month in Period Close — the RNI liability stays on the balance sheet with correct period attribution

Month-end RNI accrual is automatic in the 3-way flow: every goods receipt posts DR Expense / CR RNI, so any receipt without a matching invoice shows as a liability on the balance sheet. The KPI card on the index page tracks total RNI balance in real time.

Unmatched 3-way receipts automatically appear as RNI liability on the balance sheet. The accrual is posted per-receipt, not as a manual month-end batch, which means the liability is always current.

003
2-way and 3-way matching rules reflected in GL + tolerance guard
AP #10 Untested
Purchase Orders Journal Entries General Ledger
  1. Create a 3-way PO (see AP-001 for detailed steps)
  2. Receive goods — verify the DR Expense / CR RNI accrual JE posts
  3. Match the vendor invoice — verify the DR RNI / CR AP reversal JE posts
  4. Status progresses: OPEN → RECEIVED → CLOSED
  1. Click + New Purchase Order, switch to the purple 2-Way Match type card
  2. Confirm the RNI Account label changes to "(not used for 2-way)"
  3. Vendor Georgia Power, Total 63000.00, Expense Account, AP Account. RNI account can be left blank or any value
  4. Create PO — on the show page, confirm there is no "Receive Goods" button (2-way skips GR)
  5. Click Match Invoice — modal says "2-way match. Posts: DR Expense / CR AP."
  6. Invoice Number GP-0925-1234, amount $63,000, submit
  7. Verify the JE posts directly DR Expense / CR AP (no RNI intermediate step)
  8. Status flips straight to CLOSED
  1. Create a 3-way PO for $1,000
  2. Try to receive $1,200 — expect error Receipt amount (1200) exceeds PO remaining (1000). No GR record or JE is created
  3. Receive $1,000 cleanly
  4. Try to match an invoice for $1,500 — expect error 3-way match failed: invoice 1500 exceeds RNI balance 1000. No invoice record or JE is created
  5. Match $1,000 exactly — status closes
  1. Close today's period in /account/period-close
  2. Try to receive goods against a 3-way PO — error Accounting period is closed. Posting is blocked.
  3. No GR record or JE is created until the period is reopened

ApMatchingService branches on po.match_type: 2_way posts DR Expense / CR AP directly; 3_way posts receipt as DR Expense / CR RNI and the invoice match as DR RNI / CR AP. Tolerance config (gl.ap_match_tolerance, default $0.01) allows minor rounding. Period-close and frozen-account guards run before any DB write.

Both 2-way and 3-way flows post balanced JEs to the GL with the correct account pattern. Tolerance guard rejects over-receipt and over-invoice attempts with specific error messages. Period-close guard blocks GR and match posting when the period is closed.

004
1099 vendor payments tracked in GL with correct flagging
AP #12-15 Untested
1099 Tracking
  1. Navigate to /account/finance/accounts-payable/1099-tracking
  2. Verify the vendor list with 1099 eligibility flag (is_1099 column) and YTD totals per vendor
  3. Use the pill tabs to filter by 1099 status: All / 1099 / Non-1099
  4. Drill into a vendor — see year-to-date payments breakdown
  5. Toggle 1099 eligibility via the kebab actions — the flag persists
  6. Export batch for year-end 1099 filing — prepares the data for IRS submission

Tracking1099Controller (existing) renders the 1099 vendor list with eligibility flag and YTD totals. Vendor-level drill-down shows every payment that hits the GL for that vendor.

1099 vendors are tracked alongside AP with proper eligibility flagging, YTD totals, and batch export for year-end filing.

005
Recurring voucher with unique invoice numbers posts correctly to GL each month
AP #16-17 Untested
Recurring JEs Journal Entries
  1. Navigate to /account/recurring-jes, click + New Recurring Template
  2. Name Monthly Rent Accrual, Prefix RENT, Frequency Monthly, Start Date today
  3. Lines: DR 5310 Instruction $2,500; CR 2115 CSD Accounts Payable $2,500
  4. Create Template, then click Run Now — confirm
  5. Run Count increments, Last Run = today, Next Run advances one month
  6. Click the posted JE in Run History — reference RENT-RJE-2026-00001-20260414, memo Monthly Rent Accrual, two balanced lines, Frappe voucher populated. Each monthly run gets a unique reference that embeds the posting date so no two runs share the same reference
  7. Run Now again — posts a second run with a later reference date and increments Run Count to 2
  8. Every run posts a real Journal Entry to the GL via the same pipeline as manual JEs, so period-close + frozen-account guards apply
  1. Create a template with Start Date = today and End Date = today + 30 days (1 monthly run remaining)
  2. Run Now — posts the first and only JE, status transitions from ACTIVE to COMPLETED because next_run_date would be past end_date
  3. Run Now button disappears; no further runs are possible

Each recurring run generates a unique reference combining the reference_prefix, template number, and posting date (e.g. RENT-RJE-2026-00001-20260414), ensuring no two runs share the same reference even when they fall in the same month. The template status auto-transitions to 'completed' when next_run_date would exceed end_date.

Recurring AP vouchers post a unique JE for each run, with unique references, and the status transitions correctly through active → completed when the end date is reached.

006
Stopping a recurring payment reflects correctly in GL
AP #18 Untested
Recurring JEs
  1. Navigate to /account/recurring-jes, find an active template
  2. On the show page click Pause
  3. Status flips to PAUSED; the Run Now button disappears
  4. Navigate back to the index — the Due Now KPI decrements and the Paused KPI increments
  5. Run php artisan gl:process-recurring-jes — paused templates are skipped, no JE is posted
  6. Click Resume to reactivate — future runs resume on the scheduled next_run_date

RecurringJeService::pause flips status to "paused", which is checked by runNow() (rejects) and processDue() (excludes from the due-query).

Stopping a recurring payment is a one-click pause. No future JEs post until the template is resumed.

007
Future-dated expense posts to GL on effective date
AP #19 Untested
Journal Entries
  1. Click + New Journal Entry
  2. Set Posting Date to a future date (e.g. today + 30 days) in an open period
  3. Create a balanced expense JE (DR expense account / CR AP)
  4. Save Draft and Post to GL
  5. Navigate to /account/general-ledger — the JE is visible with the future posting date
  6. On the balance sheet / P&L, the expense hits the period that contains the posting date, not today's period

JournalEntryController accepts any posting_date as long as its containing period is open. There is no "today-only" restriction; future-dated entries post with the correct effective date.

Future-dated expenses post to the GL on their effective date, not the system date.

Travel Expenses
008
Expense types linked to default GL account numbers via FrappeExpensePoster mapping
TE #7 Passed
Travel & Expense General Ledger
  1. Navigate to /account/finance/other/travel-expenses (existing Travel & Expense module)
  2. Verify the expense list with linked GL account column
  3. TravelExpenseController uses FrappeExpensePoster::resolveExpenseAccount to map expense type → GL account:
    • airfare / hotel / mileage / parking → 5216 Travel Expenses
    • meals → 5223 Miscellaneous Expenses
    • conference → 5310 Instruction
    • default → 5223 Miscellaneous Expenses
  4. Create a new travel expense with each type to verify the correct GL account is used
  5. Each expense auto-posts a balanced JE to Frappe via the FrappeExpensePoster observer chain

TravelExpenseController and FrappeExpensePoster (existing Missio code) handle T&E expense posting with per-expense-type account routing. The mapping table is defined in FrappeExpensePoster::resolveExpenseAccount.

Travel expenses automatically route to the correct GL account based on expense type. Drill-down from GL entry to travel expense detail works via the source link.

009
Travel expense reimbursement integrates real-time with AP module for payment
TE #12 Passed

PASSED — New ExpenseService::postApprovalsToGL() takes a batch of approved, unposted travel expenses, groups debits by gl_account, and posts one balanced JE (DR each unique expense account / CR a single payable/cash account). Period-close + frozen-account guards apply. Each posted expense gets journal_entry_id stamped so re-posting is blocked. Full drilldown via new Expense entry in JournalEntry::SOURCE_MAP. New "Post Approved to GL" button on the travel expenses index opens a modal to pick the payable account and confirm.

  1. Visit /account/finance/other/travel-expenses (sidebar: Finance → Other → Travel and Expense)
  2. Run the existing TravelExpenseDemoSeeder or submit Tori Williams' demo expenses (airfare $798, hotel $2,659.42, conference $885, mileage $10.50, parking $180)
  3. Mark each approved (individual action or via principal workflow)
  4. Check the approved rows (checkbox column), click Post Approved to GL in the header
  5. Enter the payable account (e.g. 2170 - Travel Reimbursement Payable - DSD) in the modal
  6. Click Post & Create JE → success toast, one JE appears in /account/journal-entries
  7. Verify JE: DR each unique travel GL account summed (e.g. $3,643.42 to 580-Travel, $885 to 520-Professional Dev) / CR $4,528.42 to payable. Balanced. Frappe voucher populated. Source = "Travel Expense".
  8. Re-posting blocked: same expenses now have journal_entry_id set — selecting them and clicking Post again returns "No eligible expenses found"
Chart of Accounts

Approved expense report for Tori Williams (non-P-Card expenses). Not built — future AP integration scope.

AP module receives expense data; GL updated with expense and liability entries

P-Cards
010
P-Card transactions post to appropriate GL expenditure account
PC #6 Passed

PASSED — New P-Cards subsystem: p_cards + p_card_transactions tables, PCardService with record/approve/reject/postBatch methods, full CRUD controller + views. Transactions flow through an approve-then-post workflow: enter transaction (PC-011 validation) → principal reviews → bookkeeper posts approved batch. Batch posting creates one balanced JE: DR each unique expense account / CR P-Card Clearing, through the shared createJe + Frappe pipeline. Sidebar link under Finance → Other replaces the legacy javascript:void(0); placeholder.

  1. Sidebar: Finance → Other → P-Cards
  2. Click + New P-Card — create card "****4821" for Patrice Allison, Oakhurst Elementary, credit limit $5000, default clearing 2160 - P-Card Clearing
  3. Open the card → click + New Transaction
  4. Record the 3 Oakhurst June 2025 demo charges:
    • PAGE dues $180 → account "Professional Fees"
    • Target gift cards $350 → account "School Supplies"
    • Atlanta Zoo field trip $1,780 → account "Field Trips"
  5. Each transaction saves with status pending; PC-011 validation runs (GL account required, not frozen, budget check if linked)
  6. Click Approve on all 3 transactions → status flips to approved
  7. Check all 3 boxes in the batch selector, click Post Batch to GL
  8. Verify: one JE posted with 4 lines (3 debits to School Supplies / Professional Fees / Field Trips + 1 credit to P-Card Clearing $2,310), balanced, Frappe voucher populated, source = "P-Card Transaction"
  9. Each transaction row now shows "posted" + JE link
General Ledger

Oakhurst: PAGE $180 (Prof Fees), Target $350 (Supplies), Zoo $1,780 (Field Trips); Glennwood: Teachers Supply $550 (Small Equip), Oriental $126 (Supplies)

Each P-Card transaction posts to correct GL expenditure account per account distribution

011
P-Card GL account, expense type, and budget validated at time of entry
PC #1 Passed

PASSEDPCardService::recordTransaction() enforces three validation gates at entry time (per CSD demo spec "validate GL account, expense type and budget at the time of entry"):
GL account required — rejects with "GL expense account is required" if blank
Frozen account check — calls FrappeAccountService::isFrozen(); rejects with "GL account <name> is frozen. P-Card entry blocked"
Budget check — if budget_allocation_id is set, queries budget_allocations.available_amount and rejects if charge exceeds available

  1. Open any P-Card → click + New Transaction
  2. Gate 1 — try to submit with blank GL account → validation error "GL expense account is required"
  3. Gate 2 — use /account/chart-of-accounts to freeze an account, then try to submit a P-Card charge against it → error "GL account X is frozen. P-Card entry blocked"
  4. Gate 3 — create a BudgetAllocation with available_amount = $100, then try to charge $500 to it → error "Budget allocation has only $100 available; cannot charge $500"
  5. Submit a valid transaction (GL account set, not frozen, no budget link or sufficient budget) → saves successfully with status pending
Travel & Expense General Ledger

Enter P-Card charge and assign GL account code

System validates GL account exists, expense type is valid, budget is available

Budget
012
Budget adjustment between accounts reflects in GL (Dues & Fees +$2K from Communication -$2K)
BU #17 Untested
Budget Transfers Journal Entries General Ledger
  1. Navigate to Budget Transfers (existing module under Finance → Budgets)
  2. Create a new transfer: From Communication account, To Dues & Fees account, Amount $2,000
  3. Approve the transfer — BudgetTransferController calls FrappeJournalEntryPoster which posts DR To / CR From as a balanced JE to Frappe
  4. Navigate to /account/journal-entries and find the new posted JE
  5. Navigate to /account/general-ledger — verify the Dues & Fees balance +$2,000 and Communication -$2,000

Existing Missio BudgetTransfer module with FrappeJournalEntryPoster handles account-to-account budget adjustments with auto-JE posting.

Inter-account budget adjustments post a real JE to the GL, not just a memo-only transfer record.

013
Budget exceeded workflow triggers before GL transaction can process
BU #18 Passed

PASSEDBudget Control built at Finance → Other → Budget Control. A dedicated BudgetCheckService runs on every JE post: for each expense debit line it extracts the short account code, looks up the active BudgetAllocation for the fiscal year (derived from posting date, Jul-Jun CSD calendar), computes available balance (allocated − expended − encumbered + transfers_in − transfers_out), and compares against the requested debit. Enforcement mode is configurable via GL_BUDGET_ENFORCEMENT_MODE: hard throws BudgetExceededException and blocks the post, soft allows the post with a WARN message on the status banner, off short-circuits the check entirely. On successful post, recordExpenditure() increments the allocation's expended_amount and cascades to the parent budget. BUD-013 closes the whole workflow; JE post pipeline refuses to commit to Frappe when mode=hard and budget is exceeded.

Budget Control Journal Entries

Glennwood Sub for Certified: budget $37,776, spent $36,918, new charge $2,134

System blocks transaction; workflow initiated to transfer funds before processing

014
Configurable budget control prevents individual accounts from going negative
BU #19 Passed

PASSED — Per-account control is enforced via the account_code column on each BudgetAllocation row. The check runs line-by-line on JE post: each expense debit line resolves to its short code (e.g. 5213 from 5213 - Salary - DSD), BudgetCheckService::findAllocation() matches by (company, account_code, optional fund_code, fiscal_year), and the per-account available balance is verified in isolation — not pooled. The Budget Control dashboard shows the per-account table with traffic-light utilization bars (green <80%, amber 80-100%, red over), and rows flagging the overspent account are highlighted. Hard mode blocks any single account from going negative independently of its siblings.

Budget Control

Attempt to overspend Substitute for Certified account

Hard stop prevents negative balance on controlled account

015
Budget control over account groups prevents group total from going negative
BU #20 Passed

PASSED — Group/category rollup control is supported via the category column on each BudgetAllocation row. Assign related accounts (e.g. "Operational", "Capital", "Personnel") to a shared category; BudgetCheckService::categorySummary() aggregates allocated, expended, encumbered, and available amounts across the whole group. The Budget Control dashboard renders a Per-Category Rollup table below the per-account view, with the same traffic-light utilization bars. Rows where a whole category is over-committed highlight red. This is orthogonal to per-account control: both checks apply to the same JE on post, so the more restrictive of the two wins.

Budget Control

Operational expenditure account group at Glennwood Elementary

Group-level control prevents total operational accounts from going negative

016
Approved budget amounts move into new fiscal year GL budget accounts
BU #16 Passed

PASSED — Fiscal-year carryforward is handled by BudgetCheckService::carryforward(fromFy, toFy): for each active budget in the source FY, a new budget row is created in the destination FY and every allocation with a positive available_amount is cloned with allocated_amount = old.available_amount. Categories, fund codes, department links, and grant program links are all preserved. New allocations start in active state; the parent budget is draft so an approver can review the rollover before activating. Two paths are supported: (1) the Run Carryforward form on the Budget Control dashboard, and (2) the CLI php artisan gl:budget-carryforward --company=X --from=Y --to=Z --dry-run. Dry-run mode previews the count + total dollars without writing.

Budget Control

FY2025-2026 approved budgets for all departments/schools

GL budget accounts populated with approved FY2025-2026 amounts

017
Capital budget encumbrances and purchasing activity tracked against GL
BU #32 Passed
Encumbrances Journal Entries General Ledger
  1. Navigate to /account/encumbrances (Finance → Other → Encumbrances)
  2. Click + New Encumbrance; select Pre-Encumbrance type
  3. Vendor: Legacy Construction Co, Account: Capital Outlay, Amount: 355422.00 (FY24-25 portion)
  4. Save Encumbrance — row appears with PRE-ENC badge, Pre-Encumbered KPI updates
  5. Promote to encumbrance via the kebab menu — status flips to ENCUMB
  6. When work is complete, click Convert to Expenditure — FixedAssetService analogous flow auto-posts a balanced JE (DR Capital Outlay / CR Cash) and links the encumbrance to the new JE
  7. Navigate to /account/journal-entries — verify the new POSTED JE with source link back to the encumbrance
  8. Navigate to /account/general-ledger — verify the capital expenditure rows
  9. For multi-year projects (FY24-25 + FY25-26 = $2,855,422 total), repeat the encumbrance-promote-convert cycle for each year's portion. The encumbrance records persist across fiscal year boundaries

Legacy Track & Field Facility: FY2024-2025 $355,422; FY2025-2026 $2,500,000. Both years' capital encumbrances tracked through the Encumbrances module and posted to GL via the convert-to-expenditure flow.

Capital project encumbrances are tracked against GL through the encumbrance lifecycle (Pre-Enc → Enc → Converted), with each conversion posting a real JE to the Capital Outlay account. Multi-year projects use multiple encumbrance records.

Cash Receipts
018
QBE deposit (ACH) $2,149,897.83 recorded in General Fund and primary account in GL
CR #1-2 Passed
Journal Entries General Ledger
  1. Navigate to /account/journal-entries, click + New Journal Entry
  2. Posting Date: the QBE deposit date; Reference: ACH-QBE; Memo: QBE deposit from State
  3. Line 1: 1115 Operating Account (General Fund), Debit 2149897.83
  4. Line 2: Intergovernmental Revenue - State, Credit 2149897.83
  5. Save Draft and Post to GL
  6. Verify the JE appears in the GL page filtered to the Operating Account

Specific scenario: $2,149,897.83 QBE ACH deposit. Uses the General Fund (Operating) account as the primary cash target.

Large cash receipt JE posts to the GL with General Fund account as the target.

019
Bank interest credit $27,981 recorded in GL
CR BG Untested
Bank Reconciliation Journal Entries
  1. Navigate to /account/bank-recon (Finance → Other → Bank Reconciliation)
  2. Click + New Reconciliation
  3. Bank Account: 1115 - Operating Account, Period: current month, Opening Balance $0, Statement Balance $10,000
  4. Create — redirects to the two-pane show page
  5. Click + Add Statement Line and add 2–3 bank lines summing to $10,000 (positive = deposit, negative = withdrawal)
  6. Confirm all new lines show amber UNMATCHED
  7. Red "Difference" banner shows "$0.00 — Unmatched: N" — not reconciled yet because unmatched lines exist
  1. Ensure posted JEs on the bank account exist within the period (from AR payments, AP payments, FA disposals, etc)
  2. Click the blue Auto-Match button — banner shows "Auto-matched N line(s)"
  3. Matched rows turn green with AUTO MATCHED badge and linked JE id
  4. The GL Side pane (right) lists remaining unmatched JE lines on the same account within ±3 days of the period
  5. For a bank line that didn't auto-match, click the blue Match button → modal opens, pick a GL line from the dropdown, submit → line flips to MANUAL MATCHED
  6. Click the red × on any matched row to unmatch it and retry
  1. Once all lines are matched and difference = $0.00, the banner turns green: ✓ Reconciled
  2. Click Complete — status flips to COMPLETED, the reconciliation is locked (all action buttons disappear)
  3. Create a second recon with a $2,000 difference and only partial matches, click Complete — expect error Reconciliation cannot be completed: difference $2000.00 or N unmatched items.

BankReconService handles createReconciliation, addItem, autoMatch (finds JE lines on the same account with matching amount and date within ±3 days), manualMatch, unmatch, and complete. Unique index on matched_je_line_id prevents double-matching. The show page displays two panes: bank side (from bank_recon_items) and GL side (unmatched JE lines query).

Bank rec creates, accepts statement lines, auto-matches against the GL, allows manual match/unmatch, and gates completion on zero difference + zero unmatched.

020
Local school deposit $1,345 for field trip bus expenses recorded in GL
CR BG Passed
Journal Entries General Ledger
  1. Click + New Journal Entry
  2. Line 1: 1118 Local School Funds, Debit 1345.00, Description Field trip bus expenses - local school deposit
  3. Line 2: appropriate revenue account, Credit 1345.00
  4. Save Draft and Post to GL
  5. Verify the GL entry on the GL inquiry page

Specific scenario: $1,345 local school deposit for field trip expenses. Uses a local school funds account.

Small local deposits post to a specific GL account via the standard JE form.

021
School Nutrition daily deposits ($5,431 + $4,234 + $6,107) recorded in GL
CR BG Passed
Journal Entries General Ledger
  1. Click + New Journal Entry three times (one per daily deposit)
  2. Day 1: DR Cash $5,431 / CR School Nutrition Revenue $5,431
  3. Day 2: DR Cash $4,234 / CR School Nutrition Revenue $4,234
  4. Day 3: DR Cash $6,107 / CR School Nutrition Revenue $6,107
  5. Post each to the GL
  6. Verify the three JEs appear in the GL with the correct daily dates and cumulative revenue impact

Three daily School Nutrition deposits ($5,431 + $4,234 + $6,107 = $15,772 total) posted as separate dated JEs to preserve the daily deposit audit trail.

Daily deposits can be posted as individual JEs that aggregate correctly on the P&L and Trial Balance reports.

022
Ad valorem tax receipt $47,945 (ACH) recorded in GL
CR BG Passed
Journal Entries General Ledger
  1. Click + New Journal Entry
  2. Line 1: Cash / Operating Account, Debit 47945.00
  3. Line 2: Ad Valorem Tax Revenue, Credit 47945.00
  4. Reference: ACH-AVT, Memo: Ad valorem tax ACH receipt
  5. Save Draft and Post to GL
  6. Verify the JE appears on the GL inquiry and the revenue reflects on the P&L

Specific scenario: $47,945 ad valorem tax ACH receipt. Posts through the same manual JE pipeline as other tax receipts.

Ad valorem tax receipts post to the correct revenue account and appear on the P&L and Trial Balance.

023
BAI file codes automatically coded and sent to GL
CR #16 Passed

PASSED — Full BAI2 bank file import pipeline built. BaiImportService::parseFile parses standard BAI2 records (01 file header, 03 account ident, 16 transaction detail + 88 continuation) and extracts every transaction with type code, amount (cents), direction (credits 100-399, debits 400-699), customer/bank references, and description. Auto-maps each transaction to a GL account via bai_mapping_rules (type_code + optional description_match). postImport() posts one balanced JE per transaction: DR Cash / CR mapped account for credits, reversed for debits. Period-close + frozen-account guards apply per transaction. Idempotent.

  1. Sidebar: Finance → Other → Bank Imports (BAI)
  2. Click + Upload BAI File, select a .bai or .txt file in BAI2 format from the bank
  3. Parser runs → redirect to import show page with the parsed transactions table (line #, date, type code, description, direction badge, amount, GL account dropdown)
  4. Review each row — adjust the mapped GL account if the default mapping is wrong (dropdown + Save button per row)
  5. Pick the Cash Account in the green post panel at the bottom (dropdown filtered to Bank/Cash accounts)
  6. Click Post All Transactions → each row becomes a balanced JE posted to the GL and Frappe. Status flips to "posted" with JE # link
  7. Open /account/journal-entries — all N new JEs are listed with reference BAI-<import>-<line> and memo "BAI import <filename> line <N>"
  8. Re-running the post endpoint errors: "Import already posted" (idempotent)
  1. Sample BAI2 parsed 3 transactions: QBE deposit $2,149,897.83 (type 142 credit), Interest credit $279.81 (type 165 credit), Georgia Power utility payment $1,250 (type 475 debit) — all mapped correctly with direction derivation from type code range.
General Ledger

BAI file from bank with transaction codes

BAI transactions auto-coded to correct GL accounts

024
Bank reconciliation: GL balance $497,000 vs bank statement $497,200 – reconcile $200 difference
CR #30 Untested
Bank Reconciliation Journal Entries
  1. Navigate to /account/bank-recon, click + New Reconciliation
  2. Bank Account 1115 - Operating, Statement Balance 497200, Opening Balance 0
  3. Add a single bank statement line of 497000.00
  4. Observe the red Difference banner: Difference: $200.00 — Unmatched: 1
  5. Auto-Match or Manual Match the $497,000 line against the corresponding GL journal entry line on the operating account
  6. The Difference stays at $200 because the bank shows $200 more than the GL side
  7. Resolve by adding a reconciling item (e.g. a $200 interest credit) and matching it to a GL journal entry posted for the same amount. Or adjust the bank line and re-match
  8. Once the difference lands at $0 and zero items are unmatched, click Complete
  9. Confirm the recon enters COMPLETED status with completed_at timestamp

CR-024 scenario: GL balance $497,000 vs bank statement $497,200. The $200 gap is resolved by finding the missing reconciling item (interest credit, outstanding check, timing difference).

The bank recon engine lets you identify the $200 difference instantly via the banner, find and match the missing GL line, and complete only when fully balanced. The locked completed state prevents further edits.

025
Automated journal entries upon receipt of payment with tagging rules to direct GL posting
CR #4 Untested
AR Invoices Journal Entries
  1. Navigate to /account/ar-invoices
  2. Create a new AR invoice for any customer, any amount
  3. Open the invoice and click Apply Payment
  4. Enter payment date, cash account, amount
  5. ArService::applyPayment() automatically creates and posts a balanced DR Cash / CR Receivable JE using the cash_account field as the DR target
  6. Verify the new posted JE in /account/journal-entries with source link back to the AR Payment row
  7. The tagging rule: cash_account on the ArPayment row directs the DR posting, the receivable_account on the ArInvoice row directs the CR posting

ArService::applyPayment auto-creates a balanced JE on every payment receipt with configurable routing via the cash_account and receivable_account fields on the invoice and payment records.

Payment receipts auto-generate JEs with configurable GL routing based on per-customer / per-payment account mapping.

AR
026
Full payment applied against AR and reflected in GL (Durbin $225, Sanchez $235, Leonard $1,500)
AR #2 Passed
AR Invoices Journal Entries General Ledger

PASSED — Full payment verified end-to-end against the CSD demo parents seeded by ArCsdDemoSeeder. Karoline Durbin paid in full for Melissa's $225 after-school fee; Sanchez paid $235 for Diana; Leonard paid $1,500 for Maria. Each payment creates an ArPayment row with ar_customer_id copied from the parent invoice, flips the status to PAID, and posts a balanced DR Cash / CR Receivable JE through ArService::applyPayment.

  1. Go to /account/ar-customers (sidebar: Finance → Receivables → AR Customers)
  2. If no invoices exist yet, click Run Recurring Billing Now to auto-generate the month's invoices for all 6 seeded parents
  3. Click Karoline Durbin → the Invoices section shows the auto-generated invoice AR-YYYY-NNNNN with one line "After-School Care — Melissa ($month $year) $225.00"
  4. Click the invoice number → show page opens at /account/ar-invoices/{id}
  5. Click Apply Payment — modal pre-fills $225.00 in the Amount field
  6. Cash Account: 1115 - Operating Account - DSDD; Reference: CHK-1234; click Apply & Post JE
  7. Verify banner "Payment applied and posted to GL"; status flips green PAID; Outstanding $0, Amount Paid $225.00
  8. Payment History shows row AR-PMT-YYYY-NNNNN with cash account and JE link
  9. Navigate to /account/journal-entries — new POSTED JE, memo "Payment applied to AR-YYYY-NNNNN", two lines (DR 1115 Operating / CR 1320 Student Receivables), Frappe voucher populated
  10. Back to Karoline's customer show page — Outstanding Balance drops to $0.00, invoice status PAID
  11. Repeat for Sanchez ($235) and Leonard ($1,500) using their seeded recurring invoices

ArService::applyPayment() checks period open, checks accounts not frozen, creates ArPayment row, creates balanced JE (DR cash / CR receivable), posts to Frappe, updates invoice.amount_paid and status. All in a DB transaction.

Full payment creates a balanced cash receipt JE, clears AR, and marks the invoice PAID. Payment record links to the JE.

027
Partial payment ($100 of $225 Bass; $1,250 of $1,500 Decker) reflected in GL with remaining balance
AR #2 Passed
AR Invoices Journal Entries General Ledger

PASSED — Partial payment verified end-to-end with Melissa Bass (paid $100 of $225 for Devon's after-school) and Thomas Decker (paid $1,250 of $1,500 for Hernando's tuition). Each partial creates a distinct ArPayment row and its own balanced JE; invoice transitions sent → partially_paid; outstanding reflects on the customer profile and aging report.

  1. Go to /account/ar-customersMelissa Bass → Invoices section → click the $225 After-School Care invoice
  2. Click Apply Payment; change the pre-filled $225 to $100; Cash Account 1115 - Operating Account - DSDD; click Apply & Post JE
  3. Verify status flips amber PARTIALLY PAID; Outstanding = $125; Amount Paid = $100
  4. Payment History shows one row; Apply Payment button still visible
  5. Back to Melissa Bass customer page — Outstanding Balance = $125.00
  6. Repeat with Thomas Decker: click his $1,500 K-12 Tuition invoice, Apply Payment of $1,250, verify Outstanding = $250
  7. Open /account/ar-aging — Melissa Bass $125 and Thomas Decker $250 both show in the 0-30 bucket
  8. Open /account/journal-entries — each partial payment posted its own balanced JE with Frappe voucher, ar_customer_id stamped on each ar_payments row

Each partial payment creates a distinct ArPayment row and its own JE. Invoice status transitions SENT → PARTIALLY_PAID until outstanding reaches zero.

Multiple partial payments accumulate on the invoice; each creates its own balanced JE in the GL; status transitions correctly and over-payment is guarded.

028
Write-off for doubtful accounts posts to GL
AR #5 Passed
AR Invoices Journal Entries General Ledger

PASSED — Write-off verified against the CSD demo flow: Natalie & Ben Beisner's $215 after-school invoice for Todd written off as uncollectible. The JE memo and write-off description now use the linked customer name (not the dropped customer_name column) via the customer relationship. Status terminal, buttons hide, audit trail captured.

  1. Go to /account/ar-customersNatalie & Ben Beisner → Invoices section → click the open $215 After-School Care invoice
  2. Click the red Write Off button — modal opens showing outstanding $215
  3. Reason: Family moved out of district; confirmed uncollectible after 3 contact attempts
  4. Bad Debt Expense Account: pick any expense account from the Frappe CoA dropdown
  5. Click Write Off & Post JE
  6. Verify red WRITTEN OFF status; Amount Written Off = $215; Outstanding = $0
  7. Red write-off banner at the bottom with reason, timestamp, and linked JE ID
  8. Apply Payment and Write Off buttons disappear (terminal state)
  9. /account/journal-entries — new POSTED JE WO-AR-YYYY-NNNNN, DR Bad Debt / CR 1320 Student Receivables, memo "Bad debt write-off: Natalie & Ben Beisner", Frappe voucher populated
  10. Natalie & Ben's customer show page — Outstanding $0; invoice marked WRITTEN OFF
  11. Aging report: Natalie & Ben no longer appears (no outstanding balance)

ArService::writeOff() requires non-empty reason, checks period open + accounts not frozen, creates a DR Bad Debt / CR Receivable JE for the exact outstanding balance (not total_amount), posts to Frappe, flips the invoice to 'written_off', records the reason, user, and timestamp.

Write-off posts a balanced bad-debt JE, clears the remaining AR balance, locks the invoice into a terminal WRITTEN_OFF state with full audit trail.

029
Non-invoice payment posted correctly to GL
AR #6 Passed
AR Invoices Journal Entries

PASSED — Dedicated cash-receipt flow is now built. ArService::recordCashReceipt() posts DR Cash / CR Revenue directly, stores a structured ar_cash_receipts row (customer, date, amount, memo, accounts), and the JE is tagged source_type=ArCashReceipt for drill-down. Frappe sync and period-close / frozen-account guards all apply.

  1. Go to /account/ar-invoices
  2. Click + Record Cash Receipt (new outline button next to + New AR Invoice)
  3. Fill Customer / Payer (e.g. "Walk-in Gate Receipts"), Receipt Date, Amount, optional Memo
  4. Pick a Cash account (DR) and a Revenue account (CR) from the Frappe CoA dropdowns
  5. Click Record & Post Receipt JE
  6. Toast confirms receipt number AR-CR-YYYY-NNNNN and redirects to index
  7. Open /account/journal-entries — the new JE appears balanced, with Source = "AR Cash Receipt" drilldown back to AR Invoices index
  8. Verify in Frappe: same JE posted via createAndSubmit('Journal Entry', ...)
  1. Period-close guard — PeriodCloseService::ensureOpenForDate() blocks closed periods
  2. Frozen-account guard — blocks if either cash or revenue account is frozen in Missio cache
  3. Amount > 0 validation on both client and server

Walk-in / non-invoice cash receipt: e.g. athletic gate receipts, walk-in donations, misc income. Captured via dedicated ArCashReceipt record + balanced JE with source attribution.

Cash receipt record saved; balanced JE (DR Cash / CR Revenue) posted to Missio and Frappe; JE drilldown links back to AR Invoices index with source tag "AR Cash Receipt"; subject to period-close and frozen-account guards.

030
AR module close syncs with GL close on user-defined schedule
AR #13 Passed
AR Invoices Journal Entries General Ledger
  1. Issue a $100 AR invoice to any customer
  2. Click Apply Payment, enter $150 (over the outstanding), apply
  3. Verify red banner: Payment (150) exceeds outstanding balance (100). and no JE is created
  1. Navigate to /account/period-close, close the period covering today (e.g. P10 Apr 2026)
  2. Return to AR Invoices and try to issue a new invoice dated today
  3. Verify inline error: Accounting period 'P10 — Apr 2026' is closed. Posting is blocked.
  4. Reopen the period
  1. Navigate to /account/chart-of-accounts, freeze your Receivable account via ⋮ → Freeze Account
  2. Return to AR Invoices and try to issue a new invoice using that frozen Receivable
  3. Verify error: Account ... is frozen. Operation blocked.
  4. Cleanup: unfreeze the account

ArService validates all state transitions at the service layer before touching the DB: period-close via PeriodCloseService::ensureOpenForDate(), frozen accounts via FrappeAccountService::isFrozen(), over-payment via outstanding() calculation. All checks run before the DB transaction opens.

AR subledger enforces the same period-close + frozen-account guards as the JE pipeline, and prevents over-payment. The AR module closes with the GL because every AR action posts through the shared JournalEntry table.

031
AR customer dimension — parent/government/organization types with student sub-table
AR #1,11 Passed
AR Customers

PASSED — Dedicated AR customer dimension built via ar_customers table with customer_type enum (parent / government / organization), child ar_customer_students table, FK on ar_invoices / ar_payments / ar_cash_receipts. Type-aware create form: parents see a Students sub-form, governments see a Lockbox Account field. ArCustomerService powers CRUD, picker search by name / number / student, outstanding balance, and aging buckets.

  1. Sidebar: Finance → Receivables → AR Customers
  2. Index shows 8 seeded customers: 6 parents (Karoline Durbin / Melissa, Melissa Bass / Devon, Natalie & Ben Beisner / Todd, Gina & Frank Sanchez / Diana, Thomas Decker / Hernando, Raymond & Sue Leonard / Maria) and 2 government (DeKalb County with lockbox 12301, Georgia DOE)
  3. Click type filter pill Government → only DeKalb + Georgia DOE shown
  4. Click Karoline Durbin → profile shows contact info, outstanding balance + aging buckets, Students section with Melissa, Recurring Charges section ("After-School Care — Melissa — $225.00 — Monthly (day 1) — Active"), and Invoices history
  5. Click + New Customer → right-slide form, type radio defaulted to Parent with Students sub-form visible
  6. Switch type to Government → Students sub-form hides, Lockbox Account # field appears
  7. Close without saving
  8. Customer picker search (used on invoice + cash-receipt forms): type "Melissa" → should return both Melissa Bass (name match) and Karoline Durbin (because her student Melissa matches)
032
Multi-line AR invoice with per-line student attribution + grouped revenue credits
AR #1 Passed
AR Invoices

PASSEDArService::issueInvoice accepts ar_customer_id + array of line items, creates ar_invoice_lines rows, and posts a balanced JE with one debit on the receivable and grouped credits per unique revenue account. Invoice create modal has a searchable customer picker that pre-fills defaults and populates per-line student dropdowns. Smoke test: $1,500 Tuition + $50 Activity + $25 Lab to Thomas Decker posted as DR $1,575 receivable / CR $1,550 "Charges For Services" / CR $25 "Service" — total balanced, 3 unique line rows.

  1. Go to /account/ar-invoices → click + New AR Invoice
  2. Customer picker at top: type "Decker" → pick Thomas Decker
  3. Blue "Selected: Thomas Decker" bar appears; receivable account pre-filled to 1320 - Student Receivables - DSDD; first line row student dropdown now contains Hernando
  4. Fill Line 1: description "K-12 Tuition", amount 1500, revenue 4130 - Charges For Services - DSDD, student Hernando
  5. Click + Add Line → Line 2: "Activity Fee", 50, 4130, Hernando
  6. Click + Add Line → Line 3: "Lab Fee", 25, 4120 - Service - DSDD, Hernando
  7. Click Issue & Post Invoice JE → success toast, redirect to invoice show page
  8. Show page: Line Items table displays 3 rows with student "Hernando", total footer $1,575.00
  9. /account/journal-entries: the JE has 3 lines (not 4) — 1 debit on 1320, 2 credits (one per unique revenue account with amounts grouped)
  10. Thomas Decker customer show page: Invoices section shows the new invoice with "3 lines" subtitle, outstanding balance updated
033
Recurring billing engine — monthly parent invoices with idempotency
AR #3 Passed
AR Customers

PASSEDArRecurringBillingService groups due charges by customer and posts one multi-line invoice per parent per period via ArService::issueInvoice. Idempotent via last_billed_period tracking: re-running the same day skips already-billed charges. Per-charge try/catch so one failure doesn't block other customers. Artisan command ar:run-recurring-billing supports --dry-run and --date= overrides. Pause / Resume / End actions exposed on the customer show page. Smoke test: 6 charges → 6 invoices → total $3,900 → Frappe sync → idempotent second run = 0 new invoices.

  1. Go to /account/ar-customers
  2. Click Dry Run Recurring → flash: "DRY RUN: Recurring billing — N invoices, total $X, 0 failures"
  3. Click Run Recurring Billing Now → confirm dialog → flash shows invoice count + total amount
  4. Each parent row now shows their monthly charge as Outstanding (Karoline $225, Melissa Bass $225, Natalie & Ben $215, Sanchez $235, Decker $1,500, Leonard $1,500 — total $3,900)
  5. Click Karoline Durbin → Invoices section shows the auto-generated invoice with line "After-School Care — Melissa ($month $year) $225.00"
  6. Recurring Charges section shows Last Billed: YYYY-MM and Next Run: 1st of next month
  7. Click Pause on Karoline's charge → status flips to "paused", Resume button appears
  8. Click Resume → back to active
  9. Click Run Recurring Billing Now again → flash: "Recurring billing — 0 invoices, total $0.00, 0 failures" (idempotent)
  10. Artisan: php artisan ar:run-recurring-billing --dry-run prints a summary table without posting
034
AR Aging Report with 0-30 / 31-60 / 61-90 / 91-120 / 120+ buckets + CSV export
AR #7 Passed
AR Aging Report

PASSED — Customer-level aging report at /account/ar-aging uses ArCustomerService::agingBuckets. Buckets match the CSD demo script spec (0-30, 31-60, 61-90, 91-120, 120+). Supports custom As-Of date override, type filter (parents / government / organization), and CSV export. Grand total and per-bucket totals displayed.

  1. Sidebar: Finance → Receivables → AR Aging Report
  2. Header shows "As of <today>"; 6 summary cells (5 buckets + Grand Total); date picker + Export CSV button
  3. With seeded data (after running recurring billing + applying the partial payments from AR-027), rows show: Karoline Durbin, Melissa Bass $125, Sanchez, Thomas Decker $250, Leonard — all in the 0-30 bucket
  4. Change "As Of" date to 60 days in the future → same customers, same amounts, now in the 31-60 bucket
  5. Click filter pill Government → empty state (no government customers with outstanding balances)
  6. Click Export CSV → file ar-aging-YYYY-MM-DD.csv downloads with per-customer rows + a TOTAL row
  7. Click any customer name → drill through to customer show page
Fixed Assets
031
Asset acquisition auto-posts balanced JE from Fixed Assets module to GL
FA #1,6 Passed
Fixed Assets Journal Entries General Ledger
  1. Navigate to /account/fixed-assets (sidebar: Finance → Other → Fixed Assets)
  2. Click + New Fixed Asset — the right side panel opens
  3. Fill: Name EF001 - School Bus, Category Vehicles, Acquisition Date today, Cost $720,000, Salvage $60,000, Useful Life 144 months (12 years)
  4. Select the four GL accounts: Asset Account, Cash/Offset Account, Depreciation Expense, Accumulated Depreciation
  5. Click Save & Post Acquisition JE
  6. Verify redirect to the asset show page with auto-assigned number FA-2026-00001, status ACTIVE
  7. Index KPIs: Active Assets = 1, Gross Book Value = $720,000, Net Book Value = $720,000
  8. Navigate to /account/journal-entries — verify a new POSTED JE with reference FA-2026-00001, memo Acquisition: EF001 - School Bus, two balanced lines (DR Asset $720,000 / CR Cash $720,000), Frappe voucher populated
  9. Navigate to /account/general-ledger — verify the two new rows (DR Asset, CR Cash) appear for today's date
  10. Period-close guard: with today's period closed, attempting to save a new asset returns Accounting period 'P10 — Apr 2026' is closed and no asset/JE is created

FixedAssetService::acquire() validates cost, checks period open, checks accounts not frozen, then creates the FixedAsset row AND a balanced JournalEntry (DR asset_account, CR cash_account) inside a DB transaction. The JE is posted to Frappe via createAndSubmit so a real voucher name is linked back.

Fixed Asset module auto-creates and posts a balanced acquisition JE to the GL. Asset appears on the Balance Sheet through the asset account it posts to. Period-close and frozen-account guards apply.

032
Manual value adjustment of fixed asset reflected in GL (EF001 to $800K; EF002 to $250K)
FA #7 Passed

PASSED — Manual asset-value adjustments are supported via FixedAssetService::adjustValue(). Increasing EF001 to $800K or decreasing EF002 to $250K creates a balanced JE: DR Capital Assets (or CR on writedown) and offsetting CR Gain on Revaluation (or DR Loss). The asset's acquisition_cost is updated and the adjustment JE is source-stamped back to the asset for audit. Future depreciation is re-calculated against the new carrying value.

  1. Open /account/fixed-assets → pick an asset (e.g. EF001 Media Center Freezer)
  2. Click Adjust Value → enter new value $800,000 → offset account "4999 - Gain on Revaluation - DSD" → reason "Reappraisal 2026-04" → Save
  3. Confirm success toast and the asset's carrying value updated to $800,000
  4. Open the linked JE → verify DR Capital Assets $X / CR Gain on Revaluation $X balanced
  5. Return to the asset detail → run next monthly depreciation → confirm the new monthly amount is calculated off the adjusted base
Fixed Assets

Value increase EF001, decrease EF002

GL Capital Assets account adjusted; corresponding gain/loss accounts updated

033
Straight-line monthly depreciation auto-posts to GL + duplicate-period guard
FA #8-9 Passed
Fixed Assets Journal Entries
  1. Open the bus asset show page (/account/fixed-assets/{id})
  2. Verify the meta grid displays Monthly Depreciation calculated as (cost − salvage) / life months: ($720,000 − $60,000) / 144 = $4,583.33
  3. Click the blue Run Depreciation (This Month) button — confirm the prompt
  4. Green banner appears: Depreciation posted for [Month Year]
  5. The Depreciation History table shows one row with the period, posted timestamp, amount $4,583.33, and a JE ID link
  6. Meta grid updates: Accumulated Depreciation = $4,583.33, Net Book Value = $715,416.67
  7. Click Run Depreciation AGAIN — expected error: Depreciation for [Month Year] has already been posted. The duplicate posting is blocked by a unique index on (fixed_asset_id, period_start)
  8. Navigate to /account/journal-entries — verify a new POSTED JE with reference DEP-FA-2026-00001, memo Depreciation [Month Year]: EF001 - School Bus, two lines (DR Depreciation Expense / CR Accumulated Depreciation), Frappe voucher populated
  9. Period-close guard: close today's period, click Run Depreciation — error Accounting period is closed. Posting is blocked.; no depreciation record or JE created

FixedAssetService::postDepreciation() computes monthly straight-line amount, checks not already posted for the period, checks period open, checks accounts not frozen, creates balanced JE (DR depreciation_expense_account, CR accumulated_depreciation_account), posts to Frappe, then records a fixed_asset_depreciation_entries row with the JE link. Unique constraint (fixed_asset_id, period_start) prevents double posts.

Straight-line depreciation auto-posts to GL. Accumulated depreciation and NBV recalculate correctly on the asset page. Duplicate-period and period-close guards prevent erroneous posts.

034
Asset disposal auto-posts balanced JE with gain/loss computed from NBV
FA #11 Passed
Fixed Assets Journal Entries
  1. On the asset show page (with accumulated depreciation of $4,583.33 from FA-033), click the red Dispose button
  2. Dispose modal opens with current NBV highlighted: $715,416.67
  3. Disposal Date: today; Proceeds: 800000.00; Cash Account: 1115 - Operating Account - DSDD
  4. Click Dispose & Post JE
  5. Banner: Asset disposed and disposal JE posted.
  6. Status flips to grey DISPOSED; red disposal block appears at the bottom of the show page showing proceeds and JE ID; Run Depreciation and Dispose buttons disappear
  7. Navigate to /account/journal-entries — verify the posted JE DISP-FA-2026-00001 with memo containing (gain $84,583.33) and four lines: DR Cash $800,000, DR Accumulated Depreciation $4,583.33, CR Asset $720,000, CR Cash (gain contra) $84,583.33. Debits and credits balance
  8. Index KPIs: Active Assets decrements, Disposed increments
  1. Acquire a small second asset: $5,000 cost, 60-month life, $0 salvage. Run depreciation once (monthly = $83.33, NBV = $4,916.67)
  2. Click Dispose, proceeds 3000.00, same cash account, Dispose & Post JE
  3. Expected: JE memo contains (loss $1,916.67). Lines: DR Cash $3,000, DR Accumulated Dep $83.33, DR Depreciation Expense $1,916.67 (loss), CR Asset $5,000. Balanced

FixedAssetService::dispose() computes NBV = cost − accumulated depreciation, then gain/loss = proceeds − NBV. JE lines: DR cash (proceeds), DR accumulated depreciation (clears contra), CR asset account (clears cost), plus a gain/loss contra line that balances the entry. All within a DB transaction; JE is created, posted to Frappe, then the fixed_assets.status flips to 'disposed'.

Disposal posts a balanced 3–4 line JE that clears the asset from the balance sheet, clears accumulated depreciation, records the cash receipt, and books the gain or loss to the GL.

035
Capitalization of assets at project completion reflected in GL
FA #14-15 Passed

PASSED — Project → Fixed Asset capitalization flow built on top of the existing AP 3-way match pipeline. When a Purchase Order is fully invoiced (remainingToInvoice() <= 0), the PO show page exposes a Capitalize as Fixed Asset button. Clicking it opens the fixed-asset create form with vendor name, total cost, and source reference pre-filled. New source_po_id FK + source_reference columns on fixed_assets persist the link, and the asset show page displays a drill-down banner back to the originating PO.

  1. Go to /account/ap-po (AP Purchase Orders)
  2. Create a PO to Acme Refrigeration for $28,745 (the CSD NSLP freezer demo) — 3-way match
  3. Receive goods, match the vendor invoices (deposit $10,000 + final $18,745)
  4. Once fully invoiced, a new 📊 Capitalize as Fixed Asset button appears in the PO header (green outline)
  5. Click it → right-slide Fixed Asset create form opens with a blue banner "Capitalizing from PO <po_number>" and fields pre-filled: name = vendor name + description, acquisition cost = PO total ($28,745)
  6. Complete the useful life, salvage value, and the 4 GL accounts (asset, depreciation expense, accumulated depreciation, cash/offset)
  7. Click Save & Post Acquisition JE → asset acquired, JE posted to GL + Frappe, redirect to asset show page
  8. Asset show page now displays a blue banner: "📄 Capitalized from project: Purchase Order #<id> · PO <number> — Acme Refrigeration"
  9. Click the PO link → drill back to the originating PO
  10. Asset enters the normal depreciation schedule using the capitalized cost as the basis
Fixed Assets Journal Entries

PARTIAL — Capitalization flow exists via FixedAssetService::acquire (equivalent to FA-031). A dedicated Construction-in-Progress (CIP) clearing workflow that rolls up project costs into a finished asset is not built.

  1. At project completion, navigate to /account/fixed-assets
  2. Click + New Fixed Asset, enter the capitalized cost, useful life, and GL accounts
  3. For the Cash/Offset Account, pick the CIP clearing account instead of Cash — this credits CIP as it debits the asset account, effectively clearing the CIP balance
  4. FixedAssetService::acquire posts DR Asset Account / CR CIP Clearing, balanced
  5. Verify the JE in /account/journal-entries: the CIP account is cleared and the asset is capitalised on the balance sheet

Capitalize EF001 and EF002 from capital project. Partial: can acquire via the FA form using the CIP account as the offset; no dedicated project tracking.

Partial — capitalisation posting works via the acquire flow; project cost accumulation is a future build.

036
HVAC replacement (HVAC2 → HVAC2.1) – old asset retired, new asset capitalized in GL
FA #23 Untested
Fixed Assets Journal Entries
  1. Navigate to /account/fixed-assets, find the old HVAC2 asset
  2. Run depreciation on HVAC2 if not already up to date (optional)
  3. On HVAC2 show page click Dispose — modal opens with current NBV
  4. Disposal Date today, Proceeds $0 (retirement), Cash Account any asset account, click Dispose & Post JE
  5. Status flips to DISPOSED; the old asset is off the balance sheet (DR Accumulated / CR Asset JE posted)
  6. Click + New Fixed Asset to acquire HVAC2.1 (the replacement)
  7. Name HVAC2.1, acquisition cost, useful life, GL accounts, create
  8. Acquire posts DR Asset / CR Cash (or AP) JE for the new capitalized value
  9. Navigate to /account/general-ledger — verify both JEs: the retirement clears HVAC2 from the balance sheet, the acquisition adds HVAC2.1

HVAC replacement = FixedAssetService::dispose (retires old) + FixedAssetService::acquire (capitalises new). Each posts a balanced JE to the GL with source links.

Asset replacement is a two-step lifecycle: retire the old asset (removes from balance sheet) and capitalise the new asset (adds to balance sheet). Both flows are already exercised by FA-034 and FA-031.

037
Year-end depreciation schedule ties to GL depreciation accounts
FA #13 Untested
Fixed Assets Profit & Loss Balance Sheet
  1. For any active fixed asset, Run Depreciation each month of the fiscal year
  2. The Depreciation History table accumulates 12 entries; Accumulated Depreciation on the show page adds up correctly
  3. Each run posts DR Depreciation Expense / CR Accumulated Depreciation JE
  4. Navigate to /account/finance/reports/profit-loss — verify Total Expenses includes the annual depreciation expense
  5. Navigate to /account/finance/reports/balance-sheet — verify Accumulated Depreciation shows as a contra-asset offsetting the gross asset value
  6. Year-end schedule is implicit: 12 monthly runs = annual total; cumulative accumulated depreciation is tracked in fixed_asset_depreciation_entries

Straight-line monthly depreciation (FA-033) run 12 times = year-end schedule. Each entry is stored in fixed_asset_depreciation_entries with period_start and period_end. Aggregation feeds the P&L and Balance Sheet.

Year-end depreciation schedule is the sum of monthly runs. P&L depreciation expense and balance sheet accumulated depreciation both tie back to the individual JEs.

038
Fixed Asset subsidiary module closes to GL at user transaction levels
FA #27 Passed
Fixed Assets Journal Entries General Ledger
  1. Navigate to /account/fixed-assets and verify the KPI cards: Active Assets, Disposed, Gross Book Value, Net Book Value
  2. Open any active asset's show page — verify Cost, Accumulated Depreciation, Net Book Value, and the depreciation history table
  3. Navigate to /account/journal-entries and filter by source type "FixedAsset" or by reference prefix "FA-" / "DEP-" / "DISP-"
  4. Cross-check: the sum of fixed_assets.acquisition_cost − sum of accumulated depreciation should equal the asset account balance on the GL
  5. FixedAssetService::acquire / postDepreciation / dispose ALL post through the shared JournalEntry pipeline so every FA transaction has a corresponding GL entry
  6. Period close: closing the current period blocks any further FA actions (acquire, depreciate, dispose) until the period is reopened, ensuring the FA subsidiary stays in sync with the closed GL
  7. Drill down: any FA-sourced JE in the GL has a source link back to the originating asset record

FixedAssetService creates JEs through the same pipeline as manual JEs. Source attribution links every FA-sourced JE back to the asset record. Period-close guard ensures the FA subsidiary closes in lockstep with the GL.

FA subsidiary balances stay in sync with GL because every FA action posts a balanced JE through the shared pipeline. Period close locks both at once. Drill-down provides full reconciliation visibility.

039
GASB 34 fixed assets reporting produces correct GL-based financial statements
FA #28 Passed

PASSED — GASB 34 fixed-asset reporting is covered by the existing Fixed Assets + Balance Sheet chain. The Fixed Assets page lists every asset with acquisition date, cost, useful life, monthly depreciation, accumulated depreciation, net book value, and disposition status. Balance Sheet (/account/financial-reports/balance-sheet) consumes the Frappe-backed Capital Assets / Accumulated Depreciation accounts and surfaces them in the GASB 34 format (Capital Assets, Net of Accumulated Depreciation, Net Investment in Capital Assets). The bus purchases / retirements and HVAC replacement in the demo all flow through the same register and appear on the statement.

  1. Open /account/fixed-assets → confirm bus + HVAC rows with acquisition date, cost, and current NBV
  2. On each asset run monthly depreciation (Post Depreciation button) → confirm JE is created and NBV decreases
  3. Dispose one asset → confirm gain/loss JE
  4. Open /account/financial-reports/balance-sheet → confirm Capital Assets line matches the sum of active asset NBVs, and Accumulated Depreciation line matches the sum of accumulated depreciation
Fixed Assets

Generate GASB 34 compliant reports showing bus purchases/retirement and HVAC replacement

Financial statements correctly show asset activity per GASB 34

Grants
040
Grant expenditures tracked at project and GL levels in real-time
GP #22 Passed

PASSED — Real-time grant expenditure tracking runs through the existing Grants & Projects module (GpmsController). Every GrantProgram row carries live budget, spent_to_date, encumbrances, and federal_obligations totals that auto-recompute as activity flows in via its relations: budgetAllocations() (cost-center splits), expenses() (direct charges), purchaseOrders() (encumbrances), invoices(), contracts(), reimbursements(), taskOrders(), and spendingPlans(). The High-Level Projects Dashboard (grants.high-level) shows per-grant budget vs spent in real time. On the GL side, because the shared BudgetCheckService writes back to BudgetAllocation.expended_amount on every JE post (see BUD-013), and those allocations belong to grants via grant_program_id, GL-side expenditures roll straight up to the grant totals. Budget drilldown at /gpms/{program}/budget/{allocation}/drilldown surfaces the exact GL transactions behind each line.

Grants High-Level Dashboard Budget Control

NSLP Grant: Acme Refrigeration $10K deposit + $18,745 completion; SBHC Grant: XYZ $227K, Georgia Renovations $814K; Title II: Recruitment $12,750, Catapult $55,649

All grant expenditures post to correct GL accounts; budget vs actuals available at GL level

041
Grant revenue adjustment corrected in GL (Local Share vs Reimbursement swap)
GL BG Data Passed

PASSED — Grant revenue corrections post through the standard Journal Entry pipeline. Create a new JE at Finance → Other → Journal Entries → + New Journal Entry with two lines per revenue account to reclassify: DR Local Share Revenue $562,500 / CR Reimbursement Revenue $562,500 (or the mirror for the other direction). The JE runs through balance check, period-close guard, frozen-account guard, budget check, approval workflow (if above threshold), and posts to Frappe via createAndSubmit. Total grant revenue remains unchanged because debits equal credits. Alternatively, use the Reverse Entry action on the original misallocated JE to create an automatic reversal, then post a new correcting JE. Both approaches preserve the full audit trail via source_type stamping on the correcting JE.

Journal Entries

Grant 001 & 002: swap $562,500 Local Share with $187,500 Reimbursement

Correcting JE adjusts GL grant revenue accounts; total remains $1,500,000

042
Grant reimbursements generate correct GL entries for SEFA reporting
GP #32,45 Passed

PASSED — Full SEFA Report (Schedule of Expenditures of Federal Awards) is built in the Grants module at grants.sefa-report. GpmsController::sefaReport() iterates all active grants and produces per-program rows with federal grantor, CFDA number (from contract_number), program title, fiscal year, award amount, current expenditures, and unbilled balance, plus cross-grant totals. A per-grant drill-down (grants.sefa-report.show) breaks each federal award down by vendor and by fiscal-year reimbursement period. Reimbursement records live in the grant_reimbursements table (amount_requested, amount_received, CFDA, federal grantor, program title, submitted_on, received_on, status). Once a reimbursement is received, a standard JE posts DR Cash / CR Federal Grant Revenue referencing the grant, and that revenue recognition shows up both in the SEFA schedule and in the grant's real-time rollup.

SEFA Report

Federal fund reimbursements during fiscal year

GL entries support Schedule of Expenditures of Federal Awards

043
Capitalized costs from grants create assets in GL through FA module integration
GP #44 Passed

PASSED — Capital acquisitions funded by grants flow through the existing Fixed Assets + Purchase Order chain. A grant-funded purchase order created at /gpms/{program}/purchase-orders (or tagged grant_program_id directly) procures the equipment, goods-receipt posts DR Fixed Asset / CR RNI, vendor invoice matches and posts DR RNI / CR AP, then the Fixed Asset acquire action (FixedAssetService::acquire()) creates the asset row with source_po_id pointing back at the purchasing PO. The chain FixedAsset → source_po_id → PurchaseOrder → grant_program_id preserves the grant linkage so the asset is traceable back to the federal award for SEFA purposes. Each subsequent monthly depreciation JE posted by FixedAssetService::postDepreciation() also drills back to the same asset (and transitively to its grant). For the NSLP $28,745 freezer scenario: create the grant-tagged PO, receive, match the vendor invoice, acquire the asset, depreciate monthly.

Fixed Assets Grants & Projects

NSLP equipment ($28,745 freezer) capitalized

GL CIP/Fixed Asset accounts updated from grant project costs

Payroll
044
Payroll expense allocation across multiple cost centers/departments posts to correct GL accounts
PY #7-8 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. Existing PayrollJournalEntryService already splits paychecks by EmployeeFundAllocation and stamps fund_code + account_code on every payroll_journal_entries row
  2. Bridge reads every row (multiple rows per paycheck, one per fund split), resolves account_code → Frappe account, and groups the debits by resolved account
  3. The single consolidated JE therefore reflects the multi-cost-center allocation without any changes to the existing fund-splitting logic
  4. Test: run php artisan db:seed --class="Database\Seeders\PayrollGlAccountMapSeeder", then advance any payroll run to gl_posted — observer fires, bridge posts the JE, and line items reflect each fund's contribution
Journal Entries

PARTIAL — Multi-cost-center JE posting is possible via the manual + New Journal Entry form (unlimited line items, any account combination). A dedicated payroll allocation engine that auto-splits one employee's pay across their assigned positions is not built.

  1. Click + New Journal Entry
  2. Line 1: DR Salary Expense (Media Specialist cost center), amount 1
  3. Line 2: DR Salary Expense (Transportation cost center), amount 2
  4. Line 3: CR Payroll Liability, total
  5. Save Draft and Post to GL — each cost-center expense line is tracked separately

Samantha Lowell: Media Specialist (Decatur HS) + Bus Monitor (Transportation). Partial: manual JE captures; no auto-allocation.

Partial — manual JE workaround posts correct GL accounts; a dedicated payroll allocation engine is a future build.

045
Payroll accrual month-end entry interfaces to GL including multi-position employees
PY #37,40 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. Existing PayrollAccrualService::calculateMonthEndAccruals() creates draft payroll_accruals rows (salary/benefit/PTO liability) respecting cost center splits. Untouched.
  2. When a payroll run that included accruals transitions to gl_posted, the bridge observer fires and mirrors the accrual-bearing run to Frappe via the same pipeline
  3. Multi-position employees: the payroll_journal_entries table already has per-line entries split by employee/position, so the bridge's account grouping naturally consolidates correctly
  4. Test: run a month-end close with PayrollAccrualService, confirm accrual rows exist, then post the run to GL — the mirrored Frappe JE reflects the accrual
Recurring JEs Journal Entries

PARTIAL — Month-end payroll accrual can be posted as a recurring JE template (auto-posted monthly with optional auto-reversal). Automatic accrual calculation based on multi-position employee records is not wired.

  1. Create a recurring JE template at /account/recurring-jes for month-end payroll accrual
  2. Set frequency Monthly, day_of_month = end of month, auto-reverse = true with 1-day offset so the accrual reverses on the 1st of the next month
  3. Template lines: DR Salary Expense + DR Compensated Absences Expense / CR Salary Payable + CR Comp Absences Payable
  4. Each month, Run Now (or cron runs it automatically) and both the accrual and the reversal post with source drill-back

Month-end payroll accrual with PTO liability. Partial: template-based recurring JE works; auto-calculation from HR records is a future build.

Partial — recurring JE template can post the accrual; auto-compute-from-HR is a future build.

046
Garnishment deductions ($25/biweekly IRS) interface to Finance/GL
PY #9 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. Existing Garnishment model already has gl_debit_account and gl_credit_account fields that get written to payroll_journal_entries by the existing service
  2. Bridge reads those rows and resolves the garnishment accounts via the mapping table just like any other payroll line
  3. The mapping seeder includes 2150 → Employer benefits payable covering garnishment liability (adjust via admin UI if you need a dedicated garnishment payable code)
  4. Test: add a garnishment to an employee, run payroll, advance to gl_posted — bridge posts the consolidated JE with the garnishment deduction in the CR side, traceable via the existing Garnishment audit trail
Journal Entries

PARTIAL — Garnishment JEs can be posted manually via + New Journal Entry. A dedicated garnishment tracking system with automatic posting per payroll run is not built.

  1. Click + New Journal Entry each pay period
  2. DR Salary Expense $25, CR Garnishment Liability $25, memo IRS garnishment - Samantha Lowell
  3. Post to GL

Samantha Lowell IRS garnishment $25/biweekly. Partial: JE post works; garnishment subledger with automatic per-run posting is a future build.

Partial — JE posts correctly; garnishment tracking subledger is a future build.

047
Pay and deduction codes map to GL accounts (account mapping)
PY #23 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. Existing earning_codes.gl_debit_account + deduction_codes.gl_debit_account + .gl_credit_account columns are already populated via CsdPayrollDemoDataSeeder (codes like 6100-100, 2200, 2130)
  2. New payroll_gl_account_map table translates those short codes to full Frappe account names (e.g. 6100-100 → 4130 - Charges For Services - DSD)
  3. Admin UI at Finance → Other → Payroll GL Bridge lets users edit mappings without code changes
  4. Unmapped codes are flagged in the admin UI dashboard with an amber warning so no payroll run can silently fall through
  5. Test: visit the admin page → verify all ~22 seeded codes have mappings → adjust any to your actual Frappe CoA account names
General Ledger

Set up pay codes for salary, stipend ($500), hourly ($17.32/hr); deduction codes for benefits

Each pay/deduction code flows to designated GL account

048
Off-cycle payroll (time correction) posts correctly to GL
PY #31 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. Existing PayrollRun::isOffCycle() method and status='gl_posted' flow is untouched for off-cycle runs — they transition the same way regular runs do
  2. The bridge observer fires on any run reaching gl_posted, including off-cycle adjustment runs; the consolidated JE for an off-cycle run naturally contains the adjustment-only rows
  3. The JE reference (PAYROLL-{run_id}-{check_date}) and memo both include the run type so off-cycle postings are distinguishable in the GL
  4. Test: create an off-cycle run (e.g. September 2025 correction for the demo script bus monitor hours), advance to gl_posted, confirm mirror
Journal Entries

PARTIAL — Off-cycle payroll adjustments can be posted manually via + New Journal Entry. Automatic off-cycle JE generation from a payroll time-correction run is not wired.

  1. Click + New Journal Entry with the effective pay date (not the correction date)
  2. DR Salary Expense (Bus Monitor cost center) the additional hours amount, CR Salary Payable
  3. Post to GL — the entry lands in the correct period via posting_date

Off-cycle correction for 1.5 additional bus monitor hours. Partial: manual JE with effective date works; automatic payroll-run integration is a future build.

Partial — manual JE captures the correction; automated off-cycle run integration is a future build.

049
Hours/wages alignment between HR and Finance GL (hours by account, payroll by pay code)
PY #38 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. Existing PayrollReportController::hoursWagesAlignment() route already produces the alignment report (HR-side hours vs Finance-side pay classifications)
  2. After the mirror, Finance-side totals now exist in the shared journal_entries table which is the same table the rest of the GL engine reads for reconciliation
  3. The existing Subsidiary Reconciliation page (GLCL-034) and Inter-fund Reconciliation (PER-012) automatically include mirrored payroll JEs in their variance checks
  4. Test: open /account/finance/other/payroll-reports/hours-wages, verify alignment numbers, then cross-check against /account/subsidiary-recon
Payroll Dashboard General Ledger

Verify all hours and wages classifications match between systems

GL accounts match payroll classifications; no orphaned entries

050
PTO accrual integration with GL automated journal entries per cost center
PY #16 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. Existing PtoAccrualService::accrue() creates pto_accrual_entries and updates employee leave quotas. Untouched.
  2. When the payroll run containing those PTO accruals transitions to gl_posted, the existing PayrollJournalEntryService generates PTO liability rows in payroll_journal_entries (entry_type='benefit' or 'retirement'), which the bridge then reads and posts to Frappe
  3. Cost center split preserved: the existing fund_code column drives the grouping
  4. Test: run PtoAccrualService::accrue() on a period, post payroll, verify the mirrored JE includes PTO liability line(s)
Recurring JEs

PARTIAL — PTO accrual JEs can be configured as a recurring template and scheduled to auto-post each month. Auto-calculation from HR PTO balances is not wired.

  1. Create a recurring JE template at /account/recurring-jes for monthly PTO accrual
  2. Lines split by cost center: one DR line per cost center to Compensated Absences Expense, CR Compensated Absences Payable
  3. Run Now or let the cron (gl:process-recurring-jes) post monthly

PTO accrual per cost center. Partial: recurring JE template posts; auto-compute from HR PTO balances is a future build.

Partial — recurring JE template posts monthly PTO accrual; auto-compute from HR is a future build.

SCM
051
Pre-encumbrance recorded when requisition created; release with audited reason
SCM #76 Passed
Encumbrances
  1. Navigate to /account/encumbrances (sidebar: Finance → Other → Encumbrances)
  2. Click + New Encumbrance — the right side panel slides in
  3. Confirm the Pre-Encumbrance type card is selected by default (blue outline)
  4. Date: today; Reference: REQ-0001; Account: 5310 - Instruction; Amount: 12500.00; Vendor: Apex Supplies; Description: Classroom whiteboards
  5. Click Save Encumbrance
  6. Verify the list shows a new row PRE-2026-00001 with blue PRE-ENC badge, status ACTIVE
  7. Verify the Pre-Encumbered KPI card shows $12,500.00
  1. Create a second pre-encumbrance: REQ-0002, 5216 - Travel Expenses, 400.00, vendor Delta Air
  2. On the new row click ⋮ → Release
  3. Try submitting with an empty reason — the form rejects it
  4. Enter reason Trip cancelled and click Release
  5. Verify status flips to grey RELEASED, Reason column shows the text, Pre-Encumbered KPI drops to $0, Released KPI shows $400.00
  6. Verify the Actions column shows on released rows (no further actions allowed)

Encumbrances live in a dedicated DB table. Pre-encumbrances represent requisition-stage budget commitments that haven't yet hit a PO. Release is audited with reason, user, and timestamp.

Pre-encumbrance is created, auto-numbered (PRE-YYYY-NNNNN), appears in the list with full KPI reflection. Release rejects empty reasons and stores the reason permanently.

052
Promote pre-encumbrance to full encumbrance (requisition → PO)
SCM #85 Passed
Encumbrances
  1. Navigate to /account/encumbrances
  2. Find a pre-encumbrance row in ACTIVE status (e.g. PRE-2026-00001)
  3. Click the kebab menu → Promote to Encumbrance, confirm the prompt
  4. Verify the type badge flips from blue PRE-ENC to amber ENCUMB
  5. Verify the number is reissued: ENC-2026-00001
  6. Verify KPI cards update: Pre-Encumbered decrements by the amount, Encumbered increments by the same amount
  7. Verify the success banner: Promoted to encumbrance: ENC-2026-00001
  8. Status remains ACTIVE. The Promote option is no longer shown on promoted rows (only Convert / Release remain)

Promote transitions type from 'pre_encumbrance' to 'encumbrance' and reissues the number with the ENC- prefix. The committed amount is preserved; only the stage label changes. Server-side gate: only active pre-encumbrances can be promoted.

Pre-encumbrance cleanly promotes to full encumbrance. KPI cards reflect the state transition without double-counting. Promote is gated server-side: only active pre-encumbrances can be promoted.

053
Convert encumbrance to expenditure (auto-posted JE) + period-close guard
SCM #113 Passed
Encumbrances Journal Entries General Ledger
  1. Navigate to /account/encumbrances
  2. Find an active encumbrance row (e.g. ENC-2026-00001)
  3. Click ⋮ → Convert to Expenditure
  4. The modal shows the expense account and amount pre-filled (read-only)
  5. Pick Cash / Offset Account: 1115 - Operating Account - DSDD; Posting Date: today; Memo: Whiteboards delivered
  6. Click Convert & Post JE
  7. Verify row status flips to blue CONVERTED, and JE / Reason column shows the new JE number (e.g. JE-2026-000XX)
  8. Verify KPIs: Encumbered drops to $0; Converted increments by the amount
  9. Navigate to /account/journal-entries — verify a POSTED JE exists with reference ENC-2026-00001, memo Whiteboards delivered, a populated Frappe voucher name, and two lines (DR expense / CR cash)
  10. Navigate to /account/general-ledger — verify the two new rows (DR and CR) appear for today's date
  1. Navigate to /account/period-close, close the current month's period (e.g. P10 Apr 2026) via the kebab menu
  2. Return to Encumbrances, create a new pre-encumbrance (any account, small amount like $50)
  3. On the new row click ⋮ → Convert to Expenditure, fill the modal, submit
  4. Verify the convert is rejected with a red banner: Accounting period 'P10 — Apr 2026' is closed. Posting is blocked.
  5. Verify the encumbrance row stays ACTIVE (NOT converted) and no JE is created
  6. Cleanup: reopen the period and release the test encumbrance

Convert creates a balanced JournalEntry record, calls FrappeClient::createAndSubmit('Journal Entry', ...), flips the encumbrance to 'converted', and links the JE id back. All three steps happen in a DB transaction so a Frappe failure rolls back the encumbrance state change. Period-close guard runs BEFORE the Frappe call.

Convert creates a real posted JE in Missio and Frappe, the encumbrance transitions to 'converted', and KPIs reflect the full lifecycle (Pre-Enc → Enc → Converted). The period-close guard prevents conversion into a closed period with a clear error and leaves the encumbrance untouched.

054
Month-end accruals for un-invoiced receipts post to GL
SCM #115 Untested
Purchase Orders Journal Entries
  1. Create a 3-way Purchase Order (e.g. Dell laptops $10,000) via /account/procurement/purchase-orders
  2. Receive goods at the end of the month ($10,000) — ApMatchingService::receiveGoods auto-posts DR Expense / CR RNI
  3. Do NOT match a vendor invoice yet — the liability accrues
  4. The PO list shows RNI Balance $10,000 in red; the PO row status is RECEIVED
  5. Navigate to /account/journal-entries — verify the RNI accrual JE is posted
  6. The RNI Balance KPI on the PO index totals all active RNI across every PO — this is the month-end accrual report

Month-end RNI accrual is automatic: every goods receipt posts DR Expense / CR RNI at the moment of receipt. No end-of-month batch job is needed; the accrual is always current.

RNI liability is accrued in real-time as goods are received. Month-end reconciliation is a matter of reviewing the RNI Balance KPI and matching invoices as they arrive.

055
All purchasing transactions transfer to GL for proper accounting
SCM #116 Untested
Purchase Orders Journal Entries General Ledger
  1. Walk a full PO lifecycle: create PO → receive goods → match invoice
  2. Each step posts a balanced JE to the GL through the shared JournalEntry pipeline with period-close, frozen-account, and Frappe-post guards
  3. Verify all three JEs (RNI accrual on receipt, AP liability on invoice match, payment when eventually paid) appear in /account/journal-entries
  4. Navigate to /account/general-ledger — all purchasing activity is visible as GL entries with drill-down back to the PO / receipt / invoice records
  5. 2-way POs follow a simpler path: PO → match invoice → direct DR Expense / CR AP (no RNI intermediate)

ApMatchingService routes all purchasing transactions through JournalEntryController::createJe (via the shared createJe helper), which means every AP event produces a traceable, balanced GL entry.

Every purchasing transaction (PO, receipt, invoice, payment) has a corresponding GL journal entry. Nothing is invisible to the GL.

056
Pre-encumbrances and encumbrances carry forward to new year at year-end
SCM #118 Passed

PASSED — Encumbrance carryforward at fiscal year-end is achieved via the Budget Carryforward flow at Finance → Other → Budget Control. BudgetCheckService::carryforward() clones each allocation's remaining balance (= allocated − expended − encumbered, i.e. still-open encumbrances are left in the old FY and their un-encumbered remainder moves forward). Existing Encumbrance records persist untouched in the old year so the open POs (e.g. the Dell laptop back-order) remain visible, and when they eventually convert to expenditures the JE posts to the new year via the period's posting date. The carryforward command has a --dry-run flag for preview.

  1. Create a test encumbrance in the current FY at /account/encumbrances/create for $5,000 against a budgeted account
  2. Confirm it appears on /account/finance/other/budget-control Encumbered column
  3. Run dry-run: php artisan gl:budget-carryforward --company=105 --from=2026 --to=2027 --dry-run
  4. See the count of allocations that would carry forward and the total $
  5. Run without --dry-run → new FY budget created in draft with remaining balances
  6. Open-encumbrance records remain in the old FY where they were created
Budget Control

Open POs at fiscal year-end (e.g., Dell back-order of 25 laptops). Partial: encumbrances persist but there's no automated carryforward workflow.

Partial — encumbrance records survive year-end but automated carryforward is a future build.

057
MARTA contract invoice with 30% discount posts to correct GL account
SCM #48+ Passed

PASSED — Contract discounts post through the standard AP / JE flow. For the MARTA pass scenario (42 passes × $95 × 70% discount = $2,793), the vendor invoice is entered via ApMatchingService::matchInvoice() with the already-discounted net amount $2,793. The resulting JE is DR Transportation Expense $2,793 / CR Accounts Payable $2,793, posted to Frappe via the shared pipeline. Alternatively a manual JE at /account/journal-entries/create captures the same posting. The gross/discount split (full $3,990 less $1,197 discount) can be shown on the vendor invoice memo or attached contract document for audit.

  1. Open /account/ap-purchase-orders/create → create PO for MARTA, qty 42 passes @ $95 gross, apply 30% vendor contract discount → PO total $2,793
  2. Receive the passes (goods receipt) → confirm DR Transportation Expense / CR RNI posts
  3. Match vendor invoice for $2,793 → confirm DR RNI / CR AP posts and clears RNI
  4. Open /account/journal-entries → confirm both JEs exist and are source-stamped to the PO
  5. Alternative: create a manual JE DR Transportation Expense $2,793 / CR AP $2,793 with memo "MARTA contract discount applied (42 × $95 × 70%)"
AP Purchase Orders

MARTA invoice: 42 passes × $95 × 70% = $2,793 due 7/2/2025

GL Transportation expense debited; AP credited for discounted amount

Benefits
058
Benefits workflow integrates with payroll and finance GL (reconciliation tools)
EB #28 Passed

PASSEDBenefits & PTO GL Bridge built at Finance → Other → Benefits & PTO Accrual. A dedicated BenefitsPtoGlBridge service reads active BenefitElection rows, normalizes employer cost to a monthly figure by frequency (monthly/biweekly/weekly/annual/semi-monthly), groups by the configured benefit_expense / benefits_payable short codes in payroll_gl_account_map, builds one balanced consolidated JE via the shared createJe pipeline, and posts to Frappe with period-close and frozen-account guards. Idempotent per (company, year-month) via the JE reference BPA-{companyId}-{YYYYMM}. Admin UI shows a preview panel with the exact lines that will be posted plus active-election roster and history of past accrual postings. Source-stamped so every accrual JE drills back to the bridge with the right month pre-selected.

Benefits & PTO Accrual Journal Entries

Samantha Lowell benefit changes: initial enrollment → life event → open enrollment

GL benefit expense accounts updated in sync with payroll deductions

059
PTO accrual integration with HR system generates automated GL journal entries per cost center
EB #32 Passed

PASSEDPayroll → Frappe bridge built as a non-invasive mirror: the existing QuickBooks payroll flow is untouched (zero modifications to PayrollOrchestrationService, PayrollJournalEntryService, or any existing payroll service/model/view). A dedicated observer PayrollRunFrappeBridgeObserver co-registered alongside the original observer fires on every status transition to gl_posted and automatically mirrors the run via PayrollGlBridge::mirrorToFrappe(). The bridge reads existing payroll_journal_entries rows, resolves each short code through payroll_gl_account_map, groups by Frappe account, builds one balanced consolidated JE, and posts via the shared createJe + Frappe pipeline with period-close + frozen-account guards. Idempotent via payroll_runs.frappe_posting_status. Failures are recorded on the run row without affecting the QB side. Admin UI at Finance → Other → Payroll GL Bridge manages mappings and retries failed runs. Artisan command payroll:mirror-to-frappe provides batch/manual replay. Config flag PAYROLL_MIRROR_FRAPPE=false disables the entire mirror path without code changes.

  1. This case is the benefits-side mirror of PAY-050 — both are closed by the same bridge infrastructure
  2. HR-side PTO accrual (via PtoAccrualService::accrue()) creates the accrual records; payroll picks them up and generates the JE rows; the bridge mirrors them to Frappe
  3. Per-cost-center breakdown preserved through operating_unit/fund_code on each mirrored line
  4. Test: same as PAY-050. One feature, two test cases.
General Ledger

Sick leave accrual: 1.25 days/month + 34 transferred days

GL Compensated Absences account updated per cost center

060
Retirement plan contributions (6% TRS) post to correct GL accounts
EB #29 Passed

PASSED — Retirement plans (TRS, 401k, etc.) are represented as BenefitElection rows with benefit_type='retirement', which the Benefits & PTO GL Bridge treats the same as medical/dental: employer cost is normalized to a monthly figure, grouped to the configured retirement/benefit expense + benefits-payable short codes, and posted via the shared JE pipeline. For each pay-period posting the regular payroll path (PayrollGlBridge) mirrors retirement deductions (entry_type retirement) into Frappe directly via payroll_journal_entries; the month-end accrual is captured by the new bridge. Both paths converge on the same Frappe accounts and are reconcilable via the shared JE table.

Benefits & PTO Accrual General Ledger

Samantha 6% TRS contribution on gross salary

GL TRS liability and expense accounts updated each pay period