Files
hub/app/Http/Controllers/Seller/Management/BankReconciliationController.php
kelly 6d64d9527a feat: implement Management Suite core features
Bank Account Management:
- BankAccountsController with full CRUD operations
- BankAccountService for account management logic
- Bank account views (index, create, edit)
- GL account linking for cash accounts

Bank Transfers:
- BankTransfersController with approval workflow
- BankTransfer model with status management
- Inter-business transfer support
- Transfer views (index, create, show)

Plaid Integration Infrastructure:
- PlaidItem, PlaidAccount, PlaidTransaction models
- PlaidIntegrationService with 10+ methods (stubbed API)
- BankReconciliationController with match/learn flows
- Bank match rules for auto-categorization

Journal Entry Automation:
- JournalEntryService for automatic JE creation
- Bill approval creates expense entries
- Payment completion creates cash entries
- Inter-company Due To/Due From entries

AR Enhancements:
- ArService with getArSummary and getTopArAccounts
- Finance dashboard AR section with drill-down stats
- Credit hold and at-risk tracking
- Top AR accounts table with division column

UI/Navigation:
- Updated SuiteMenuResolver with new menu items
- Removed Usage & Billing from sidebar (moved to Owner)
- Brand Manager Suite menu items added
- Vendors page shows divisions using each vendor

Models and Migrations:
- BankAccount, BankTransfer, BankMatchRule models
- PlaidItem, PlaidAccount, PlaidTransaction models
- Credit hold fields on ArCustomer
- journal_entry_id on ap_bills and ap_payments
2025-12-07 00:34:53 -07:00

