Commit c95a8dd4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): one split, applied across any scope of revenue

The wizard could only ever rewire one stream at a time, so making a policy
("this is how we split membership money") meant repeating the same work per
stream — 97 streams across 14 categories. The split is now written once and
pushed onto whatever it should govern.

Scopes: this stream, a whole category, a hand-picked set, everything not yet
mapped, or all of it. The screen shows the resolved target list and the count
before anything is written, and each target still gets its own versioned rule
— nothing is shared and nothing is retroactive.

AllocationPlanService is the guard rail. Every save runs resolveTargets ->
validate -> apply, apply() re-validates on its own (there is no FK on
account_id, so an unvalidated write would point rules at accounts that do not
exist), and the whole batch is one transaction — a single bad target rolls
back all of it rather than leaving the chart half-rewired.

Refused before a row is written, all verified against a clone of production:
header accounts, missing/archived accounts, no remainder line, two remainder
lines, percentages over 100, zero and negative values, a line type that does
not match its account's type, an expense line on a collection, mixing inflow
and outflow streams in one scope, an effective date inside a closed period,
a deferred line with no recognition account, and a stage the category cannot
produce (skipped with a reason rather than written).

Also:
- Direction now drives which line types exist at all, and is editable per
  stream from the wizard. Phase_106_001 corrects 11 payroll/procurement
  streams seeded as 'inflow' — a salary expense and a supplier payment are
  debits, and the screen was offering revenue accounts for them.
- parseLines() accepted 5 of the 12 line types the schema allows, which made
  outflow streams impossible to configure at all. It now accepts all of them.
- Deferred revenue is configurable from the wizard (months + recognition
  account, filtered to revenue accounts).
- The entry preview inverts for outflow: split lines debit, cash credits.

Arithmetic verified against RevenueAllocator at 0.03, 1.00, 150,000.00 and
999,999.99 EGP, and with 14% inclusive VAT — balanced in every case.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 00dfe062
......@@ -161,9 +161,12 @@ return [
['GET', '/accounting/revenue-mapping/search-accounts', 'Accounting\Controllers\RevenueMappingController@searchAccounts', ['auth'], 'accounting.revenue_mapping.view'],
['GET', '/accounting/revenue-mapping/parent-accounts', 'Accounting\Controllers\RevenueMappingController@parentAccounts', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/create-account', 'Accounting\Controllers\RevenueMappingController@createAccount', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/plan-targets', 'Accounting\Controllers\RevenueMappingController@planTargets', ['auth'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/apply', 'Accounting\Controllers\RevenueMappingController@applyPlan', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/simulate', 'Accounting\Controllers\RevenueMappingController@simulate', ['auth', 'csrf'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/sync', 'Accounting\Controllers\RevenueMappingController@sync', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/{id:\d+}/wizard', 'Accounting\Controllers\RevenueMappingController@wizard', ['auth'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/{id:\d+}/direction', 'Accounting\Controllers\RevenueMappingController@setDirection', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@update', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
......
......@@ -26,6 +26,10 @@ $stageColors = [
</p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<?php if (can('accounting.revenue_mapping.manage') && !empty($streams)): ?>
<a href="/accounting/revenue-mapping/<?= (int) $streams[0]['id'] ?>/wizard?scope=category&category=<?= e($streams[0]['category']) ?>"
class="btn btn-primary">وزّع على مجموعة</a>
<?php endif; ?>
<a href="/accounting/revenue-mapping/diagnostics" class="btn btn-outline">فحص الحالة</a>
<a href="/accounting/revenue-mapping/recognition" class="btn btn-outline">الإيراد المؤجل</a>
<a href="/accounting/revenue-mapping/tax-profiles" class="btn btn-outline">الملفات الضريبية</a>
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Every payroll and procurement stream was seeded with default_direction =
* 'inflow', which is the wrong side of the entry for most of them. A salary
* expense, a supplier payment, a depreciation charge and a staff loan are all
* money going OUT — their allocation lines are debits, not credits.
*
* Direction drives which line types the allocation wizard offers and what it
* validates against, so a wrong direction here means the screen offers revenue
* accounts for a payroll run. The streams left alone are the genuinely
* credit-side ones: tax withheld, insurance payable, deductions, and the
* supplier payable itself.
*
* Idempotent — re-running it changes nothing.
*/
return static function (Database $db): void {
$outflow = [
// Procurement / inventory — value coming in as an asset, cash going out.
'procurement:inventory_receipt',
'procurement:input_tax',
'procurement:cash_out',
'inventory:goods_receipt',
'inventory:depreciation',
// Payroll — cost incurred and cash paid.
'payroll:gross_salary',
'payroll:employer_insurance',
'payroll:net_paid',
'hr:loan_disbursement',
'hr:end_of_service',
'hr:coach_payment',
];
$placeholders = implode(',', array_fill(0, count($outflow), '?'));
$db->query(
"UPDATE revenue_streams
SET default_direction = 'outflow', updated_at = NOW()
WHERE stream_code IN ({$placeholders})
AND default_direction <> 'outflow'",
$outflow
);
// Rules already written against these streams carry the same wrong
// direction. Only touch the ones still active and still on the default —
// a rule someone deliberately set to inflow on an outflow stream is left
// alone rather than silently flipped underneath them.
$db->query(
"UPDATE revenue_posting_rules r
JOIN revenue_streams s ON s.id = r.stream_id
SET r.direction = 'outflow', r.updated_at = NOW()
WHERE s.stream_code IN ({$placeholders})
AND r.status = 'active'
AND r.direction = 'inflow'
AND r.stage IN ('payment', 'refund', 'writeoff')",
$outflow
);
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment