- Navigate to
/account/chart-of-accounts(sidebar: Finance → Other → Chart of Accounts) - Verify stat cards show non-zero totals: Total Accounts, Asset Accounts, Liability Accounts, Revenue & Expense
- Click each pill tab (All / Assets / Liabilities / Equity / Income / Expense) — verify the table filters in place
- Type
Operatingin the search box — verify 1115 - Operating Account appears and other accounts are hidden - 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
- 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 - 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.
- Navigate to
/account/chart-of-accounts - Search for
1115— find 1115 - Operating Account - On that row, click the ⋮ kebab menu → Test Post
- Confirm alert:
✓ POSTING ALLOWED — Test JE posted: ACC-JV-2026-XXXXX
- On the same row, click ⋮ → Freeze Account
- Confirm the Status column flips from Active to ❄ Frozen and the alert reads
Account frozen - Click ⋮ → Test Post again
- Confirm alert:
⛔ POSTING BLOCKED (frozen) — Account 1115 - Operating Account - DSDD is frozen. Posting is blocked.
- Navigate to
/account/journal-entries→ click + New Journal Entry (opens right-side panel) - Line 1: Account 1115 - Operating Account, Debit 50.00
- Line 2: Account 5223 - Miscellaneous Expenses, Credit 50.00
- Click Save Draft
- Confirm inline error appears under the Account cell on line 1:
Account is frozen — posting blocked.and the form does NOT submit
- Return to
/account/chart-of-accounts - On the 1115 - Operating Account row, click ⋮ → Unfreeze Account
- Confirm status badge returns to Active
- 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.
✓ 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.
- Open
/account/finance/other/master-data-categories - Click + New Category → enter name (e.g. "School Location") + description → Save
- Click into the new row → + Add Value → enter "Oakhurst Elementary" → repeat for each location
- Toggle one value to Inactive → confirm it greys out
- Delete a category → confirm it is removed
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.
- Encumbrance → Expenditure: create an encumbrance, promote, then Convert to Expenditure — verify a new posted JE appears in
/account/journal-entrieswith source link back to the encumbrance - AR Invoice issue: new AR invoice → auto-posts DR Receivable / CR Revenue JE — verify it appears in JE list, linked to the AR invoice
- AR Payment apply: on any open invoice click Apply Payment → auto-posts DR Cash / CR Receivable JE
- AP receive goods (3-way): on any open PO click Receive Goods → auto-posts DR Expense / CR RNI JE
- AP match invoice: Match Invoice action → auto-posts DR RNI / CR AP JE (for 3-way) or DR Expense / CR AP (for 2-way)
- FA acquire: new fixed asset → auto-posts DR Asset / CR Cash JE
- FA depreciate: Run Depreciation → auto-posts DR Depreciation Expense / CR Accumulated Depreciation JE
- Recurring template Run Now: auto-posts from the template lines, optionally followed by a reversal JE
- 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.
- Navigate to
/account/recurring-jes(Finance → Other → Recurring JEs) - Click + New Recurring Template
- Name:
Prepaid Insurance Amortization; Reference Prefix:PPI; Frequency: Monthly; Start Date: today - Schedule section: set Auto-Reverse: Yes; Reverse Days After:
30 - Lines: DR
5310 Instruction$1,200; CR2115 AP$1,200. Footer shows ✓ Balanced - Click Create Template
- On the show page, click Run Now, confirm
- Banner: "Recurring JE posted successfully."
- Run Count advances to 1, Next Run moves one month forward
- Run History row shows status POSTED with a linked JE ID and a linked reversal JE ID
- 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"
- 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
- Click Pause — status flips to PAUSED; Run Now button disappears
- Click Resume — back to ACTIVE
- 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 - 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.
- Navigate to
/account/journal-entries→ click + New Journal Entry (right-side panel opens) - Posting Date: today; Reference:
TEST-001; Memo:GL engine smoke test - Line 1: Account 1115 - Operating Account, Debit 500.00, Description
Cash receipt - Line 2: Account 5223 - Miscellaneous Expenses, Credit 500.00, Description
Offset - Verify the footer shows ✓ Balanced (green)
- Click Save Draft → redirects to the show page with status DRAFT
- Click Post to GL → status flips to POSTED and the Frappe Voucher field populates with
ACC-JV-2026-XXXXX - Navigate to
/account/general-ledger— verify two new rows (one debit, one credit) appear for today's date
- Click + New Journal Entry
- Line 1: any account, Debit 100.00; Line 2: any other account, Credit 90.00
- Click Save Draft
- Confirm inline error on Line 1 Debit cell:
Out of balance: debits $100.00 ≠ credits $90.00.The form does NOT submit
- Click + New Journal Entry → leave both lines' Account blank
- Click Save Draft
- Confirm inline
Account is required.appears under each Account dropdown in red
- Open the posted JE from the happy path (click its row or ⋮ → View)
- Click Reverse Entry → confirm the prompt
- 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.
✓ 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.
- Sidebar: Finance → Other → CoA Change Requests
- 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") - Submit → redirect to index, row appears under Pending filter pill
- Click the request number → show page with full detail + approver actions (Approve / Reject side by side)
- Click Approve & Execute → Frappe API is called, new account
1325 - Grant Receivables - Title I - DSDis created, request status flips to Approved,frappe_nameis populated, audit trail shows reviewer + timestamp - Verify: visit
/account/chart-of-accounts→ new account is listed, can be picked on any new JE - Test reject flow: submit another request, open it, reject with required review notes → status flips to Rejected, no Frappe action taken
Add a new COA segment value and route for approval
New segment requires and goes through workflow approval
✓ 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.
Configure GL for modified accrual basis per governmental accounting
System supports all four bases of accounting
✓ 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.
- Open
/account/chart-of-accounts→ verify Fund / Function / Object hierarchy is visible - Open
/account/fixed-assets→ confirm school buses + HVAC assets carry cost, accumulated depreciation, NBV - Open
/account/financial-reports/balance-sheet→ verify Net Position Restricted / Unrestricted sections match the demo data - Run monthly depreciation from an asset's detail page → confirm JE posts with DR Depreciation Expense / CR Accumulated Depreciation
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.
- Navigate to
/account/period-close(sidebar: Finance → Other → Period Close) - Select Fiscal Year 2026 from the dropdown
- Find the row containing today's date (e.g. P10 — Apr 2026)
- Click ⋮ → Close Period, add a note like
September close complete, click Close Period - Confirm the status badge flips from OPEN (green) to CLOSED (red) and the Closed At column populates with the current timestamp
- Verify KPI cards at the top update: Open decrements by 1, Closed increments by 1
- On the same CLOSED period, click ⋮ → Reopen Period
- Attempt to submit the reason modal with an empty reason — confirm the browser/server rejects it (reason is required)
- Enter a reason:
Late Georgia Power invoice — CFO approved - Click Reopen Period
- Confirm: status flips to REOPENED (amber), the Reason column shows the text, Reopened At gets a timestamp, and Reopened KPI increments by 1
- On the REOPENED period, click ⋮ → Close Period again
- Confirm status returns to CLOSED
- 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.
✓ 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.
- Open
/account/bai-imports→ click + New Import - Upload a BAI2 file (sample in
tests/fixtures/bai/if needed) - Preview page lists each transaction grouped by BAI code with GL-account suggestions
- Review / adjust mappings → click Commit to GL
- Open
/account/journal-entries→ confirm one JE per transaction, each source-stampedBaiImport - Open
/account/bank-recon→ confirm the new JEs appear on the bank side ready to match
Bank of Central Decatur lockbox; DeKalb County customer account 12301
Lockbox deposits posted; reconciliation reports generated for e-banking, state/federal revenue
✓ 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.
- Open
/account/subsidiary-recon→ confirm AR/AP/FA/Payroll summary cards show GL balance vs subledger balance - Any mismatched row is highlighted red with a "View detail" link
- Open
/account/interfund-recon→ confirm per-fund transfer summary - Create a test JE with DR Fund 100 / CR Fund 200 → confirm it appears in the interfund view
AP Account balance data for auto reconciliation
Intercompany and AP account reconciliations automated with workflow
✓ PASSED — Saved 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"].
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.
✓ 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.
- Open
/account/financial-reports/profit-loss→ see full detail - Append
?subtotals_only=1to the URL → view collapses to function-level subtotals - Repeat for
/account/financial-reports/trial-balanceand/account/financial-reports/balance-sheet - Open
/account/finance/other/budget-control→ confirm utilization bars flag overspent categories red
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.
- 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 - Navigate to
/account/finance/reports/balance-sheet— verify Assets / Liabilities / Equity sections populate with the correct totals and the sections balance - Navigate to
/account/finance/reports/profit-loss— verify Revenue / Expense sections with Total Revenue, Total Expenses, and Net Profit/Loss computed - Navigate to
/account/general-ledger— verify GL entries list, KPI cards (Revenue, Expenditures, Net Position, Unposted), and the pill tabs filter by voucher type - Navigate to
/account/chart-of-accounts— verify the full COA with type and root type badges - 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.
- Navigate to
/account/journal-entries - Verify KPI cards: Draft Entries, Posted Entries, Reversed — all with accurate counts
- Each row shows: JE #, Date, Reference, Memo, Debit, Credit, Status badge, Source link, Frappe voucher, Actions kebab
- Find any posted JE and click the kebab → View
- Header shows JE number + status + approval status + Frappe voucher
- Source banner (if present) shows the originating record (Encumbrance / AR / AP / FA / Recurring) with a View Source link
- Meta grid: Posting Date, Reference, Frappe Voucher, Posted At
- Lines table with line no, account, debit, credit, description
- Totals row with Debits = Credits and green ✓ Balanced indicator
- Navigate to
/account/general-ledger - KPI cards populate live from Frappe: Total Revenue, Total Expenditures, Net Position, Unposted Entries
- Pill tabs filter GL rows: All / Revenue / Expense / Journal Entry
- 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.
- Navigate to
/account/period-close - Click the + Initialize Fiscal Year button (top right)
- Enter fiscal year
2026and click Initialize - 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)
- Verify each row shows the correct date range (e.g. P01: 07/01/2025 — 07/31/2025, P12: 06/01/2026 — 06/30/2026)
- Verify all periods start with OPEN status (green badge)
- Verify KPI cards: Open Periods = 12, Closed = 0, Reopened = 0
- Confirm a success banner:
Fiscal year 2026 initialized with 12 periods. - 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.
✓ 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.
- Payroll posts monthly accrual JE via
PayrollAccrualService(or manual) — goes to pending approval if > $5K - Approver reviews at
/account/je-approvals— can approve or reject with a reason - Reject with reason "George Ramirez hours discrepancy — needs adjustment"
- Payroll analyst posts a correcting adjustment JE in a follow-up step
- Both JEs visible in audit trail, discrepancy resolved before period close
Payroll underpayment found during recurring JE verification
Discrepancy identified, resolved, and balanced through approval workflow
- Navigate to
/account/period-close, find P10 or similar month, verify status OPEN - Navigate to
/account/fixed-assets, pick an active asset with monthly depreciation configured - On the asset show page click Run Depreciation (This Month) — confirm the new Depreciation History row and the linked JE
- Navigate to
/account/journal-entriesand open the new DEP JE — two balanced lines (DR Depreciation Expense / CR Accumulated Depreciation) - Navigate back to Period Close, click ⋮ → Close Period on the month and add a note
- Verify status flips to CLOSED
- Try to Run Depreciation for the same month again — rejected because month is already posted
- 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.
- Navigate to
/account/period-close, close the period covering today (e.g. P10 — Apr 2026) via ⋮ → Close Period - Navigate to
/account/journal-entriesand click + New Journal Entry - Posting Date: today; Line 1: 1115 - Operating Account Debit
10.00; Line 2: 5223 - Miscellaneous Expenses Credit10.00 - Click Save Draft
- Confirm an inline error appears under the Posting Date field:
Accounting period 'P10 — Apr 2026' is closed. Posting is blocked. - Confirm the form does NOT submit and no JE row is created
- Return to
/account/period-close, reopen the closed period with a reason - Retry the JE save in the side panel — confirm it now succeeds and redirects to the show page with status DRAFT
- Re-close the period to verify state transitions work both directions
- Try saving another JE dated today — confirm it is blocked again
- On the Period Close page, verify each CLOSED row shows Closed At timestamp populated
- Verify each REOPENED row shows Reopened At timestamp AND the reason text in the Reason / Notes column
- 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.
- Navigate to
/account/journal-entries - Verify the table has a new Source column between Status and Frappe
- 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)
- 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) - Click the source link — verify it navigates to the originating record's show page (encumbrance list, AR invoice show, PO show, asset show)
- Open any sourced JE's show page
- Verify an indigo banner above the meta grid: [Source Type]: [display name] with a View Source → button on the right
- Click the button — navigates to the source record
- From the source record, navigate back to confirm the drill-down is bidirectional via the JE detail page
- Navigate to
/account/general-ledger - Find a row whose voucher matches one of the recently-posted Missio JEs (check
frappe_name) - Verify the Voucher cell renders as a purple 🔗 link with hover tooltip "Drill to source document"
- Click the link — navigates to the Missio JE show page for that voucher
- 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).
✓ 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.
- Open
/account/period-close→ confirm all 12 months of the current FY are listed - Pick a prior month (e.g. October) → click Close → confirm status flips to "closed"
- Go to
/account/journal-entries→ + New Journal Entry with posting date in October → attempt to post → expect rejection with "Period is closed" - Same JE with posting date in the current open month → posts successfully
- Go back to period-close → click Reopen on October → supply reason → confirm state is "reopened"
- Re-post the October JE → succeeds
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.
✓ COVERED: JE audit trail (person/date/time), line-level comments via description column. Auto-balance-by-BU and attachments are out of scope.
- Open any posted JE at
/account/journal-entries/{id} - Verify the meta grid shows Posted At timestamp, Posted By user, Frappe voucher name
- journal_entries table stores created_by, posted_by, posted_at (who/when audit)
- 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)
- journal_entry_lines stores description per line for line-level annotation
- 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.
- Create a new JE via
+ New Journal Entrypanel - Leave Account blank on both lines → inline error
Account is required.under each Account dropdown - Create $100 DR + $90 CR (out of balance) → inline error
Out of balance: debits $100.00 ≠ credits $90.00 - 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. - Only one non-zero line → inline error
A journal entry needs at least 2 non-zero lines. - Posting date in a closed period → inline error
Accounting period 'P10 — Apr 2026' is closed. Posting is blocked. - 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.
- Navigate to
/account/journal-entries - Find a posted JE (e.g. one of the balanced entries from previous tests with status POSTED)
- Click the ⋮ kebab on its row → Reverse Entry (red option, only visible on posted rows). Confirm the prompt
- Verify the row status flips from POSTED to CANCELLED
- Open the JE show page — the header badge reads
CANCELLED. The "Reverse Entry" button is gone - Navigate to
/account/general-ledgerand 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
- Create a new JE via + New Journal Entry, balanced, Save Draft
- On the show page verify the status is
DRAFT - 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.
- Navigate to
/account/journal-entries→ click + New Journal Entry - Create a balanced $100 entry (DR 1115 Operating 100, CR 5223 Misc 100). Save Draft
- On the show page the button reads "Post to GL" (below $5,000 threshold)
- Click Post to GL — confirm. Status flips to POSTED with Frappe voucher populated
- New JE: balanced $10,000 (DR 5310 Instruction, CR 1115 Operating), memo
Large expense test. Save Draft - The button now reads "Submit for Approval"
- Click Submit for Approval — confirm. Banner:
Entry submitted for approval (amount $10,000.00 ≥ threshold $5,000.00) - Status badges become
DRAFT+ amber⌛ Awaiting Approval. The action button is replaced by "Awaiting approver decision" - Navigate to
/account/je-approvals— the JE appears in Pending Requests with its number, amount, submitter, relative time. KPI Pending = 1
- In the Approvals Inbox, click the green ✓ Approve button on the pending row, confirm
- Banner:
Approved and posted: JE-2026-00XXX → ACC-JV-2026-YYYYY - Row moves from Pending to Recent History with
APPROVEDbadge; KPI Approved Today increments - Open the JE show page — status is now
POSTED+ green✓ Approvedbadge, Frappe voucher populated, Posted At timestamped - Navigate to
/account/general-ledger— verify the DR/CR rows for the approved JE appear for today's date
- New JE for $7,500, submit for approval
- In the inbox, click the red ✗ Reject button → modal opens
- Try submitting with an empty reason — form refuses (required textarea)
- Enter reason
Wrong expense account — should be 5216 not 5310; please correct and resubmit, click Reject - Row moves to Recent History with red
REJECTEDbadge and the reason text - Open the rejected JE — status still
DRAFTwith red✗ Rejectedbadge; button reads "Submit for Approval" again so the user can fix and resubmit
- Config key
gl.je_allow_self_approvalcontrols whether the submitter can approve their own request (default true for single-user testing, set to false for production) - 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 - Verified via
php artisan tinker: override config, call approve() → exception. Request stays pending
- Navigate to
/account/period-close, close the period covering today - Submit a large JE dated today for approval
- In the inbox, click Approve
- Expected red error:
Approved, but not posted: Accounting period 'P10 — Apr 2026' is closed. Posting is blocked. - The JE's
approval_status=approvedbutstatusstaysdraft(not posted to Frappe, no voucher) - 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.
- Navigate to
/account/recurring-jes→ click + New Recurring Template - Fill in: name "Monthly Internet", frequency monthly, day-of-month 1, lines DR 5201 $200 / CR 1115 $200. Save
- The new template appears with gray
DRAFTbadge in the Approval column and a yellow Submit button - Click Submit → badge flips to amber
PENDING; a green Approve button appears - 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."
- Navigate to
/account/journal-entries→ click + New Journal Entry - At the top of the form, the new indigo blue box shows the dropdown "Start from a recurring template"
- Pick "Monthly Internet — $200.00" → the form auto-fills with memo, reference, and both lines
- Don't change anything. Click Save → lands on JE show page
- 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
- Set
GL_JE_APPROVAL_THRESHOLD=0in.env, runphp artisan config:clear(so threshold doesn't mask the test) - Open New Journal Entry, pick the "Monthly Internet" template again
- Change the debit on line 1 from $200 to $250, and the credit on line 2 from $200 to $250 (keep balanced)
- Save → the show page now shows a blue "Submit For Approval" button instead of "Post" because
spawned_from_template_modified=true - Click Submit For Approval → expected: "Entry submitted for approval (amount $250.00 ≥ threshold $0.00)"
- The JE appears in
/account/je-approvalsPending Requests table, waiting for an approver
- As admin, create a test employee at
/account/employees/createwith role = Employee (NOT admin) - Log out, log in as the test employee
- Open Finance → Other in the sidebar → the Approvals Inbox menu item is HIDDEN for non-approver users
- Try to navigate directly:
http://<host>/account/je-approvals→ expected 404 (or 403). Test employee is blocked from the controller-level gate - Open
/account/recurring-jes→ for any pending template, the green Approve button is replaced by gray text "awaiting approver"
- Log out as test employee, log back in as admin
- Navigate to the test employee's profile → click the Permissions tab
- At the top of the Permissions tab, find the new "Finance Approver" section with a green Grant Approver Role button
- Click Grant Approver Role → confirm → alert: "Finance Approver role granted." The badge flips from gray NOT GRANTED to green GRANTED
- Log out, log back in as the test employee
- Open Finance → Other → the Approvals Inbox menu item is now visible
- Click it → the page loads normally (no 404)
- 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.
✓ 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.
- Open any existing JE at
/account/journal-entries/{id} - In the Attachments section, upload a PDF → confirm it appears with download link
- Click Copy as New Draft → confirm a new draft JE is created with identical lines and the source-stamp "Copied from JE-YYYY-NNNNN"
- Delete the uploaded attachment → confirm it is removed
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
✓ PASSED — Excel/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.
Upload JE batch from Excel spreadsheet
Uploaded JEs validated against same rules; template supports copy/paste
✓ 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.
- Create a JE whose amount exceeds the approval threshold (default $5,000)
- Attach a supporting document on the JE show page
- Post the JE → it's routed to the Approvals Inbox
- Log in as a second user with approval permission → open the Approvals Inbox at
/account/je-approvals - Click the pending request → scroll to the Attachments section → download the file → confirm it opens
- Approve or reject the JE as needed
View attachment on pending-approval JE
Attachments accessible during approval workflow
- Every JE has a Reference text field for cross-referencing (grant number, check number, voucher number, etc)
- 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
- JournalEntry::SOURCE_MAP defines the category taxonomy; JournalEntry::sourceDescriptor() resolves the label + display name + drill URL for each category
- 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.
- Manual JE: use + New Journal Entry on the JE page — covered
- Recurring: create a Recurring Template with monthly/quarterly/annual frequency — covered (SYSGOV-005 / AP-005)
- Auto-recorded: every subledger module (AR, AP, FA, Encumbrance) auto-posts balanced JEs — covered (SYSGOV-004)
- Reversals: use Reverse Entry on any posted JE (PRE-025), or configure auto-reverse on a recurring template with reverse-days-after offset
- Top-side: manual JE at any level (account + amount) works via the JE form
- 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.
- Navigate to
/account/journal-entries - The Draft Entries KPI card shows the count of JEs that have been saved but not yet posted to the GL
- Each row has a status badge: DRAFT (amber), POSTED (green), CANCELLED (grey)
- Unposted entries have status = draft and frappe_name = NULL in journal_entries (nothing has hit Frappe yet)
- Subledger-originated entries (AR invoice, AP receipt, FA acquire, Recurring run, Encumbrance convert) post immediately; their draft state is transient
- 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.
✓ 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.
- Open
/account/finance/other/budget-control→ confirm per-account summary table with Allocated / Expended / Encumbered / Available columns - Utilization column shows traffic-light bar (green <80%, amber 80-100%, red over 100%)
- Post a JE that debits a budgeted expense account → refresh → confirm Expended amount increased and Available decreased by the same amount
- Create an encumbrance at
/account/encumbrances/createon the same account → refresh → confirm Encumbered increased and Available decreased
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
✓ 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.
- Open
/account/subsidiary-recon→ confirm 4 summary cards (AR, AP, FA, Payroll) - Each card shows "GL balance $X" vs "Subledger $Y" with a green check or red exclaim
- Click the drill-link on one → see the subledger detail rows
- 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
Compare GL AP balance ($68,000) with AP subsidiary detail (DeKalb County Gas)
Out-of-balance report identifies discrepancies between GL and subsidiaries
- Create a JE via + New Journal Entry panel
- Enter Debit $100 on line 1 and Credit $90 on line 2
- Click Save Draft
- Expected red error under line 1 Debit cell:
Out of balance: debits $100.00 ≠ credits $90.00 - The form does NOT submit and no JE record is created
- Same check runs at post time: if someone edits a draft via tinker to be unbalanced, Post to GL rejects it
- 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.
- Open any posted JE's show page
- Verify audit metadata: Posted At timestamp, Posted By user, Frappe voucher name, Posting Date, Reference
- journal_entries table records: created_by (draft submitter), posted_by (poster), posted_at (post timestamp), status (draft / posted / cancelled)
- Reverse a posted JE via Reverse Entry — the status changes to CANCELLED while the original posted_at and posted_by remain preserved
- 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.
- Navigate to
/account/period-close, close P09 (September) - Navigate to
/account/journal-entries, try to create a JE dated 09/15 - Expected inline error:
Accounting period 'P09 — Sep 2025' is closed. Posting is blocked. - The period is locked down — no new postings can enter
- Return to Period Close, click ⋮ → Reopen Period on P09
- Enter a required reason like
Prior period adjustment — missed vendor invoice - Status flips to REOPENED (amber), timestamp + reason stored
- Now post the adjustment JE dated 09/15 — it succeeds
- 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.
✓ 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.
- Verify all periods for the fiscal year are closed at
/account/period-close - Trigger the year-end close via tinker:
php artisan tinker --execute='(new App\Services\Gl\YearEndCloseService(105))->postClosingEntry(2025, "6212 - Fund Balance - DSD", null)' - Open
/account/journal-entries→ confirm a new JE exists with referenceYEARCLOSE-2025, dozens of lines (one per revenue/expense account), and balanced totals - Open
/account/financial-reports/trial-balancefor the new FY → confirm revenue/expense accounts all show $0 opening balance - Fund Balance account shows the $11,522,100 carried forward
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
✓ 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.
- Open
/account/journal-entries/create - Try to enter an invalid date (e.g. "not-a-date") → form rejects with "posting_date is required / must be a valid date"
- Enter a valid date in a closed period → validator rejects with "Period is closed (closed on YYYY-MM-DD by user X)"
- Enter a valid date in an open period with Reference = "CHK-1234" → save as draft
- Post the draft → open its detail page → confirm source banner shows the reference
Enter JE with valid source and date in open period
JE accepted for processing
- Navigate to
/account/period-close, close the period covering today - Navigate to
/account/journal-entries, click + New Journal Entry - Enter a balanced JE with today's date
- Click Save Draft
- Expected inline error under Posting Date:
Accounting period 'P10 — Apr 2026' is closed. Posting is blocked. - Change the date to a different open period and retry — the save succeeds
- 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.
- On the JE create side panel, the Posting Date field is a native HTML5 date input
- Browsers reject invalid dates at the input level (can't type "abc" as a date)
- Server-side Validator rule
required|daterejects any string that isn't a parseable date - 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.
- Create a JE — note the auto-generated JE number format:
JE-YYYY-NNNNN(alpha-numeric with dashes only) - JournalEntryController::nextJeNumber() always returns a deterministic sequence so users can't inject special characters
- The journal_entries table has a unique index on (company_id, je_number) so duplicate IDs are impossible at the DB level
- 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).
✓ 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.
- Open
/account/journal-entries/create - Try to submit with the first line's account blank → field-level error "Account is required"
- Enter account but leave debit and credit both zero → error "A journal entry needs at least 2 non-zero lines"
- Enter both debit and credit on the same line → error "A line can have either a debit OR a credit, not both"
- Enter a single balanced pair of valid lines → saves as draft successfully
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
- Auto-generated JE numbers ensure every JE has a unique je_number per company
- The (company_id, je_number) unique constraint on journal_entries prevents duplication of any ID+company combination
- Since the header_date is stored on each row and the je_number is itself unique, the ID+date combination is implicitly unique too
- 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.
✓ 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.
- Open
/account/journal-entries/create— note the header panel (JE #, date, reference, memo) is visually separate from the Lines table - Inspect the form field names: header fields have flat names (
posting_date), line fields use array notation (lines[0][account]) - The server rejects any payload where line fields leak into the header scope
⚠ 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
- Navigate to
/account/journal-entriesand click + New Journal Entry (right-side panel) - Posting Date:
10/22/2025; Memo:Property Tax receipt - Line 1: Cash / Operating Account, Debit
2675059.00, DescriptionProperty Tax Receipt - Line 2: Property Taxes (revenue), Credit
2675059.00 - Verify the footer shows ✓ Balanced ($2,675,059 = $2,675,059)
- Click Save Draft → show page renders with status DRAFT
- Click Post to GL → confirm the prompt
- Status flips to POSTED, Frappe voucher populated (ACC-JV-YYYY-NNNNN)
- 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.
- Navigate to
/account/journal-entriesand click + New Journal Entry - Posting Date: the September QBE receipt date; Memo:
September QBE payment from state - Line 1: Cash / Operating Account, Debit
1701047.00 - Line 2: Intergovernmental Revenue - State, Credit
1701047.00 - Save Draft and Post to GL
- Verify in the General Ledger page that the two rows appear with the correct date and amounts
- 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.
- Navigate to
/account/journal-entriesand find the incorrect posted JE (Georgia Power $68,000) - Click the kebab → Reverse Entry, confirm — status flips to CANCELLED, Frappe voucher is cancelled
- Click + New Journal Entry and create the correct $63,000 JE with the same accounts
- Save Draft → Post to GL
- Navigate to
/account/general-ledger— the incorrect cancellation nets to zero and the new correct entry appears - 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.
- Navigate to
/account/journal-entriesand find the posted JE with the swapped Grant Revenue allocation - Kebab → Reverse Entry, confirm — original JE cancelled
- Create a new JE with the correct Local Share / Reimbursement account split
- Save Draft → Post to GL
- 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.
- Navigate to
/account/journal-entries, click + New Journal Entry - Enter a balanced HVAC JE: DR Instruction $51,850 / CR AP $51,850, memo
HVAC pre-close accrual - Click Save Draft — the JE is saved with status DRAFT, frappe_name NULL
- Do NOT click Post to GL — verify the JE stays in DRAFT, not in the GL, and not in Frappe
- The Draft Entries KPI increments by 1; the Posted Entries KPI is unchanged
- 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.
- Navigate to
/account/finance/reports/trial-balance - Select Fiscal Year FY2023 from the filter
- Verify the Totals row shows DR
$7,262,500/ CR$519,500 - Switch to Fiscal Year FY2024
- Verify the Totals row shows DR
$12,167,500/ CR$680,500 - 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.
- Navigate to
/account/finance/reports/profit-loss - Find the Revenue section and identify each revenue account
- Verify the DeKalb County line shows
$1,352,200($761,200 Inv001 + $591,000 Inv002) - Verify the State QBE line shows
$2,117,000 - Verify Total Revenue =
$3,469,200 - 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.
- Navigate to
/account/finance/reports/profit-loss - Find the Expenses section and identify each expense account
- Verify the school buses line shows
$719,250(posted expenditure) - The HVAC JE of $51,850 is in DRAFT status (not posted), so it does NOT appear on the P&L
- Verify Total Expenses reflects only the $719,250 that has actually been posted
- 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.
- Navigate to
/account/procurement/purchase-orders(sidebar: Supply Chain → Procurement → Purchase Orders) - Click + New Purchase Order — side panel opens
- Select the 3-Way Match type card (blue outlined — default)
- Fill: Vendor
Dell Inc., Total61875.00, Order Date today, Expected Delivery today + 14d, Memo75 laptops for Oakhurst - GL mapping: Expense Account (e.g. 5310 Instruction), AP Account (2115 Accounts Payable), RNI Account required for 3-way
- Click Create PO — redirects to show page with auto number
PO-2026-00001, status OPEN, Received $0, RNI $0 - Click the amber Receive Goods button — modal shows remaining $61,875
- Click Receive & Post RNI JE
- Confirm banner
Goods received and RNI accrual posted - Status flips to amber RECEIVED; Received = $61,875; RNI Balance = $61,875
- Goods Receipts table shows one row
GR-2026-00001with linked JE
- Navigate to
/account/journal-entriesand find the JE with referenceGR-2026-00001 - Verify status POSTED, memo
Goods received (RNI accrual): PO-2026-00001 — Dell Inc. - Two balanced lines: DR Expense Account $61,875 / CR RNI Account $61,875
- Frappe voucher name populated
- Return to the PO show page and click Match Invoice
- Modal says "3-way match. RNI balance available: $61,875.00. Posts: DR RNI / CR AP."
- Invoice Number:
DELL-INV-4567, Date today, Amount pre-filled $61,875 - Click Match & Post AP JE
- Status flips to green CLOSED, Invoiced = $61,875, RNI Balance back to $0
- Vendor Invoices table shows the row with match type
3 way, statusmatched - 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.
- Create a 3-way PO in
/account/procurement/purchase-ordersfor $1,000 - Click Receive Goods, amount $1,000, submit — RNI accrual JE posts (DR Expense / CR RNI)
- Do NOT match an invoice yet
- Return to the PO list — the row shows RNI Balance $1,000.00 in red, status RECEIVED
- Index KPI RNI Balance totals all POs where received > invoiced — this is the month-end accrual total
- Navigate to
/account/general-ledgerand filter to today's date — verify DR Expense, CR RNI rows appear - The RNI Balance persists as a liability until the vendor invoice arrives and is matched
- 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.
- Create a 3-way PO (see AP-001 for detailed steps)
- Receive goods — verify the DR Expense / CR RNI accrual JE posts
- Match the vendor invoice — verify the DR RNI / CR AP reversal JE posts
- Status progresses: OPEN → RECEIVED → CLOSED
- Click + New Purchase Order, switch to the purple 2-Way Match type card
- Confirm the RNI Account label changes to "(not used for 2-way)"
- Vendor
Georgia Power, Total63000.00, Expense Account, AP Account. RNI account can be left blank or any value - Create PO — on the show page, confirm there is no "Receive Goods" button (2-way skips GR)
- Click Match Invoice — modal says "2-way match. Posts: DR Expense / CR AP."
- Invoice Number
GP-0925-1234, amount $63,000, submit - Verify the JE posts directly DR Expense / CR AP (no RNI intermediate step)
- Status flips straight to CLOSED
- Create a 3-way PO for $1,000
- Try to receive
$1,200— expect errorReceipt amount (1200) exceeds PO remaining (1000).No GR record or JE is created - Receive $1,000 cleanly
- Try to match an invoice for
$1,500— expect error3-way match failed: invoice 1500 exceeds RNI balance 1000.No invoice record or JE is created - Match $1,000 exactly — status closes
- Close today's period in
/account/period-close - Try to receive goods against a 3-way PO — error
Accounting period is closed. Posting is blocked. - 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.
- Navigate to
/account/finance/accounts-payable/1099-tracking - Verify the vendor list with 1099 eligibility flag (is_1099 column) and YTD totals per vendor
- Use the pill tabs to filter by 1099 status: All / 1099 / Non-1099
- Drill into a vendor — see year-to-date payments breakdown
- Toggle 1099 eligibility via the kebab actions — the flag persists
- 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.
- Navigate to
/account/recurring-jes, click + New Recurring Template - Name
Monthly Rent Accrual, PrefixRENT, Frequency Monthly, Start Date today - Lines: DR
5310 Instruction$2,500; CR2115 CSD Accounts Payable$2,500 - Create Template, then click Run Now — confirm
- Run Count increments, Last Run = today, Next Run advances one month
- Click the posted JE in Run History — reference
RENT-RJE-2026-00001-20260414, memoMonthly 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 - Run Now again — posts a second run with a later reference date and increments Run Count to 2
- Every run posts a real Journal Entry to the GL via the same pipeline as manual JEs, so period-close + frozen-account guards apply
- Create a template with Start Date = today and End Date = today + 30 days (1 monthly run remaining)
- Run Now — posts the first and only JE, status transitions from ACTIVE to COMPLETED because next_run_date would be past end_date
- 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.
- Navigate to
/account/recurring-jes, find an active template - On the show page click Pause
- Status flips to PAUSED; the Run Now button disappears
- Navigate back to the index — the Due Now KPI decrements and the Paused KPI increments
- Run
php artisan gl:process-recurring-jes— paused templates are skipped, no JE is posted - 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.
- Click + New Journal Entry
- Set Posting Date to a future date (e.g. today + 30 days) in an open period
- Create a balanced expense JE (DR expense account / CR AP)
- Save Draft and Post to GL
- Navigate to
/account/general-ledger— the JE is visible with the future posting date - 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.
- Navigate to
/account/finance/other/travel-expenses(existing Travel & Expense module) - Verify the expense list with linked GL account column
- 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
- airfare / hotel / mileage / parking →
- Create a new travel expense with each type to verify the correct GL account is used
- 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.
✓ 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.
- Visit
/account/finance/other/travel-expenses(sidebar: Finance → Other → Travel and Expense) - Run the existing
TravelExpenseDemoSeederor submit Tori Williams' demo expenses (airfare $798, hotel $2,659.42, conference $885, mileage $10.50, parking $180) - Mark each approved (individual action or via principal workflow)
- Check the approved rows (checkbox column), click Post Approved to GL in the header
- Enter the payable account (e.g.
2170 - Travel Reimbursement Payable - DSD) in the modal - Click Post & Create JE → success toast, one JE appears in
/account/journal-entries - 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".
- Re-posting blocked: same expenses now have
journal_entry_idset — selecting them and clicking Post again returns "No eligible expenses found"
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
✓ 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.
- Sidebar: Finance → Other → P-Cards
- Click + New P-Card — create card "****4821" for Patrice Allison, Oakhurst Elementary, credit limit $5000, default clearing
2160 - P-Card Clearing - Open the card → click + New Transaction
- 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" - Each transaction saves with status pending; PC-011 validation runs (GL account required, not frozen, budget check if linked)
- Click Approve on all 3 transactions → status flips to approved
- Check all 3 boxes in the batch selector, click Post Batch to GL
- 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"
- Each transaction row now shows "posted" + JE link
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
✓ PASSED — PCardService::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
- Open any P-Card → click + New Transaction
- Gate 1 — try to submit with blank GL account → validation error "GL expense account is required"
- Gate 2 — use
/account/chart-of-accountsto freeze an account, then try to submit a P-Card charge against it → error "GL account X is frozen. P-Card entry blocked" - 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" - Submit a valid transaction (GL account set, not frozen, no budget link or sufficient budget) → saves successfully with status pending
Enter P-Card charge and assign GL account code
System validates GL account exists, expense type is valid, budget is available
- Navigate to Budget Transfers (existing module under Finance → Budgets)
- Create a new transfer: From Communication account, To Dues & Fees account, Amount $2,000
- Approve the transfer — BudgetTransferController calls FrappeJournalEntryPoster which posts DR To / CR From as a balanced JE to Frappe
- Navigate to
/account/journal-entriesand find the new posted JE - 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.
✓ PASSED — Budget 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.
Glennwood Sub for Certified: budget $37,776, spent $36,918, new charge $2,134
System blocks transaction; workflow initiated to transfer funds before processing
✓ 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.
Attempt to overspend Substitute for Certified account
Hard stop prevents negative balance on controlled account
✓ 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.
Operational expenditure account group at Glennwood Elementary
Group-level control prevents total operational accounts from going negative
✓ 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.
FY2025-2026 approved budgets for all departments/schools
GL budget accounts populated with approved FY2025-2026 amounts
- Navigate to
/account/encumbrances(Finance → Other → Encumbrances) - Click + New Encumbrance; select Pre-Encumbrance type
- Vendor:
Legacy Construction Co, Account: Capital Outlay, Amount:355422.00(FY24-25 portion) - Save Encumbrance — row appears with PRE-ENC badge, Pre-Encumbered KPI updates
- Promote to encumbrance via the kebab menu — status flips to ENCUMB
- 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
- Navigate to
/account/journal-entries— verify the new POSTED JE with source link back to the encumbrance - Navigate to
/account/general-ledger— verify the capital expenditure rows - 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.
- Navigate to
/account/journal-entries, click + New Journal Entry - Posting Date: the QBE deposit date; Reference:
ACH-QBE; Memo:QBE deposit from State - Line 1: 1115 Operating Account (General Fund), Debit
2149897.83 - Line 2: Intergovernmental Revenue - State, Credit
2149897.83 - Save Draft and Post to GL
- 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.
- Navigate to
/account/bank-recon(Finance → Other → Bank Reconciliation) - Click + New Reconciliation
- Bank Account: 1115 - Operating Account, Period: current month, Opening Balance $0, Statement Balance $10,000
- Create — redirects to the two-pane show page
- Click + Add Statement Line and add 2–3 bank lines summing to $10,000 (positive = deposit, negative = withdrawal)
- Confirm all new lines show amber UNMATCHED
- Red "Difference" banner shows "$0.00 — Unmatched: N" — not reconciled yet because unmatched lines exist
- Ensure posted JEs on the bank account exist within the period (from AR payments, AP payments, FA disposals, etc)
- Click the blue Auto-Match button — banner shows "Auto-matched N line(s)"
- Matched rows turn green with AUTO MATCHED badge and linked JE id
- The GL Side pane (right) lists remaining unmatched JE lines on the same account within ±3 days of the period
- 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
- Click the red × on any matched row to unmatch it and retry
- Once all lines are matched and difference = $0.00, the banner turns green: ✓ Reconciled
- Click Complete — status flips to COMPLETED, the reconciliation is locked (all action buttons disappear)
- 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.
- Click + New Journal Entry
- Line 1: 1118 Local School Funds, Debit
1345.00, DescriptionField trip bus expenses - local school deposit - Line 2: appropriate revenue account, Credit
1345.00 - Save Draft and Post to GL
- 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.
- Click + New Journal Entry three times (one per daily deposit)
- Day 1: DR Cash $5,431 / CR School Nutrition Revenue $5,431
- Day 2: DR Cash $4,234 / CR School Nutrition Revenue $4,234
- Day 3: DR Cash $6,107 / CR School Nutrition Revenue $6,107
- Post each to the GL
- 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.
- Click + New Journal Entry
- Line 1: Cash / Operating Account, Debit
47945.00 - Line 2: Ad Valorem Tax Revenue, Credit
47945.00 - Reference:
ACH-AVT, Memo:Ad valorem tax ACH receipt - Save Draft and Post to GL
- 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.
✓ 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.
- Sidebar: Finance → Other → Bank Imports (BAI)
- Click + Upload BAI File, select a .bai or .txt file in BAI2 format from the bank
- Parser runs → redirect to import show page with the parsed transactions table (line #, date, type code, description, direction badge, amount, GL account dropdown)
- Review each row — adjust the mapped GL account if the default mapping is wrong (dropdown + Save button per row)
- Pick the Cash Account in the green post panel at the bottom (dropdown filtered to Bank/Cash accounts)
- Click Post All Transactions → each row becomes a balanced JE posted to the GL and Frappe. Status flips to "posted" with JE # link
- Open
/account/journal-entries— all N new JEs are listed with referenceBAI-<import>-<line>and memo "BAI import <filename> line <N>" - Re-running the post endpoint errors: "Import already posted" (idempotent)
- 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.
BAI file from bank with transaction codes
BAI transactions auto-coded to correct GL accounts
- Navigate to
/account/bank-recon, click + New Reconciliation - Bank Account 1115 - Operating, Statement Balance
497200, Opening Balance0 - Add a single bank statement line of
497000.00 - Observe the red Difference banner:
Difference: $200.00 — Unmatched: 1 - Auto-Match or Manual Match the $497,000 line against the corresponding GL journal entry line on the operating account
- The Difference stays at $200 because the bank shows $200 more than the GL side
- 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
- Once the difference lands at $0 and zero items are unmatched, click Complete
- 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.
- Navigate to
/account/ar-invoices - Create a new AR invoice for any customer, any amount
- Open the invoice and click Apply Payment
- Enter payment date, cash account, amount
- ArService::applyPayment() automatically creates and posts a balanced DR Cash / CR Receivable JE using the cash_account field as the DR target
- Verify the new posted JE in
/account/journal-entrieswith source link back to the AR Payment row - 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.
✓ 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.
- Go to
/account/ar-customers(sidebar: Finance → Receivables → AR Customers) - If no invoices exist yet, click Run Recurring Billing Now to auto-generate the month's invoices for all 6 seeded parents
- Click Karoline Durbin → the Invoices section shows the auto-generated invoice
AR-YYYY-NNNNNwith one line "After-School Care — Melissa ($month $year) $225.00" - Click the invoice number → show page opens at
/account/ar-invoices/{id} - Click Apply Payment — modal pre-fills $225.00 in the Amount field
- Cash Account: 1115 - Operating Account - DSDD; Reference:
CHK-1234; click Apply & Post JE - Verify banner "Payment applied and posted to GL"; status flips green PAID; Outstanding $0, Amount Paid $225.00
- Payment History shows row
AR-PMT-YYYY-NNNNNwith cash account and JE link - 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 - Back to Karoline's customer show page — Outstanding Balance drops to $0.00, invoice status PAID
- 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.
✓ 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.
- Go to
/account/ar-customers→ Melissa Bass → Invoices section → click the $225 After-School Care invoice - Click Apply Payment; change the pre-filled $225 to $100; Cash Account
1115 - Operating Account - DSDD; click Apply & Post JE - Verify status flips amber PARTIALLY PAID; Outstanding = $125; Amount Paid = $100
- Payment History shows one row; Apply Payment button still visible
- Back to Melissa Bass customer page — Outstanding Balance = $125.00
- Repeat with Thomas Decker: click his $1,500 K-12 Tuition invoice, Apply Payment of $1,250, verify Outstanding = $250
- Open
/account/ar-aging— Melissa Bass $125 and Thomas Decker $250 both show in the 0-30 bucket - Open
/account/journal-entries— each partial payment posted its own balanced JE with Frappe voucher,ar_customer_idstamped on eachar_paymentsrow
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.
✓ 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.
- Go to
/account/ar-customers→ Natalie & Ben Beisner → Invoices section → click the open $215 After-School Care invoice - Click the red Write Off button — modal opens showing outstanding $215
- Reason:
Family moved out of district; confirmed uncollectible after 3 contact attempts - Bad Debt Expense Account: pick any expense account from the Frappe CoA dropdown
- Click Write Off & Post JE
- Verify red WRITTEN OFF status; Amount Written Off = $215; Outstanding = $0
- Red write-off banner at the bottom with reason, timestamp, and linked JE ID
- Apply Payment and Write Off buttons disappear (terminal state)
/account/journal-entries— new POSTED JEWO-AR-YYYY-NNNNN, DR Bad Debt / CR 1320 Student Receivables, memo "Bad debt write-off: Natalie & Ben Beisner", Frappe voucher populated- Natalie & Ben's customer show page — Outstanding $0; invoice marked WRITTEN OFF
- 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.
✓ 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.
- Go to
/account/ar-invoices - Click + Record Cash Receipt (new outline button next to + New AR Invoice)
- Fill Customer / Payer (e.g. "Walk-in Gate Receipts"), Receipt Date, Amount, optional Memo
- Pick a Cash account (DR) and a Revenue account (CR) from the Frappe CoA dropdowns
- Click Record & Post Receipt JE
- Toast confirms receipt number
AR-CR-YYYY-NNNNNand redirects to index - Open
/account/journal-entries— the new JE appears balanced, with Source = "AR Cash Receipt" drilldown back to AR Invoices index - Verify in Frappe: same JE posted via
createAndSubmit('Journal Entry', ...)
- Period-close guard —
PeriodCloseService::ensureOpenForDate()blocks closed periods - Frozen-account guard — blocks if either cash or revenue account is frozen in Missio cache
- 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.
- Issue a $100 AR invoice to any customer
- Click Apply Payment, enter $150 (over the outstanding), apply
- Verify red banner:
Payment (150) exceeds outstanding balance (100).and no JE is created
- Navigate to
/account/period-close, close the period covering today (e.g. P10 Apr 2026) - Return to AR Invoices and try to issue a new invoice dated today
- Verify inline error:
Accounting period 'P10 — Apr 2026' is closed. Posting is blocked. - Reopen the period
- Navigate to
/account/chart-of-accounts, freeze your Receivable account via ⋮ → Freeze Account - Return to AR Invoices and try to issue a new invoice using that frozen Receivable
- Verify error:
Account ... is frozen. Operation blocked. - 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.
✓ 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.
- Sidebar: Finance → Receivables → AR Customers
- 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)
- Click type filter pill Government → only DeKalb + Georgia DOE shown
- 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
- Click + New Customer → right-slide form, type radio defaulted to Parent with Students sub-form visible
- Switch type to Government → Students sub-form hides, Lockbox Account # field appears
- Close without saving
- 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)
✓ PASSED — ArService::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.
- Go to
/account/ar-invoices→ click + New AR Invoice - Customer picker at top: type "Decker" → pick Thomas Decker
- Blue "Selected: Thomas Decker" bar appears; receivable account pre-filled to
1320 - Student Receivables - DSDD; first line row student dropdown now contains Hernando - Fill Line 1: description "K-12 Tuition", amount 1500, revenue
4130 - Charges For Services - DSDD, student Hernando - Click + Add Line → Line 2: "Activity Fee", 50, 4130, Hernando
- Click + Add Line → Line 3: "Lab Fee", 25,
4120 - Service - DSDD, Hernando - Click Issue & Post Invoice JE → success toast, redirect to invoice show page
- Show page: Line Items table displays 3 rows with student "Hernando", total footer $1,575.00
/account/journal-entries: the JE has 3 lines (not 4) — 1 debit on 1320, 2 credits (one per unique revenue account with amounts grouped)- Thomas Decker customer show page: Invoices section shows the new invoice with "3 lines" subtitle, outstanding balance updated
✓ PASSED — ArRecurringBillingService 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.
- Go to
/account/ar-customers - Click Dry Run Recurring → flash: "DRY RUN: Recurring billing — N invoices, total $X, 0 failures"
- Click Run Recurring Billing Now → confirm dialog → flash shows invoice count + total amount
- 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)
- Click Karoline Durbin → Invoices section shows the auto-generated invoice with line "After-School Care — Melissa ($month $year) $225.00"
- Recurring Charges section shows
Last Billed: YYYY-MMandNext Run: 1st of next month - Click Pause on Karoline's charge → status flips to "paused", Resume button appears
- Click Resume → back to active
- Click Run Recurring Billing Now again → flash: "Recurring billing — 0 invoices, total $0.00, 0 failures" (idempotent)
- Artisan:
php artisan ar:run-recurring-billing --dry-runprints a summary table without posting
✓ 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.
- Sidebar: Finance → Receivables → AR Aging Report
- Header shows "As of <today>"; 6 summary cells (5 buckets + Grand Total); date picker + Export CSV button
- 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
- Change "As Of" date to 60 days in the future → same customers, same amounts, now in the 31-60 bucket
- Click filter pill Government → empty state (no government customers with outstanding balances)
- Click Export CSV → file
ar-aging-YYYY-MM-DD.csvdownloads with per-customer rows + a TOTAL row - Click any customer name → drill through to customer show page
- Navigate to
/account/fixed-assets(sidebar: Finance → Other → Fixed Assets) - Click + New Fixed Asset — the right side panel opens
- Fill: Name
EF001 - School Bus, CategoryVehicles, Acquisition Date today, Cost $720,000, Salvage $60,000, Useful Life 144 months (12 years) - Select the four GL accounts: Asset Account, Cash/Offset Account, Depreciation Expense, Accumulated Depreciation
- Click Save & Post Acquisition JE
- Verify redirect to the asset show page with auto-assigned number
FA-2026-00001, status ACTIVE - Index KPIs: Active Assets = 1, Gross Book Value = $720,000, Net Book Value = $720,000
- Navigate to
/account/journal-entries— verify a new POSTED JE with referenceFA-2026-00001, memoAcquisition: EF001 - School Bus, two balanced lines (DR Asset $720,000 / CR Cash $720,000), Frappe voucher populated - Navigate to
/account/general-ledger— verify the two new rows (DR Asset, CR Cash) appear for today's date - Period-close guard: with today's period closed, attempting to save a new asset returns
Accounting period 'P10 — Apr 2026' is closedand 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.
✓ 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.
- Open
/account/fixed-assets→ pick an asset (e.g. EF001 Media Center Freezer) - Click Adjust Value → enter new value $800,000 → offset account "4999 - Gain on Revaluation - DSD" → reason "Reappraisal 2026-04" → Save
- Confirm success toast and the asset's carrying value updated to $800,000
- Open the linked JE → verify DR Capital Assets $X / CR Gain on Revaluation $X balanced
- Return to the asset detail → run next monthly depreciation → confirm the new monthly amount is calculated off the adjusted base
Value increase EF001, decrease EF002
GL Capital Assets account adjusted; corresponding gain/loss accounts updated
- Open the bus asset show page (
/account/fixed-assets/{id}) - Verify the meta grid displays Monthly Depreciation calculated as (cost − salvage) / life months:
($720,000 − $60,000) / 144 = $4,583.33 - Click the blue Run Depreciation (This Month) button — confirm the prompt
- Green banner appears:
Depreciation posted for [Month Year] - The Depreciation History table shows one row with the period, posted timestamp, amount $4,583.33, and a JE ID link
- Meta grid updates: Accumulated Depreciation = $4,583.33, Net Book Value = $715,416.67
- 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) - Navigate to
/account/journal-entries— verify a new POSTED JE with referenceDEP-FA-2026-00001, memoDepreciation [Month Year]: EF001 - School Bus, two lines (DR Depreciation Expense / CR Accumulated Depreciation), Frappe voucher populated - 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.
- On the asset show page (with accumulated depreciation of $4,583.33 from FA-033), click the red Dispose button
- Dispose modal opens with current NBV highlighted: $715,416.67
- Disposal Date: today; Proceeds:
800000.00; Cash Account: 1115 - Operating Account - DSDD - Click Dispose & Post JE
- Banner:
Asset disposed and disposal JE posted. - 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
- Navigate to
/account/journal-entries— verify the posted JEDISP-FA-2026-00001with 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 - Index KPIs: Active Assets decrements, Disposed increments
- Acquire a small second asset: $5,000 cost, 60-month life, $0 salvage. Run depreciation once (monthly = $83.33, NBV = $4,916.67)
- Click Dispose, proceeds
3000.00, same cash account, Dispose & Post JE - 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.
✓ 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.
- Go to
/account/ap-po(AP Purchase Orders) - Create a PO to Acme Refrigeration for $28,745 (the CSD NSLP freezer demo) — 3-way match
- Receive goods, match the vendor invoices (deposit $10,000 + final $18,745)
- Once fully invoiced, a new 📊 Capitalize as Fixed Asset button appears in the PO header (green outline)
- 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)
- Complete the useful life, salvage value, and the 4 GL accounts (asset, depreciation expense, accumulated depreciation, cash/offset)
- Click Save & Post Acquisition JE → asset acquired, JE posted to GL + Frappe, redirect to asset show page
- Asset show page now displays a blue banner: "📄 Capitalized from project: Purchase Order #<id> · PO <number> — Acme Refrigeration"
- Click the PO link → drill back to the originating PO
- Asset enters the normal depreciation schedule using the capitalized cost as the basis
⚠ 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.
- At project completion, navigate to
/account/fixed-assets - Click + New Fixed Asset, enter the capitalized cost, useful life, and GL accounts
- 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
- FixedAssetService::acquire posts DR Asset Account / CR CIP Clearing, balanced
- 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.
- Navigate to
/account/fixed-assets, find the old HVAC2 asset - Run depreciation on HVAC2 if not already up to date (optional)
- On HVAC2 show page click Dispose — modal opens with current NBV
- Disposal Date today, Proceeds $0 (retirement), Cash Account any asset account, click Dispose & Post JE
- Status flips to DISPOSED; the old asset is off the balance sheet (DR Accumulated / CR Asset JE posted)
- Click + New Fixed Asset to acquire HVAC2.1 (the replacement)
- Name HVAC2.1, acquisition cost, useful life, GL accounts, create
- Acquire posts DR Asset / CR Cash (or AP) JE for the new capitalized value
- 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.
- For any active fixed asset, Run Depreciation each month of the fiscal year
- The Depreciation History table accumulates 12 entries; Accumulated Depreciation on the show page adds up correctly
- Each run posts DR Depreciation Expense / CR Accumulated Depreciation JE
- Navigate to
/account/finance/reports/profit-loss— verify Total Expenses includes the annual depreciation expense - Navigate to
/account/finance/reports/balance-sheet— verify Accumulated Depreciation shows as a contra-asset offsetting the gross asset value - 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.
- Navigate to
/account/fixed-assetsand verify the KPI cards: Active Assets, Disposed, Gross Book Value, Net Book Value - Open any active asset's show page — verify Cost, Accumulated Depreciation, Net Book Value, and the depreciation history table
- Navigate to
/account/journal-entriesand filter by source type "FixedAsset" or by reference prefix "FA-" / "DEP-" / "DISP-" - Cross-check: the sum of fixed_assets.acquisition_cost − sum of accumulated depreciation should equal the asset account balance on the GL
- FixedAssetService::acquire / postDepreciation / dispose ALL post through the shared JournalEntry pipeline so every FA transaction has a corresponding GL entry
- 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
- 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.
✓ 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.
- Open
/account/fixed-assets→ confirm bus + HVAC rows with acquisition date, cost, and current NBV - On each asset run monthly depreciation (Post Depreciation button) → confirm JE is created and NBV decreases
- Dispose one asset → confirm gain/loss JE
- 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
Generate GASB 34 compliant reports showing bus purchases/retirement and HVAC replacement
Financial statements correctly show asset activity per GASB 34
✓ 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.
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
✓ 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.
Grant 001 & 002: swap $562,500 Local Share with $187,500 Reimbursement
Correcting JE adjusts GL grant revenue accounts; total remains $1,500,000
✓ 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.
Federal fund reimbursements during fiscal year
GL entries support Schedule of Expenditures of Federal Awards
✓ 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.
NSLP equipment ($28,745 freezer) capitalized
GL CIP/Fixed Asset accounts updated from grant project costs
✓ PASSED — Payroll → 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.
- Existing
PayrollJournalEntryServicealready splits paychecks byEmployeeFundAllocationand stampsfund_code+account_codeon everypayroll_journal_entriesrow - Bridge reads every row (multiple rows per paycheck, one per fund split), resolves
account_code→ Frappe account, and groups the debits by resolved account - The single consolidated JE therefore reflects the multi-cost-center allocation without any changes to the existing fund-splitting logic
- Test: run
php artisan db:seed --class="Database\Seeders\PayrollGlAccountMapSeeder", then advance any payroll run togl_posted— observer fires, bridge posts the JE, and line items reflect each fund's contribution
⚠ 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.
- Click + New Journal Entry
- Line 1: DR Salary Expense (Media Specialist cost center), amount 1
- Line 2: DR Salary Expense (Transportation cost center), amount 2
- Line 3: CR Payroll Liability, total
- 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.
✓ PASSED — Payroll → 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.
- Existing
PayrollAccrualService::calculateMonthEndAccruals()creates draftpayroll_accrualsrows (salary/benefit/PTO liability) respecting cost center splits. Untouched. - 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 - 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
- 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
⚠ 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.
- Create a recurring JE template at
/account/recurring-jesfor month-end payroll accrual - 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
- Template lines: DR Salary Expense + DR Compensated Absences Expense / CR Salary Payable + CR Comp Absences Payable
- 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.
✓ PASSED — Payroll → 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.
- Existing
Garnishmentmodel already hasgl_debit_accountandgl_credit_accountfields that get written topayroll_journal_entriesby the existing service - Bridge reads those rows and resolves the garnishment accounts via the mapping table just like any other payroll line
- The mapping seeder includes
2150 → Employer benefits payablecovering garnishment liability (adjust via admin UI if you need a dedicated garnishment payable code) - 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
⚠ 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.
- Click + New Journal Entry each pay period
- DR Salary Expense $25, CR Garnishment Liability $25, memo
IRS garnishment - Samantha Lowell - 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.
✓ PASSED — Payroll → 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.
- Existing
earning_codes.gl_debit_account+deduction_codes.gl_debit_account+.gl_credit_accountcolumns are already populated viaCsdPayrollDemoDataSeeder(codes like6100-100,2200,2130) - New
payroll_gl_account_maptable translates those short codes to full Frappe account names (e.g.6100-100 → 4130 - Charges For Services - DSD) - Admin UI at Finance → Other → Payroll GL Bridge lets users edit mappings without code changes
- Unmapped codes are flagged in the admin UI dashboard with an amber warning so no payroll run can silently fall through
- Test: visit the admin page → verify all ~22 seeded codes have mappings → adjust any to your actual Frappe CoA account names
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
✓ PASSED — Payroll → 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.
- Existing
PayrollRun::isOffCycle()method and status='gl_posted' flow is untouched for off-cycle runs — they transition the same way regular runs do - 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 - The JE reference (
PAYROLL-{run_id}-{check_date}) and memo both include the run type so off-cycle postings are distinguishable in the GL - Test: create an off-cycle run (e.g. September 2025 correction for the demo script bus monitor hours), advance to gl_posted, confirm mirror
⚠ 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.
- Click + New Journal Entry with the effective pay date (not the correction date)
- DR Salary Expense (Bus Monitor cost center) the additional hours amount, CR Salary Payable
- 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.
✓ PASSED — Payroll → 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.
- Existing
PayrollReportController::hoursWagesAlignment()route already produces the alignment report (HR-side hours vs Finance-side pay classifications) - After the mirror, Finance-side totals now exist in the shared
journal_entriestable which is the same table the rest of the GL engine reads for reconciliation - The existing Subsidiary Reconciliation page (GLCL-034) and Inter-fund Reconciliation (PER-012) automatically include mirrored payroll JEs in their variance checks
- Test: open
/account/finance/other/payroll-reports/hours-wages, verify alignment numbers, then cross-check against/account/subsidiary-recon
Verify all hours and wages classifications match between systems
GL accounts match payroll classifications; no orphaned entries
✓ PASSED — Payroll → 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.
- Existing
PtoAccrualService::accrue()createspto_accrual_entriesand updates employee leave quotas. Untouched. - When the payroll run containing those PTO accruals transitions to
gl_posted, the existingPayrollJournalEntryServicegenerates PTO liability rows inpayroll_journal_entries(entry_type='benefit' or 'retirement'), which the bridge then reads and posts to Frappe - Cost center split preserved: the existing fund_code column drives the grouping
- Test: run
PtoAccrualService::accrue()on a period, post payroll, verify the mirrored JE includes PTO liability line(s)
⚠ 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.
- Create a recurring JE template at
/account/recurring-jesfor monthly PTO accrual - Lines split by cost center: one DR line per cost center to Compensated Absences Expense, CR Compensated Absences Payable
- 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.
- Navigate to
/account/encumbrances(sidebar: Finance → Other → Encumbrances) - Click + New Encumbrance — the right side panel slides in
- Confirm the Pre-Encumbrance type card is selected by default (blue outline)
- Date: today; Reference:
REQ-0001; Account: 5310 - Instruction; Amount:12500.00; Vendor:Apex Supplies; Description:Classroom whiteboards - Click Save Encumbrance
- Verify the list shows a new row
PRE-2026-00001with blue PRE-ENC badge, status ACTIVE - Verify the Pre-Encumbered KPI card shows
$12,500.00
- Create a second pre-encumbrance:
REQ-0002, 5216 - Travel Expenses,400.00, vendorDelta Air - On the new row click ⋮ → Release
- Try submitting with an empty reason — the form rejects it
- Enter reason
Trip cancelledand click Release - Verify status flips to grey RELEASED, Reason column shows the text, Pre-Encumbered KPI drops to $0, Released KPI shows
$400.00 - 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.
- Navigate to
/account/encumbrances - Find a pre-encumbrance row in ACTIVE status (e.g.
PRE-2026-00001) - Click the ⋮ kebab menu → Promote to Encumbrance, confirm the prompt
- Verify the type badge flips from blue PRE-ENC to amber ENCUMB
- Verify the number is reissued:
ENC-2026-00001 - Verify KPI cards update: Pre-Encumbered decrements by the amount, Encumbered increments by the same amount
- Verify the success banner:
Promoted to encumbrance: ENC-2026-00001 - 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.
- Navigate to
/account/encumbrances - Find an active encumbrance row (e.g.
ENC-2026-00001) - Click ⋮ → Convert to Expenditure
- The modal shows the expense account and amount pre-filled (read-only)
- Pick Cash / Offset Account: 1115 - Operating Account - DSDD; Posting Date: today; Memo:
Whiteboards delivered - Click Convert & Post JE
- Verify row status flips to blue CONVERTED, and JE / Reason column shows the new JE number (e.g.
JE-2026-000XX) - Verify KPIs: Encumbered drops to $0; Converted increments by the amount
- Navigate to
/account/journal-entries— verify a POSTED JE exists with referenceENC-2026-00001, memoWhiteboards delivered, a populated Frappe voucher name, and two lines (DR expense / CR cash) - Navigate to
/account/general-ledger— verify the two new rows (DR and CR) appear for today's date
- Navigate to
/account/period-close, close the current month's period (e.g. P10 Apr 2026) via the kebab menu - Return to Encumbrances, create a new pre-encumbrance (any account, small amount like $50)
- On the new row click ⋮ → Convert to Expenditure, fill the modal, submit
- Verify the convert is rejected with a red banner:
Accounting period 'P10 — Apr 2026' is closed. Posting is blocked. - Verify the encumbrance row stays ACTIVE (NOT converted) and no JE is created
- 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.
- Create a 3-way Purchase Order (e.g. Dell laptops $10,000) via
/account/procurement/purchase-orders - Receive goods at the end of the month ($10,000) — ApMatchingService::receiveGoods auto-posts DR Expense / CR RNI
- Do NOT match a vendor invoice yet — the liability accrues
- The PO list shows RNI Balance $10,000 in red; the PO row status is RECEIVED
- Navigate to
/account/journal-entries— verify the RNI accrual JE is posted - 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.
- Walk a full PO lifecycle: create PO → receive goods → match invoice
- Each step posts a balanced JE to the GL through the shared JournalEntry pipeline with period-close, frozen-account, and Frappe-post guards
- Verify all three JEs (RNI accrual on receipt, AP liability on invoice match, payment when eventually paid) appear in
/account/journal-entries - Navigate to
/account/general-ledger— all purchasing activity is visible as GL entries with drill-down back to the PO / receipt / invoice records - 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.
✓ 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.
- Create a test encumbrance in the current FY at
/account/encumbrances/createfor $5,000 against a budgeted account - Confirm it appears on
/account/finance/other/budget-controlEncumbered column - Run dry-run:
php artisan gl:budget-carryforward --company=105 --from=2026 --to=2027 --dry-run - See the count of allocations that would carry forward and the total $
- Run without
--dry-run→ new FY budget created in draft with remaining balances - Open-encumbrance records remain in the old FY where they were created
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.
✓ 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.
- Open
/account/ap-purchase-orders/create→ create PO for MARTA, qty 42 passes @ $95 gross, apply 30% vendor contract discount → PO total $2,793 - Receive the passes (goods receipt) → confirm DR Transportation Expense / CR RNI posts
- Match vendor invoice for $2,793 → confirm DR RNI / CR AP posts and clears RNI
- Open
/account/journal-entries→ confirm both JEs exist and are source-stamped to the PO - Alternative: create a manual JE DR Transportation Expense $2,793 / CR AP $2,793 with memo "MARTA contract discount applied (42 × $95 × 70%)"
MARTA invoice: 42 passes × $95 × 70% = $2,793 due 7/2/2025
GL Transportation expense debited; AP credited for discounted amount
✓ PASSED — Benefits & 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.
Samantha Lowell benefit changes: initial enrollment → life event → open enrollment
GL benefit expense accounts updated in sync with payroll deductions
✓ PASSED — Payroll → 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.
- This case is the benefits-side mirror of PAY-050 — both are closed by the same bridge infrastructure
- 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 - Per-cost-center breakdown preserved through
operating_unit/fund_codeon each mirrored line - Test: same as PAY-050. One feature, two test cases.
Sick leave accrual: 1.25 days/month + 34 transferred days
GL Compensated Absences account updated per cost center
✓ 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.
Samantha 6% TRS contribution on gross salary
GL TRS liability and expense accounts updated each pay period