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
311 lines
10 KiB
PHP
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.');
|
|
}
|
|
}
|