311 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApPayment;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\BankMatchRule;
use App\Models\Accounting\JournalEntry;
use App\Models\Accounting\PlaidAccount;
use App\Models\Accounting\PlaidTransaction;
use App\Models\Business;
use App\Services\Accounting\BankReconciliationService;
use App\Services\Accounting\PlaidIntegrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankReconciliationController extends Controller
{
public function __construct(
protected BankReconciliationService $reconciliationService,
protected PlaidIntegrationService $plaidService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the reconciliation dashboard for a bank account.
*/
public function show(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$summary = $this->reconciliationService->getReconciliationSummary($bankAccount);
$unmatchedTransactions = $this->reconciliationService->getUnmatchedTransactions($bankAccount);
$proposedMatches = $this->reconciliationService->getProposedAutoMatches($bankAccount);
return view('seller.management.bank-accounts.reconciliation', [
'business' => $business,
'account' => $bankAccount,
'summary' => $summary,
'unmatchedTransactions' => $unmatchedTransactions,
'proposedMatches' => $proposedMatches,
]);
}
/**
* Sync transactions from Plaid.
*/
public function syncTransactions(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$sinceDate = $request->input('since_date')
? new \DateTime($request->input('since_date'))
: now()->subDays(30);
$synced = $this->plaidService->syncTransactions($business, $sinceDate);
// Run auto-matching
$matched = $this->reconciliationService->runAutoMatching($bankAccount);
return redirect()
->back()
->with('success', "Synced {$synced} transactions. {$matched} proposed auto-matches found.");
}
/**
* Find potential matches for a transaction (AJAX).
*/
public function findMatches(Request $request, Business $business, BankAccount $bankAccount, PlaidTransaction $transaction): JsonResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$matches = $this->reconciliationService->findPotentialMatches($transaction, $business);
return response()->json([
'ap_payments' => $matches['ap_payments']->map(fn ($p) => [
'id' => $p->id,
'type' => 'ap_payment',
'label' => "AP Payment #{$p->id} - ".($p->bill?->vendor?->name ?? 'Unknown'),
'amount' => $p->amount,
'date' => $p->payment_date->format('Y-m-d'),
]),
'journal_entries' => $matches['journal_entries']->map(fn ($je) => [
'id' => $je->id,
'type' => 'journal_entry',
'label' => "JE #{$je->entry_number} - {$je->memo}",
'date' => $je->entry_date->format('Y-m-d'),
]),
]);
}
/**
* Match a transaction to an AP payment.
*/
public function matchToApPayment(
Request $request,
Business $business,
BankAccount $bankAccount,
PlaidTransaction $transaction
): RedirectResponse {
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'ap_payment_id' => 'required|exists:ap_payments,id',
]);
$payment = ApPayment::findOrFail($validated['ap_payment_id']);
$this->reconciliationService->matchToApPayment($transaction, $payment, auth()->user());
return redirect()
->back()
->with('success', 'Transaction matched to AP payment successfully.');
}
/**
* Match a transaction to a journal entry.
*/
public function matchToJournalEntry(
Request $request,
Business $business,
BankAccount $bankAccount,
PlaidTransaction $transaction
): RedirectResponse {
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'journal_entry_id' => 'required|exists:journal_entries,id',
]);
$entry = JournalEntry::findOrFail($validated['journal_entry_id']);
$this->reconciliationService->matchToJournalEntry($transaction, $entry, auth()->user());
return redirect()
->back()
->with('success', 'Transaction matched to journal entry successfully.');
}
/**
* Confirm selected auto-matches.
*/
public function confirmAutoMatches(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'transaction_ids' => 'required|array',
'transaction_ids.*' => 'exists:plaid_transactions,id',
]);
$confirmed = $this->reconciliationService->confirmAutoMatches(
$validated['transaction_ids'],
auth()->user()
);
return redirect()
->back()
->with('success', "Confirmed {$confirmed} auto-matched transactions.");
}
/**
* Reject selected auto-matches.
*/
public function rejectAutoMatches(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'transaction_ids' => 'required|array',
'transaction_ids.*' => 'exists:plaid_transactions,id',
]);
$rejected = $this->reconciliationService->rejectAutoMatches(
$validated['transaction_ids'],
auth()->user()
);
return redirect()
->back()
->with('success', "Rejected {$rejected} auto-matched transactions.");
}
/**
* Ignore selected transactions.
*/
public function ignoreTransactions(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'transaction_ids' => 'required|array',
'transaction_ids.*' => 'exists:plaid_transactions,id',
]);
$ignored = $this->reconciliationService->ignoreTransactions($validated['transaction_ids']);
return redirect()
->back()
->with('success', "Ignored {$ignored} transactions.");
}
/**
* Display match rules for a bank account.
*/
public function matchRules(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$rules = $this->reconciliationService->getMatchRules($bankAccount);
$eligibleRules = $this->reconciliationService->getEligibleRules($bankAccount);
return view('seller.management.bank-accounts.match-rules', [
'business' => $business,
'account' => $bankAccount,
'rules' => $rules,
'eligibleRules' => $eligibleRules,
]);
}
/**
* Toggle auto-enable for a match rule.
*/
public function toggleRuleAutoEnable(
Request $request,
Business $business,
BankAccount $bankAccount,
BankMatchRule $rule
): RedirectResponse {
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
if ($rule->bank_account_id !== $bankAccount->id) {
abort(403, 'Access denied.');
}
$enabled = $request->boolean('enabled');
try {
$this->reconciliationService->toggleRuleAutoEnable($rule, $enabled);
$status = $enabled ? 'enabled' : 'disabled';
return redirect()
->back()
->with('success', "Auto-matching {$status} for rule: {$rule->pattern_name}");
} catch (\Exception $e) {
return redirect()
->back()
->with('error', $e->getMessage());
}
}
/**
* Link a Plaid account to a bank account.
*/
public function linkPlaidAccount(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'plaid_account_id' => 'required|exists:plaid_accounts,id',
]);
$plaidAccount = PlaidAccount::findOrFail($validated['plaid_account_id']);
$this->plaidService->linkPlaidAccountToBankAccount($plaidAccount, $bankAccount);
return redirect()
->back()
->with('success', 'Plaid account linked successfully.');
}
/**
* Authorize access to a bank account.
*/
private function authorizeAccountAccess(Business $business, BankAccount $bankAccount): void
{
// Allow access if account belongs to this business or a child business
if ($bankAccount->business_id === $business->id) {
return;
}
if ($business->isParentCompany()) {
$childIds = $business->divisions()->pluck('id')->toArray();
if (in_array($bankAccount->business_id, $childIds)) {
return;
}
}
abort(403, 'Access denied.');
}
}