You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Currently any user with a_accounts can post (or import) a journal entry with any entry_date — including back-dating into a period that's been signed off / closed. For the guild use case that's tolerable, but it's the most universal "real accounting" feature missing from the Phase 1 GL: once a period is closed, the books for that period must be immutable.
Where the guard lives — important
The lock guard goes in the service, NOT the controller. Specifically inside ledger::create_entry(). This is non-negotiable because:
The CSV import path goes through create_entry() — it inherits the guard for free.
A controller-layer guard would only protect the ACP "New entry" form and silently let CSV import + every external integration violate the lock.
If the implementation wires the check into a controller method, the source-agnostic invariant is broken. The single-line guard belongs in ledger::create_entry(), full stop.
Minimal shape
phpBB already has the right primitives for the smallest possible implementation:
One config key — bbaccounts_period_lock_through (UINT, unix timestamp). Treat 0 as "no lock".
One ACP form field to set/clear it. Lives on the existing Reports or a new "Period control" sub-mode.
One guard in ledger::create_entry() — throw \InvalidArgumentException if entry_date <= bbaccounts_period_lock_through (when lock_through > 0).
That's the whole feature. Sub-100 LOC plus tests.
The non-obvious ripple: reverse_entry()
reverse_entry() currently preserves the original entry_date (correct under "reversal = undo the original transaction" semantics). The moment a period is locked, that conflicts with the lock invariant: reversing a locked-period entry would insert lines into a locked period, which is exactly what the lock is preventing.
Filed as its own child ticket: #91 — reverse_entry() reposts at time() when original is in a locked period. Don't fold it into the main "add lock_through" task — the easy-to-miss bit deserves its own PR slice with explicit tests.
Design fork to pin down
Single rolling lock-through date vs bbaccounts_periods table with arbitrary period rows that can each be locked/reopened independently.
Single-config-key is what every small package actually ships, and it's enough for "officer closes the season after loot is settled." The multi-period table only earns its keep if someone wants to lock 2026 but reopen Q4 2025 for an adjustment — never happens in a guild, common in real businesses. Recommendation: ship the config-key shape; keep the schema-migration path (config key → table) in mind but don't start there.
Acceptance
New config key bbaccounts_period_lock_through (UINT timestamp, default 0).
ACP form to set/clear the lock-through date.
ledger::create_entry() (service layer, not controller) rejects entries with entry_date <= lock_through when lock_through > 0.
CSV import surfaces the rejection per-row in the preview (inherited automatically since it goes through create_entry()).
Service tests covering: rejected post in locked period; allowed post past the lock; allowed post when lock = 0.
Period locking is a prerequisite for #8 (period-end close): you can't roll P&L → retained earnings if the period it draws from can still mutate. The natural Phase 4 ordering is therefore: #89 P&L/BS report shapes (independent, cheapest) → #87 period locking → #91 reverse_entry rule → #88 period-end close. #10 (VAT/invoicing) is independent and lives in its own extension.
Context
Currently any user with
a_accountscan post (or import) a journal entry with anyentry_date— including back-dating into a period that's been signed off / closed. For the guild use case that's tolerable, but it's the most universal "real accounting" feature missing from the Phase 1 GL: once a period is closed, the books for that period must be immutable.Where the guard lives — important
The lock guard goes in the service, NOT the controller. Specifically inside
ledger::create_entry(). This is non-negotiable because:create_entry()— it inherits the guard for free.reference_source) also go throughcreate_entry()and inherit it.If the implementation wires the check into a controller method, the source-agnostic invariant is broken. The single-line guard belongs in
ledger::create_entry(), full stop.Minimal shape
phpBB already has the right primitives for the smallest possible implementation:
bbaccounts_period_lock_through(UINT, unix timestamp). Treat 0 as "no lock".ledger::create_entry()— throw\InvalidArgumentExceptionifentry_date <= bbaccounts_period_lock_through(whenlock_through > 0).That's the whole feature. Sub-100 LOC plus tests.
The non-obvious ripple:
reverse_entry()reverse_entry()currently preserves the originalentry_date(correct under "reversal = undo the original transaction" semantics). The moment a period is locked, that conflicts with the lock invariant: reversing a locked-period entry would insert lines into a locked period, which is exactly what the lock is preventing.Filed as its own child ticket: #91 — reverse_entry() reposts at time() when original is in a locked period. Don't fold it into the main "add lock_through" task — the easy-to-miss bit deserves its own PR slice with explicit tests.
Design fork to pin down
Single rolling lock-through date vs
bbaccounts_periodstable with arbitrary period rows that can each be locked/reopened independently.Single-config-key is what every small package actually ships, and it's enough for "officer closes the season after loot is settled." The multi-period table only earns its keep if someone wants to lock 2026 but reopen Q4 2025 for an adjustment — never happens in a guild, common in real businesses. Recommendation: ship the config-key shape; keep the schema-migration path (config key → table) in mind but don't start there.
Acceptance
bbaccounts_period_lock_through(UINT timestamp, default 0).ledger::create_entry()(service layer, not controller) rejects entries withentry_date <= lock_throughwhenlock_through > 0.create_entry()).reverse_entry()locked-period rule lands separately as Task: reverse_entry() reposts at time() when original is in a locked period #11.Decomposition (rough)
create_entry()— service layer.reverse_entry()rule via Task: reverse_entry() reposts at time() when original is in a locked period #11.Dependency / sequencing note
Period locking is a prerequisite for #8 (period-end close): you can't roll P&L → retained earnings if the period it draws from can still mutate. The natural Phase 4 ordering is therefore: #89 P&L/BS report shapes (independent, cheapest) → #87 period locking → #91 reverse_entry rule → #88 period-end close. #10 (VAT/invoicing) is independent and lives in its own extension.