From 6d64d9527a6c0bad1d310d08fbddbc7e82ced02c Mon Sep 17 00:00:00 2001 From: kelly Date: Sun, 7 Dec 2025 00:34:53 -0700 Subject: [PATCH] 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 --- .../Seller/Crm/QuoteController.php | 27 +- .../Seller/Management/AnalyticsController.php | 119 ++++ .../Seller/Management/ApBillsController.php | 11 +- .../Seller/Management/ApVendorsController.php | 40 +- .../Seller/Management/ArController.php | 135 ++++- .../Management/BankAccountsController.php | 207 +++++++ .../BankReconciliationController.php | 310 ++++++++++ .../Management/BankTransfersController.php | 185 ++++++ .../Management/DirectoryVendorsController.php | 24 + .../Seller/Management/FinanceController.php | 26 +- .../Management/ForecastingController.php | 201 +++++++ .../InventoryValuationController.php | 61 ++ .../Management/OperationsController.php | 136 +++++ .../Management/UsageBillingController.php | 193 ++++++ app/Models/Accounting/ArCustomer.php | 69 +++ app/Models/Accounting/BankAccount.php | 139 +++++ app/Models/Accounting/BankMatchRule.php | 284 +++++++++ app/Models/Accounting/BankTransfer.php | 132 +++++ app/Models/Accounting/PlaidAccount.php | 171 ++++++ app/Models/Accounting/PlaidItem.php | 141 +++++ app/Models/Accounting/PlaidTransaction.php | 328 +++++++++++ app/Services/Accounting/ArService.php | 504 ++++++++++++++++ .../Accounting/BankAccountService.php | 233 ++++++++ .../Accounting/BankReconciliationService.php | 382 ++++++++++++ app/Services/Accounting/BillService.php | 34 +- .../Accounting/FinanceAnalyticsService.php | 43 +- .../Accounting/InventoryValuationService.php | 300 ++++++++++ .../Accounting/JournalEntryService.php | 554 ++++++++++++++++++ app/Services/Accounting/PaymentService.php | 36 +- .../Accounting/PlaidIntegrationService.php | 369 ++++++++++++ app/Services/BrandAccessService.php | 247 ++++++++ app/Services/SuiteMenuResolver.php | 94 +++ config/suites.php | 25 +- ...2_06_200001_create_bank_accounts_table.php | 47 ++ ..._06_200002_create_bank_transfers_table.php | 43 ++ ..._12_06_220001_create_plaid_items_table.php | 39 ++ ..._06_220002_create_plaid_accounts_table.php | 50 ++ ...220003_create_plaid_transactions_table.php | 62 ++ ...6_220004_create_bank_match_rules_table.php | 72 +++ ...add_credit_hold_fields_to_ar_customers.php | 39 ++ ...00_add_linked_business_to_ar_customers.php | 35 ++ ...0000_add_journal_entry_id_to_ap_tables.php | 57 ++ .../management/analytics/index.blade.php | 163 ++++++ .../management/ap/vendors/index.blade.php | 18 +- .../management/ar/account-detail.blade.php | 343 +++++++++++ .../seller/management/ar/accounts.blade.php | 163 ++++++ .../management/bank-accounts/create.blade.php | 174 ++++++ .../management/bank-accounts/edit.blade.php | 171 ++++++ .../management/bank-accounts/index.blade.php | 148 +++++ .../bank-accounts/match-rules.blade.php | 156 +++++ .../bank-accounts/reconciliation.blade.php | 291 +++++++++ .../management/bank-accounts/show.blade.php | 188 ++++++ .../bank-transfers/create.blade.php | 135 +++++ .../management/bank-transfers/index.blade.php | 133 +++++ .../management/bank-transfers/show.blade.php | 161 +++++ .../directory/vendors/index.blade.php | 21 +- .../management/finance/divisions.blade.php | 329 +++++++---- .../seller/management/finance/index.blade.php | 195 +++++- .../management/forecasting/index.blade.php | 168 ++++++ .../inventory-valuation/index.blade.php | 371 ++++++++++++ .../management/operations/index.blade.php | 211 +++++++ .../management/usage-billing/index.blade.php | 218 +++++++ routes/seller.php | 79 +++ 63 files changed, 9872 insertions(+), 168 deletions(-) create mode 100644 app/Http/Controllers/Seller/Management/AnalyticsController.php create mode 100644 app/Http/Controllers/Seller/Management/BankAccountsController.php create mode 100644 app/Http/Controllers/Seller/Management/BankReconciliationController.php create mode 100644 app/Http/Controllers/Seller/Management/BankTransfersController.php create mode 100644 app/Http/Controllers/Seller/Management/ForecastingController.php create mode 100644 app/Http/Controllers/Seller/Management/InventoryValuationController.php create mode 100644 app/Http/Controllers/Seller/Management/OperationsController.php create mode 100644 app/Http/Controllers/Seller/Management/UsageBillingController.php create mode 100644 app/Models/Accounting/BankAccount.php create mode 100644 app/Models/Accounting/BankMatchRule.php create mode 100644 app/Models/Accounting/BankTransfer.php create mode 100644 app/Models/Accounting/PlaidAccount.php create mode 100644 app/Models/Accounting/PlaidItem.php create mode 100644 app/Models/Accounting/PlaidTransaction.php create mode 100644 app/Services/Accounting/ArService.php create mode 100644 app/Services/Accounting/BankAccountService.php create mode 100644 app/Services/Accounting/BankReconciliationService.php create mode 100644 app/Services/Accounting/InventoryValuationService.php create mode 100644 app/Services/Accounting/JournalEntryService.php create mode 100644 app/Services/Accounting/PlaidIntegrationService.php create mode 100644 app/Services/BrandAccessService.php create mode 100644 database/migrations/2025_12_06_200001_create_bank_accounts_table.php create mode 100644 database/migrations/2025_12_06_200002_create_bank_transfers_table.php create mode 100644 database/migrations/2025_12_06_220001_create_plaid_items_table.php create mode 100644 database/migrations/2025_12_06_220002_create_plaid_accounts_table.php create mode 100644 database/migrations/2025_12_06_220003_create_plaid_transactions_table.php create mode 100644 database/migrations/2025_12_06_220004_create_bank_match_rules_table.php create mode 100644 database/migrations/2025_12_07_010000_add_credit_hold_fields_to_ar_customers.php create mode 100644 database/migrations/2025_12_07_020000_add_linked_business_to_ar_customers.php create mode 100644 database/migrations/2025_12_07_100000_add_journal_entry_id_to_ap_tables.php create mode 100644 resources/views/seller/management/analytics/index.blade.php create mode 100644 resources/views/seller/management/ar/account-detail.blade.php create mode 100644 resources/views/seller/management/ar/accounts.blade.php create mode 100644 resources/views/seller/management/bank-accounts/create.blade.php create mode 100644 resources/views/seller/management/bank-accounts/edit.blade.php create mode 100644 resources/views/seller/management/bank-accounts/index.blade.php create mode 100644 resources/views/seller/management/bank-accounts/match-rules.blade.php create mode 100644 resources/views/seller/management/bank-accounts/reconciliation.blade.php create mode 100644 resources/views/seller/management/bank-accounts/show.blade.php create mode 100644 resources/views/seller/management/bank-transfers/create.blade.php create mode 100644 resources/views/seller/management/bank-transfers/index.blade.php create mode 100644 resources/views/seller/management/bank-transfers/show.blade.php create mode 100644 resources/views/seller/management/forecasting/index.blade.php create mode 100644 resources/views/seller/management/inventory-valuation/index.blade.php create mode 100644 resources/views/seller/management/operations/index.blade.php create mode 100644 resources/views/seller/management/usage-billing/index.blade.php diff --git a/app/Http/Controllers/Seller/Crm/QuoteController.php b/app/Http/Controllers/Seller/Crm/QuoteController.php index e80fe329..68ac08ed 100644 --- a/app/Http/Controllers/Seller/Crm/QuoteController.php +++ b/app/Http/Controllers/Seller/Crm/QuoteController.php @@ -9,6 +9,7 @@ use App\Models\Crm\CrmDeal; use App\Models\Crm\CrmQuote; use App\Models\Crm\CrmQuoteItem; use App\Models\Product; +use App\Services\Accounting\ArService; use Illuminate\Http\Request; class QuoteController extends Controller @@ -261,7 +262,7 @@ class QuoteController extends Controller /** * Convert quote to invoice */ - public function convertToInvoice(Request $request, Business $business, CrmQuote $quote) + public function convertToInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService) { if ($quote->business_id !== $business->id) { abort(404); @@ -275,6 +276,30 @@ class QuoteController extends Controller return back()->withErrors(['error' => 'This quote already has an invoice.']); } + // Credit check enforcement - only if there's an account (buyer business) + if ($quote->account_id) { + $buyerBusiness = Business::find($quote->account_id); + + if ($buyerBusiness) { + $creditCheck = $arService->checkCreditForAccount( + $business, + $buyerBusiness, + (float) $quote->total + ); + + if (! $creditCheck['can_extend']) { + return back()->withErrors([ + 'error' => 'Cannot create invoice: ' . $creditCheck['reason'], + ]); + } + + // Store warning in session if present + if (! empty($creditCheck['details']['warning'])) { + session()->flash('warning', $creditCheck['details']['warning']); + } + } + } + $invoice = $quote->convertToInvoice(); return redirect()->route('seller.crm.invoices.show', $invoice) diff --git a/app/Http/Controllers/Seller/Management/AnalyticsController.php b/app/Http/Controllers/Seller/Management/AnalyticsController.php new file mode 100644 index 00000000..f92150ce --- /dev/null +++ b/app/Http/Controllers/Seller/Management/AnalyticsController.php @@ -0,0 +1,119 @@ +requireManagementSuite($business); + + $divisions = $this->getChildDivisionsIfAny($business); + $selectedDivision = $this->getSelectedDivision($request, $business); + $includeChildren = $this->shouldIncludeChildren($request); + + // Determine which businesses to aggregate + $businessIds = $this->getBusinessIdsForScope($business, $selectedDivision, $includeChildren); + + // Collect analytics data across all businesses + $analytics = $this->collectAnalytics($businessIds); + + return view('seller.management.analytics.index', [ + 'business' => $business, + 'divisions' => $divisions, + 'selectedDivision' => $selectedDivision, + 'includeChildren' => $includeChildren, + 'analytics' => $analytics, + ]); + } + + protected function collectAnalytics(array $businessIds): array + { + // Revenue by division + $revenueByDivision = \DB::table('orders') + ->join('businesses', 'orders.business_id', '=', 'businesses.id') + ->whereIn('orders.business_id', $businessIds) + ->where('orders.status', 'completed') + ->select( + 'businesses.name as division_name', + \DB::raw('SUM(orders.total) as total_revenue'), + \DB::raw('COUNT(orders.id) as order_count') + ) + ->groupBy('businesses.id', 'businesses.name') + ->orderByDesc('total_revenue') + ->get(); + + // Expenses by division + $expensesByDivision = \DB::table('ap_bills') + ->join('businesses', 'ap_bills.business_id', '=', 'businesses.id') + ->whereIn('ap_bills.business_id', $businessIds) + ->whereIn('ap_bills.status', ['approved', 'paid']) + ->select( + 'businesses.name as division_name', + \DB::raw('SUM(ap_bills.total_amount) as total_expenses'), + \DB::raw('COUNT(ap_bills.id) as bill_count') + ) + ->groupBy('businesses.id', 'businesses.name') + ->orderByDesc('total_expenses') + ->get(); + + // AR totals by division + $arByDivision = \DB::table('invoices') + ->join('businesses', 'invoices.business_id', '=', 'businesses.id') + ->whereIn('invoices.business_id', $businessIds) + ->whereIn('invoices.status', ['sent', 'partial', 'overdue']) + ->select( + 'businesses.name as division_name', + \DB::raw('SUM(invoices.total) as total_ar'), + \DB::raw('SUM(invoices.balance_due) as outstanding_ar') + ) + ->groupBy('businesses.id', 'businesses.name') + ->orderByDesc('outstanding_ar') + ->get(); + + // Calculate totals + $totalRevenue = $revenueByDivision->sum('total_revenue'); + $totalExpenses = $expensesByDivision->sum('total_expenses'); + $totalAr = $arByDivision->sum('outstanding_ar'); + + return [ + 'revenue_by_division' => $revenueByDivision, + 'expenses_by_division' => $expensesByDivision, + 'ar_by_division' => $arByDivision, + 'totals' => [ + 'revenue' => $totalRevenue, + 'expenses' => $totalExpenses, + 'net_income' => $totalRevenue - $totalExpenses, + 'outstanding_ar' => $totalAr, + ], + ]; + } + + protected function getBusinessIdsForScope(Business $business, ?Business $selectedDivision, bool $includeChildren): array + { + if ($selectedDivision) { + if ($includeChildren) { + return $selectedDivision->divisions()->pluck('id') + ->prepend($selectedDivision->id) + ->toArray(); + } + + return [$selectedDivision->id]; + } + + if ($includeChildren && $business->hasChildBusinesses()) { + return $business->divisions()->pluck('id') + ->prepend($business->id) + ->toArray(); + } + + return [$business->id]; + } +} diff --git a/app/Http/Controllers/Seller/Management/ApBillsController.php b/app/Http/Controllers/Seller/Management/ApBillsController.php index e3eca475..f60d763f 100644 --- a/app/Http/Controllers/Seller/Management/ApBillsController.php +++ b/app/Http/Controllers/Seller/Management/ApBillsController.php @@ -15,6 +15,7 @@ use App\Services\Accounting\BillService; use App\Services\Accounting\PaymentService; use App\Support\ManagementDivisionFilter; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; class ApBillsController extends Controller { @@ -187,21 +188,21 @@ class ApBillsController extends Controller public function store(Request $request, Business $business) { $validated = $request->validate([ - 'vendor_id' => 'required|integer|exists:ap_vendors,id', + 'vendor_id' => ['required', 'integer', Rule::exists('ap_vendors', 'id')->where('business_id', $business->id)], 'vendor_invoice_number' => 'required|string|max:100', 'bill_date' => 'required|date', 'due_date' => 'required|date|after_or_equal:bill_date', 'payment_terms' => 'nullable|integer|min:0', - 'department_id' => 'nullable|integer|exists:departments,id', + 'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')->where('business_id', $business->id)], 'tax_amount' => 'nullable|numeric|min:0', 'notes' => 'nullable|string|max:1000', 'items' => 'required|array|min:1', 'items.*.description' => 'required|string|max:255', 'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.unit_price' => 'required|numeric|min:0', - 'items.*.gl_account_id' => 'required|integer|exists:gl_accounts,id', - 'items.*.department_id' => 'nullable|integer|exists:departments,id', - 'purchase_order_id' => 'nullable|integer|exists:purchase_orders,id', + 'items.*.gl_account_id' => ['required', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)], + 'items.*.department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')->where('business_id', $business->id)], + 'purchase_order_id' => ['nullable', 'integer', Rule::exists('purchase_orders', 'id')->where('business_id', $business->id)], ]); try { diff --git a/app/Http/Controllers/Seller/Management/ApVendorsController.php b/app/Http/Controllers/Seller/Management/ApVendorsController.php index 3cdaf5c8..89f8c034 100644 --- a/app/Http/Controllers/Seller/Management/ApVendorsController.php +++ b/app/Http/Controllers/Seller/Management/ApVendorsController.php @@ -10,6 +10,7 @@ use App\Models\Accounting\GlAccount; use App\Models\Business; use App\Support\ManagementDivisionFilter; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; class ApVendorsController extends Controller { @@ -23,6 +24,7 @@ class ApVendorsController extends Controller public function index(Request $request, Business $business) { $filterData = $this->getDivisionFilterData($business, $request); + $isParent = $business->parent_id === null && Business::where('parent_id', $business->id)->exists(); $query = ApVendor::whereIn('business_id', $filterData['business_ids']) ->with('business') @@ -45,6 +47,39 @@ class ApVendorsController extends Controller $vendors = $query->orderBy('name')->paginate(20)->withQueryString(); + // For parent business, compute which child divisions use each vendor + if ($isParent) { + $childBusinessIds = Business::where('parent_id', $business->id)->pluck('id')->toArray(); + $childBusinesses = Business::whereIn('id', $childBusinessIds)->get()->keyBy('id'); + + $vendors->getCollection()->transform(function ($vendor) use ($childBusinessIds, $childBusinesses) { + // Get divisions that have bills or POs with this vendor + $divisionsUsingVendor = collect(); + + // Check if vendor belongs to a child directly + if (in_array($vendor->business_id, $childBusinessIds)) { + $divisionsUsingVendor->push($childBusinesses[$vendor->business_id] ?? null); + } + + // Check for bills from other children using this vendor + $billBusinessIds = $vendor->bills() + ->whereIn('business_id', $childBusinessIds) + ->distinct() + ->pluck('business_id') + ->toArray(); + + foreach ($billBusinessIds as $bizId) { + if (! $divisionsUsingVendor->contains('id', $bizId) && isset($childBusinesses[$bizId])) { + $divisionsUsingVendor->push($childBusinesses[$bizId]); + } + } + + $vendor->divisions_using = $divisionsUsingVendor->filter()->unique('id')->values(); + + return $vendor; + }); + } + // Get GL accounts for default expense account dropdown $glAccounts = GlAccount::where('business_id', $business->id) ->where('is_active', true) @@ -57,6 +92,7 @@ class ApVendorsController extends Controller 'business' => $business, 'vendors' => $vendors, 'glAccounts' => $glAccounts, + 'isParent' => $isParent, ], $filterData)); } @@ -73,7 +109,7 @@ class ApVendorsController extends Controller 'legal_name' => 'nullable|string|max:255', 'tax_id' => 'nullable|string|max:50', 'default_payment_terms' => 'nullable|integer|min:0', - 'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id', + 'default_gl_account_id' => ['nullable', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)], 'contact_name' => 'nullable|string|max:255', 'contact_email' => 'nullable|email|max:255', 'contact_phone' => 'nullable|string|max:50', @@ -220,7 +256,7 @@ class ApVendorsController extends Controller 'legal_name' => 'nullable|string|max:255', 'tax_id' => 'nullable|string|max:50', 'default_payment_terms' => 'nullable|integer|min:0', - 'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id', + 'default_gl_account_id' => ['nullable', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)], 'contact_name' => 'nullable|string|max:255', 'contact_email' => 'nullable|email|max:255', 'contact_phone' => 'nullable|string|max:50', diff --git a/app/Http/Controllers/Seller/Management/ArController.php b/app/Http/Controllers/Seller/Management/ArController.php index e144701f..4c83adf5 100644 --- a/app/Http/Controllers/Seller/Management/ArController.php +++ b/app/Http/Controllers/Seller/Management/ArController.php @@ -5,8 +5,11 @@ declare(strict_types=1); namespace App\Http\Controllers\Seller\Management; use App\Http\Controllers\Controller; +use App\Models\Accounting\ArCustomer; use App\Models\Business; use App\Services\Accounting\ArAnalyticsService; +use App\Services\Accounting\ArService; +use App\Services\Accounting\CustomerFinancialService; use App\Support\ManagementDivisionFilter; use Illuminate\Http\Request; @@ -15,7 +18,9 @@ class ArController extends Controller use ManagementDivisionFilter; public function __construct( - protected ArAnalyticsService $analyticsService + protected ArAnalyticsService $analyticsService, + protected ArService $arService, + protected CustomerFinancialService $customerService ) {} /** @@ -48,11 +53,139 @@ class ArController extends Controller $byDivision = $this->analyticsService->getARBreakdownByDivision($business, $filterData['business_ids']); $byCustomer = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 10); + // Check for bucket filter from drill-down + $bucket = $request->get('bucket'); + return view('seller.management.ar.aging', $this->withDivisionFilter([ 'business' => $business, 'aging' => $aging, 'byDivision' => $byDivision, 'byCustomer' => $byCustomer, + 'activeBucket' => $bucket, ], $filterData)); } + + /** + * AR Accounts list page. + */ + public function accounts(Request $request, Business $business) + { + $filterData = $this->getDivisionFilterData($business, $request); + + $filters = [ + 'on_hold' => $request->boolean('on_hold'), + 'at_risk' => $request->boolean('at_risk'), + 'search' => $request->get('search'), + ]; + + $accounts = $this->arService->getAccountsWithBalances( + $business, + $filterData['business_ids'], + $filters + ); + + $metrics = $this->analyticsService->getARMetrics($business, $filterData['business_ids']); + + return view('seller.management.ar.accounts', $this->withDivisionFilter([ + 'business' => $business, + 'accounts' => $accounts, + 'metrics' => $metrics, + 'filters' => $filters, + ], $filterData)); + } + + /** + * Single account detail page. + */ + public function showAccount(Request $request, Business $business, ArCustomer $customer) + { + // Verify customer belongs to this business or a child + $isParent = $this->arService->isParentCompany($business); + $allowedBusinessIds = $isParent + ? $this->arService->getBusinessIdsWithChildren($business) + : [$business->id]; + + if (! in_array($customer->business_id, $allowedBusinessIds)) { + abort(404); + } + + $summary = $this->customerService->getFinancialSummary($customer, $business, $isParent); + $invoices = $this->customerService->getInvoices($customer, $business, $isParent); + $payments = $this->customerService->getPayments($customer, $business, $isParent); + $activities = $this->customerService->getRecentActivity($customer, $business); + + return view('seller.management.ar.account-detail', [ + 'business' => $business, + 'customer' => $customer, + 'summary' => $summary, + 'invoices' => $invoices, + 'payments' => $payments, + 'activities' => $activities, + 'isParent' => $isParent, + ]); + } + + /** + * Update credit limit (Management only). + */ + public function updateCreditLimit(Request $request, Business $business, ArCustomer $customer) + { + $request->validate([ + 'credit_limit' => 'required|numeric|min:0', + ]); + + $this->arService->updateCreditLimit( + $customer, + (float) $request->input('credit_limit'), + auth()->id() + ); + + return back()->with('success', 'Credit limit updated successfully.'); + } + + /** + * Update payment terms (Management only). + */ + public function updateTerms(Request $request, Business $business, ArCustomer $customer) + { + $request->validate([ + 'payment_terms' => 'required|string', + ]); + + $this->arService->updatePaymentTerms( + $customer, + $request->input('payment_terms'), + auth()->id() + ); + + return back()->with('success', 'Payment terms updated successfully.'); + } + + /** + * Place credit hold (Management only). + */ + public function placeHold(Request $request, Business $business, ArCustomer $customer) + { + $request->validate([ + 'reason' => 'required|string|max:500', + ]); + + $this->arService->placeCreditHold( + $customer, + $request->input('reason'), + auth()->id() + ); + + return back()->with('success', 'Credit hold placed successfully.'); + } + + /** + * Remove credit hold (Management only). + */ + public function removeHold(Request $request, Business $business, ArCustomer $customer) + { + $this->arService->removeCreditHold($customer, auth()->id()); + + return back()->with('success', 'Credit hold removed successfully.'); + } } diff --git a/app/Http/Controllers/Seller/Management/BankAccountsController.php b/app/Http/Controllers/Seller/Management/BankAccountsController.php new file mode 100644 index 00000000..9383daf4 --- /dev/null +++ b/app/Http/Controllers/Seller/Management/BankAccountsController.php @@ -0,0 +1,207 @@ +hasManagementSuite()) { + abort(403, 'Management Suite access required.'); + } + } + + /** + * Display the list of bank accounts. + */ + public function index(Request $request, Business $business): View + { + $this->requireManagementSuite($business); + + $filterData = $this->getDivisionFilterData($business, $request); + + // Determine which business to show accounts for + $targetBusiness = $filterData['selected_division'] ?? $business; + $includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses(); + + $accounts = $this->bankAccountService->getAccountsForBusiness($targetBusiness, $includeChildren); + $totalBalance = $this->bankAccountService->getTotalCashBalance($targetBusiness, $includeChildren); + + return view('seller.management.bank-accounts.index', $this->withDivisionFilter([ + 'business' => $business, + 'accounts' => $accounts, + 'totalBalance' => $totalBalance, + ], $filterData)); + } + + /** + * Show the form for creating a new bank account. + */ + public function create(Request $request, Business $business): View + { + $this->requireManagementSuite($business); + + $glAccounts = GlAccount::where('business_id', $business->id) + ->where('account_type', 'asset') + ->orderBy('account_number') + ->get(); + + return view('seller.management.bank-accounts.create', [ + 'business' => $business, + 'glAccounts' => $glAccounts, + 'accountTypes' => [ + BankAccount::TYPE_CHECKING => 'Checking', + BankAccount::TYPE_SAVINGS => 'Savings', + BankAccount::TYPE_MONEY_MARKET => 'Money Market', + ], + ]); + } + + /** + * Store a newly created bank account. + */ + public function store(Request $request, Business $business): RedirectResponse + { + $this->requireManagementSuite($business); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'account_type' => 'required|string|in:checking,savings,money_market', + 'bank_name' => 'nullable|string|max:255', + 'account_number_last4' => 'nullable|string|max:4', + 'routing_number' => 'nullable|string|max:9', + 'current_balance' => 'nullable|numeric|min:0', + 'gl_account_id' => 'nullable|exists:gl_accounts,id', + 'is_primary' => 'boolean', + 'is_active' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $this->bankAccountService->createAccount($business, $validated, auth()->user()); + + return redirect() + ->route('seller.business.management.bank-accounts.index', $business) + ->with('success', 'Bank account created successfully.'); + } + + /** + * Display the specified bank account. + */ + public function show(Request $request, Business $business, BankAccount $bankAccount): View + { + $this->requireManagementSuite($business); + + if ($bankAccount->business_id !== $business->id) { + abort(403, 'Access denied.'); + } + + $recentTransfers = $bankAccount->outgoingTransfers() + ->orWhere('to_bank_account_id', $bankAccount->id) + ->with(['fromAccount', 'toAccount']) + ->orderBy('transfer_date', 'desc') + ->limit(10) + ->get(); + + return view('seller.management.bank-accounts.show', [ + 'business' => $business, + 'account' => $bankAccount, + 'recentTransfers' => $recentTransfers, + ]); + } + + /** + * Show the form for editing the bank account. + */ + public function edit(Request $request, Business $business, BankAccount $bankAccount): View + { + $this->requireManagementSuite($business); + + if ($bankAccount->business_id !== $business->id) { + abort(403, 'Access denied.'); + } + + $glAccounts = GlAccount::where('business_id', $business->id) + ->where('account_type', 'asset') + ->orderBy('account_number') + ->get(); + + return view('seller.management.bank-accounts.edit', [ + 'business' => $business, + 'account' => $bankAccount, + 'glAccounts' => $glAccounts, + 'accountTypes' => [ + BankAccount::TYPE_CHECKING => 'Checking', + BankAccount::TYPE_SAVINGS => 'Savings', + BankAccount::TYPE_MONEY_MARKET => 'Money Market', + ], + ]); + } + + /** + * Update the specified bank account. + */ + public function update(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse + { + $this->requireManagementSuite($business); + + if ($bankAccount->business_id !== $business->id) { + abort(403, 'Access denied.'); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'account_type' => 'required|string|in:checking,savings,money_market', + 'bank_name' => 'nullable|string|max:255', + 'account_number_last4' => 'nullable|string|max:4', + 'routing_number' => 'nullable|string|max:9', + 'gl_account_id' => 'nullable|exists:gl_accounts,id', + 'is_primary' => 'boolean', + 'is_active' => 'boolean', + 'notes' => 'nullable|string', + ]); + + $this->bankAccountService->updateAccount($bankAccount, $validated); + + return redirect() + ->route('seller.business.management.bank-accounts.index', $business) + ->with('success', 'Bank account updated successfully.'); + } + + /** + * Toggle the active status of a bank account. + */ + public function toggleActive(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse + { + $this->requireManagementSuite($business); + + if ($bankAccount->business_id !== $business->id) { + abort(403, 'Access denied.'); + } + + $bankAccount->update(['is_active' => ! $bankAccount->is_active]); + + $status = $bankAccount->is_active ? 'activated' : 'deactivated'; + + return redirect() + ->back() + ->with('success', "Bank account {$status} successfully."); + } +} diff --git a/app/Http/Controllers/Seller/Management/BankReconciliationController.php b/app/Http/Controllers/Seller/Management/BankReconciliationController.php new file mode 100644 index 00000000..8aff5f54 --- /dev/null +++ b/app/Http/Controllers/Seller/Management/BankReconciliationController.php @@ -0,0 +1,310 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Seller/Management/BankTransfersController.php b/app/Http/Controllers/Seller/Management/BankTransfersController.php new file mode 100644 index 00000000..bf02cc5e --- /dev/null +++ b/app/Http/Controllers/Seller/Management/BankTransfersController.php @@ -0,0 +1,185 @@ +hasManagementSuite()) { + abort(403, 'Management Suite access required.'); + } + } + + /** + * Display the list of bank transfers. + */ + public function index(Request $request, Business $business): View + { + $this->requireManagementSuite($business); + + $filterData = $this->getDivisionFilterData($business, $request); + + $filters = [ + 'status' => $request->get('status'), + 'from_date' => $request->get('from_date'), + 'to_date' => $request->get('to_date'), + ]; + + $targetBusiness = $filterData['selected_division'] ?? $business; + $transfers = $this->bankAccountService->getTransfersForBusiness($targetBusiness, $filters); + + return view('seller.management.bank-transfers.index', $this->withDivisionFilter([ + 'business' => $business, + 'transfers' => $transfers, + 'filters' => $filters, + ], $filterData)); + } + + /** + * Show the form for creating a new bank transfer. + */ + public function create(Request $request, Business $business): View + { + $this->requireManagementSuite($business); + + $accounts = BankAccount::where('business_id', $business->id) + ->where('is_active', true) + ->orderBy('name') + ->get(); + + return view('seller.management.bank-transfers.create', [ + 'business' => $business, + 'accounts' => $accounts, + ]); + } + + /** + * Store a newly created bank transfer. + */ + public function store(Request $request, Business $business): RedirectResponse + { + $this->requireManagementSuite($business); + + $validated = $request->validate([ + 'from_bank_account_id' => 'required|exists:bank_accounts,id', + 'to_bank_account_id' => 'required|exists:bank_accounts,id|different:from_bank_account_id', + 'amount' => 'required|numeric|min:0.01', + 'transfer_date' => 'required|date', + 'reference' => 'nullable|string|max:255', + 'memo' => 'nullable|string', + ]); + + // Verify accounts belong to this business + $fromAccount = BankAccount::where('id', $validated['from_bank_account_id']) + ->where('business_id', $business->id) + ->firstOrFail(); + + $toAccount = BankAccount::where('id', $validated['to_bank_account_id']) + ->where('business_id', $business->id) + ->firstOrFail(); + + $transfer = $this->bankAccountService->createTransfer($business, $validated, auth()->user()); + + return redirect() + ->route('seller.business.management.bank-transfers.show', [$business, $transfer]) + ->with('success', 'Bank transfer created successfully.'); + } + + /** + * Display the specified bank transfer. + */ + public function show(Request $request, Business $business, BankTransfer $bankTransfer): View + { + $this->requireManagementSuite($business); + + if ($bankTransfer->business_id !== $business->id) { + abort(403, 'Access denied.'); + } + + $bankTransfer->load(['fromAccount', 'toAccount', 'createdBy', 'approvedBy', 'journalEntry']); + + return view('seller.management.bank-transfers.show', [ + 'business' => $business, + 'transfer' => $bankTransfer, + ]); + } + + /** + * Complete/approve a pending bank transfer. + */ + public function complete(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse + { + $this->requireManagementSuite($business); + + if ($bankTransfer->business_id !== $business->id) { + abort(403, 'Access denied.'); + } + + if (! $bankTransfer->isPending()) { + return redirect() + ->back() + ->with('error', 'Only pending transfers can be completed.'); + } + + try { + $this->bankAccountService->completeTransfer($bankTransfer, auth()->user()); + + return redirect() + ->route('seller.business.management.bank-transfers.show', [$business, $bankTransfer]) + ->with('success', 'Bank transfer completed successfully.'); + } catch (\Exception $e) { + return redirect() + ->back() + ->with('error', 'Failed to complete transfer: '.$e->getMessage()); + } + } + + /** + * Cancel a pending bank transfer. + */ + public function cancel(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse + { + $this->requireManagementSuite($business); + + if ($bankTransfer->business_id !== $business->id) { + abort(403, 'Access denied.'); + } + + if (! $bankTransfer->isPending()) { + return redirect() + ->back() + ->with('error', 'Only pending transfers can be cancelled.'); + } + + try { + $this->bankAccountService->cancelTransfer($bankTransfer); + + return redirect() + ->route('seller.business.management.bank-transfers.index', $business) + ->with('success', 'Bank transfer cancelled.'); + } catch (\Exception $e) { + return redirect() + ->back() + ->with('error', 'Failed to cancel transfer: '.$e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/Seller/Management/DirectoryVendorsController.php b/app/Http/Controllers/Seller/Management/DirectoryVendorsController.php index f37f3121..1e473ff7 100644 --- a/app/Http/Controllers/Seller/Management/DirectoryVendorsController.php +++ b/app/Http/Controllers/Seller/Management/DirectoryVendorsController.php @@ -36,6 +36,7 @@ class DirectoryVendorsController extends Controller $this->requireManagementSuite($business); $filterData = $this->getDivisionFilterData($business, $request); + $isParent = $business->hasChildBusinesses(); $vendors = ApVendor::whereIn('business_id', $filterData['business_ids']) ->with(['business']) @@ -43,9 +44,32 @@ class DirectoryVendorsController extends Controller ->orderBy('name') ->paginate(20); + // For parent companies, load division usage info for vendors + $vendorDivisionUsage = []; + if ($isParent && $vendors->isNotEmpty()) { + // Get all divisions that have bills with each vendor + $vendorIds = $vendors->pluck('id'); + $billsByVendor = \App\Models\Accounting\ApBill::whereIn('vendor_id', $vendorIds) + ->selectRaw('vendor_id, business_id, COUNT(*) as bill_count') + ->groupBy('vendor_id', 'business_id') + ->with('business:id,name,division_name') + ->get(); + + foreach ($billsByVendor as $bill) { + if (! isset($vendorDivisionUsage[$bill->vendor_id])) { + $vendorDivisionUsage[$bill->vendor_id] = []; + } + $vendorDivisionUsage[$bill->vendor_id][] = [ + 'business' => $bill->business, + 'bill_count' => $bill->bill_count, + ]; + } + } + return view('seller.management.directory.vendors.index', $this->withDivisionFilter([ 'business' => $business, 'vendors' => $vendors, + 'vendorDivisionUsage' => $vendorDivisionUsage, ], $filterData)); } diff --git a/app/Http/Controllers/Seller/Management/FinanceController.php b/app/Http/Controllers/Seller/Management/FinanceController.php index 4265d5e3..d6fc696b 100644 --- a/app/Http/Controllers/Seller/Management/FinanceController.php +++ b/app/Http/Controllers/Seller/Management/FinanceController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Seller\Management; use App\Http\Controllers\Controller; use App\Models\Business; +use App\Services\Accounting\ArService; use App\Services\Accounting\FinanceAnalyticsService; use App\Support\ManagementDivisionFilter; use Illuminate\Http\Request; @@ -15,7 +16,8 @@ class FinanceController extends Controller use ManagementDivisionFilter; public function __construct( - protected FinanceAnalyticsService $analyticsService + protected FinanceAnalyticsService $analyticsService, + protected ArService $arService ) {} public function apAging(Request $request, Business $business) @@ -46,10 +48,16 @@ class FinanceController extends Controller $divisions = $this->analyticsService->getDivisionRollup($business); $totals = [ + // AP Totals 'ap_outstanding' => $divisions->sum('ap_outstanding'), 'ap_overdue' => $divisions->sum('ap_overdue'), 'ytd_payments' => $divisions->sum('ytd_payments'), 'pending_approval' => $divisions->sum('pending_approval'), + // AR Totals + 'ar_total' => $divisions->sum('ar_total'), + 'ar_overdue' => $divisions->sum('ar_overdue'), + 'ar_at_risk' => $divisions->sum('ar_at_risk'), + 'ar_on_hold' => $divisions->sum('ar_on_hold'), ]; return view('seller.management.finance.divisions', compact('business', 'divisions', 'totals')); @@ -85,9 +93,23 @@ class FinanceController extends Controller public function index(Request $request, Business $business) { $isParent = $this->analyticsService->isParentCompany($business); + $includeChildren = $isParent; + + // AP Data $aging = $this->analyticsService->getAPAging($business); $forecast = $this->analyticsService->getCashForecast($business, 7); - return view('seller.management.finance.index', compact('business', 'isParent', 'aging', 'forecast')); + // AR Data + $arSummary = $this->arService->getArSummary($business, $includeChildren); + $topArAccounts = $this->arService->getTopArAccounts($business, 5, $includeChildren); + + return view('seller.management.finance.index', compact( + 'business', + 'isParent', + 'aging', + 'forecast', + 'arSummary', + 'topArAccounts' + )); } } diff --git a/app/Http/Controllers/Seller/Management/ForecastingController.php b/app/Http/Controllers/Seller/Management/ForecastingController.php new file mode 100644 index 00000000..9e06692d --- /dev/null +++ b/app/Http/Controllers/Seller/Management/ForecastingController.php @@ -0,0 +1,201 @@ +requireManagementSuite($business); + + $divisions = $this->getChildDivisionsIfAny($business); + $selectedDivision = $this->getSelectedDivision($request, $business); + $includeChildren = $this->shouldIncludeChildren($request); + + $businessIds = $this->getBusinessIdsForScope($business, $selectedDivision, $includeChildren); + + // Generate 12-month forecast + $forecast = $this->generateForecast($businessIds); + + return view('seller.management.forecasting.index', [ + 'business' => $business, + 'divisions' => $divisions, + 'selectedDivision' => $selectedDivision, + 'includeChildren' => $includeChildren, + 'forecast' => $forecast, + ]); + } + + protected function generateForecast(array $businessIds): array + { + // Get historical data for the past 12 months + $historicalData = $this->getHistoricalData($businessIds); + + // Calculate trends + $revenueTrend = $this->calculateTrend($historicalData['revenue']); + $expenseTrend = $this->calculateTrend($historicalData['expenses']); + + // Generate forecast for next 12 months + $forecastMonths = []; + $lastRevenue = end($historicalData['revenue'])['amount'] ?? 0; + $lastExpenses = end($historicalData['expenses'])['amount'] ?? 0; + + for ($i = 1; $i <= 12; $i++) { + $month = Carbon::now()->addMonths($i); + $projectedRevenue = max(0, $lastRevenue * (1 + ($revenueTrend / 100))); + $projectedExpenses = max(0, $lastExpenses * (1 + ($expenseTrend / 100))); + + $forecastMonths[] = [ + 'month' => $month->format('M Y'), + 'month_key' => $month->format('Y-m'), + 'projected_revenue' => $projectedRevenue, + 'projected_expenses' => $projectedExpenses, + 'projected_net' => $projectedRevenue - $projectedExpenses, + ]; + + $lastRevenue = $projectedRevenue; + $lastExpenses = $projectedExpenses; + } + + return [ + 'historical' => $historicalData, + 'forecast' => $forecastMonths, + 'trends' => [ + 'revenue' => $revenueTrend, + 'expenses' => $expenseTrend, + ], + 'summary' => [ + 'total_projected_revenue' => collect($forecastMonths)->sum('projected_revenue'), + 'total_projected_expenses' => collect($forecastMonths)->sum('projected_expenses'), + 'total_projected_net' => collect($forecastMonths)->sum('projected_net'), + ], + ]; + } + + protected function getHistoricalData(array $businessIds): array + { + $startDate = Carbon::now()->subMonths(12)->startOfMonth(); + $endDate = Carbon::now()->endOfMonth(); + + // Revenue (from orders) + $revenueByMonth = DB::table('orders') + ->whereIn('business_id', $businessIds) + ->where('status', 'completed') + ->whereBetween('created_at', [$startDate, $endDate]) + ->select( + DB::raw("TO_CHAR(created_at, 'YYYY-MM') as month_key"), + DB::raw('SUM(total) as amount') + ) + ->groupBy('month_key') + ->orderBy('month_key') + ->get() + ->keyBy('month_key'); + + // Expenses (from AP bills) + $expensesByMonth = DB::table('ap_bills') + ->whereIn('business_id', $businessIds) + ->whereIn('status', ['approved', 'paid']) + ->whereBetween('bill_date', [$startDate, $endDate]) + ->select( + DB::raw("TO_CHAR(bill_date, 'YYYY-MM') as month_key"), + DB::raw('SUM(total_amount) as amount') + ) + ->groupBy('month_key') + ->orderBy('month_key') + ->get() + ->keyBy('month_key'); + + // Fill in missing months with zeros + $revenue = []; + $expenses = []; + $current = $startDate->copy(); + + while ($current <= $endDate) { + $key = $current->format('Y-m'); + $revenue[] = [ + 'month' => $current->format('M Y'), + 'month_key' => $key, + 'amount' => $revenueByMonth[$key]->amount ?? 0, + ]; + $expenses[] = [ + 'month' => $current->format('M Y'), + 'month_key' => $key, + 'amount' => $expensesByMonth[$key]->amount ?? 0, + ]; + $current->addMonth(); + } + + return [ + 'revenue' => $revenue, + 'expenses' => $expenses, + ]; + } + + protected function calculateTrend(array $data): float + { + if (count($data) < 2) { + return 0; + } + + $amounts = array_column($data, 'amount'); + $n = count($amounts); + + // Simple linear regression + $sumX = 0; + $sumY = 0; + $sumXY = 0; + $sumXX = 0; + + for ($i = 0; $i < $n; $i++) { + $sumX += $i; + $sumY += $amounts[$i]; + $sumXY += $i * $amounts[$i]; + $sumXX += $i * $i; + } + + $denominator = ($n * $sumXX - $sumX * $sumX); + if ($denominator == 0) { + return 0; + } + + $slope = ($n * $sumXY - $sumX * $sumY) / $denominator; + $avgY = $sumY / $n; + + if ($avgY == 0) { + return 0; + } + + // Convert slope to percentage trend + return ($slope / $avgY) * 100; + } + + protected function getBusinessIdsForScope(Business $business, ?Business $selectedDivision, bool $includeChildren): array + { + if ($selectedDivision) { + if ($includeChildren) { + return $selectedDivision->divisions()->pluck('id') + ->prepend($selectedDivision->id) + ->toArray(); + } + + return [$selectedDivision->id]; + } + + if ($includeChildren && $business->hasChildBusinesses()) { + return $business->divisions()->pluck('id') + ->prepend($business->id) + ->toArray(); + } + + return [$business->id]; + } +} diff --git a/app/Http/Controllers/Seller/Management/InventoryValuationController.php b/app/Http/Controllers/Seller/Management/InventoryValuationController.php new file mode 100644 index 00000000..49186076 --- /dev/null +++ b/app/Http/Controllers/Seller/Management/InventoryValuationController.php @@ -0,0 +1,61 @@ +requireManagementSuite($business); + + $filterData = $this->getDivisionFilterData($business, $request); + + // Determine scope + $targetBusiness = $filterData['selectedDivision'] ?? $business; + $includeChildren = $filterData['selectedDivision'] === null && $business->hasChildBusinesses(); + $businessIds = $includeChildren + ? $business->childBusinesses()->pluck('id')->push($business->id)->toArray() + : [$targetBusiness->id]; + + // Get valuation data + $summary = $this->valuationService->getValuationSummary($businessIds); + $byType = $this->valuationService->getValuationByType($businessIds); + $byDivision = $includeChildren ? $this->valuationService->getValuationByDivision($businessIds) : collect(); + $byCategory = $this->valuationService->getValuationByCategory($businessIds); + $byLocation = $this->valuationService->getValuationByLocation($businessIds); + $topItems = $this->valuationService->getTopItemsByValue($businessIds, 10); + $aging = $this->valuationService->getInventoryAging($businessIds); + $atRisk = $this->valuationService->getInventoryAtRisk($businessIds); + + return view('seller.management.inventory-valuation.index', $this->withDivisionFilter([ + 'business' => $business, + 'summary' => $summary, + 'byType' => $byType, + 'byDivision' => $byDivision, + 'byCategory' => $byCategory, + 'byLocation' => $byLocation, + 'topItems' => $topItems, + 'aging' => $aging, + 'atRisk' => $atRisk, + 'isParent' => $business->hasChildBusinesses(), + ], $filterData)); + } +} diff --git a/app/Http/Controllers/Seller/Management/OperationsController.php b/app/Http/Controllers/Seller/Management/OperationsController.php new file mode 100644 index 00000000..c28f6b30 --- /dev/null +++ b/app/Http/Controllers/Seller/Management/OperationsController.php @@ -0,0 +1,136 @@ +requireManagementSuite($business); + + $divisions = $this->getChildDivisionsIfAny($business); + $selectedDivision = $this->getSelectedDivision($request, $business); + $includeChildren = $this->shouldIncludeChildren($request); + + $businessIds = $this->getBusinessIdsForScope($business, $selectedDivision, $includeChildren); + + // Collect operations data + $operations = $this->collectOperationsData($businessIds); + + return view('seller.management.operations.index', [ + 'business' => $business, + 'divisions' => $divisions, + 'selectedDivision' => $selectedDivision, + 'includeChildren' => $includeChildren, + 'operations' => $operations, + ]); + } + + protected function collectOperationsData(array $businessIds): array + { + $today = Carbon::today(); + $startOfMonth = Carbon::now()->startOfMonth(); + $startOfWeek = Carbon::now()->startOfWeek(); + + // Order stats + $orderStats = DB::table('orders') + ->whereIn('business_id', $businessIds) + ->select([ + DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_orders'), + DB::raw('COUNT(CASE WHEN status = \'processing\' THEN 1 END) as processing_orders'), + DB::raw('COUNT(CASE WHEN status = \'completed\' AND created_at >= \''.$startOfMonth->toDateString().'\' THEN 1 END) as completed_this_month'), + DB::raw('COUNT(CASE WHEN created_at >= \''.$startOfWeek->toDateString().'\' THEN 1 END) as orders_this_week'), + ]) + ->first(); + + // Product stats + $productStats = DB::table('products') + ->join('brands', 'products.brand_id', '=', 'brands.id') + ->whereIn('brands.business_id', $businessIds) + ->select([ + DB::raw('COUNT(*) as total_products'), + DB::raw('COUNT(CASE WHEN products.is_active = true THEN 1 END) as active_products'), + DB::raw('COUNT(CASE WHEN products.stock_quantity <= products.low_stock_threshold AND products.stock_quantity > 0 THEN 1 END) as low_stock_products'), + DB::raw('COUNT(CASE WHEN products.stock_quantity = 0 THEN 1 END) as out_of_stock_products'), + ]) + ->first(); + + // Customer stats + $customerStats = DB::table('contacts') + ->whereIn('business_id', $businessIds) + ->where('is_customer', true) + ->select([ + DB::raw('COUNT(*) as total_customers'), + DB::raw('COUNT(CASE WHEN created_at >= \''.$startOfMonth->toDateString().'\' THEN 1 END) as new_this_month'), + ]) + ->first(); + + // Bill stats + $billStats = DB::table('ap_bills') + ->whereIn('business_id', $businessIds) + ->select([ + DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_bills'), + DB::raw('COUNT(CASE WHEN status = \'approved\' THEN 1 END) as approved_bills'), + DB::raw('COUNT(CASE WHEN status = \'overdue\' THEN 1 END) as overdue_bills'), + DB::raw('SUM(CASE WHEN status IN (\'pending\', \'approved\') THEN total_amount ELSE 0 END) as pending_amount'), + ]) + ->first(); + + // Expense stats + $expenseStats = DB::table('expenses') + ->whereIn('business_id', $businessIds) + ->select([ + DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_expenses'), + DB::raw('SUM(CASE WHEN status = \'pending\' THEN amount ELSE 0 END) as pending_amount'), + ]) + ->first(); + + // Recent activity + $recentOrders = DB::table('orders') + ->join('businesses', 'orders.business_id', '=', 'businesses.id') + ->whereIn('orders.business_id', $businessIds) + ->orderByDesc('orders.created_at') + ->limit(5) + ->select(['orders.*', 'businesses.name as business_name']) + ->get(); + + return [ + 'orders' => $orderStats, + 'products' => $productStats, + 'customers' => $customerStats, + 'bills' => $billStats, + 'expenses' => $expenseStats, + 'recent_orders' => $recentOrders, + ]; + } + + protected function getBusinessIdsForScope(Business $business, ?Business $selectedDivision, bool $includeChildren): array + { + if ($selectedDivision) { + if ($includeChildren) { + return $selectedDivision->divisions()->pluck('id') + ->prepend($selectedDivision->id) + ->toArray(); + } + + return [$selectedDivision->id]; + } + + if ($includeChildren && $business->hasChildBusinesses()) { + return $business->divisions()->pluck('id') + ->prepend($business->id) + ->toArray(); + } + + return [$business->id]; + } +} diff --git a/app/Http/Controllers/Seller/Management/UsageBillingController.php b/app/Http/Controllers/Seller/Management/UsageBillingController.php new file mode 100644 index 00000000..f5a7781b --- /dev/null +++ b/app/Http/Controllers/Seller/Management/UsageBillingController.php @@ -0,0 +1,193 @@ +requireManagementSuite($business); + + $divisions = $this->getChildDivisionsIfAny($business); + $selectedDivision = $this->getSelectedDivision($request, $business); + $includeChildren = $this->shouldIncludeChildren($request); + + $businessIds = $this->getBusinessIdsForScope($business, $selectedDivision, $includeChildren); + + // Collect usage data + $usage = $this->collectUsageData($business, $businessIds); + + return view('seller.management.usage-billing.index', [ + 'business' => $business, + 'divisions' => $divisions, + 'selectedDivision' => $selectedDivision, + 'includeChildren' => $includeChildren, + 'usage' => $usage, + ]); + } + + protected function collectUsageData(Business $parentBusiness, array $businessIds): array + { + $startOfMonth = Carbon::now()->startOfMonth(); + $endOfMonth = Carbon::now()->endOfMonth(); + + // Get suite limits from config + $defaults = config('suites.defaults.sales_suite', []); + + // Count active brands + $brandCount = DB::table('brands') + ->whereIn('business_id', $businessIds) + ->where('is_active', true) + ->count(); + + // Count active products (SKUs) + $skuCount = DB::table('products') + ->join('brands', 'products.brand_id', '=', 'brands.id') + ->whereIn('brands.business_id', $businessIds) + ->where('products.is_active', true) + ->count(); + + // Count messages sent this month + $messageCount = DB::table('messages') + ->whereIn('business_id', $businessIds) + ->whereBetween('created_at', [$startOfMonth, $endOfMonth]) + ->count(); + + // Count menu sends this month + $menuSendCount = DB::table('menu_sends') + ->whereIn('business_id', $businessIds) + ->whereBetween('created_at', [$startOfMonth, $endOfMonth]) + ->count(); + + // Count CRM contacts + $contactCount = DB::table('contacts') + ->whereIn('business_id', $businessIds) + ->count(); + + // Calculate limits based on number of brands + $brandLimit = $parentBusiness->brand_limit ?? $defaults['brand_limit'] ?? 1; + $skuLimitPerBrand = $defaults['sku_limit_per_brand'] ?? 15; + $messageLimitPerBrand = $defaults['message_limit_per_brand'] ?? 500; + $menuLimitPerBrand = $defaults['menu_limit_per_brand'] ?? 100; + $contactLimitPerBrand = $defaults['contact_limit_per_brand'] ?? 1000; + + $totalSkuLimit = $brandCount * $skuLimitPerBrand; + $totalMessageLimit = $brandCount * $messageLimitPerBrand; + $totalMenuLimit = $brandCount * $menuLimitPerBrand; + $totalContactLimit = $brandCount * $contactLimitPerBrand; + + // Is enterprise plan? + $isEnterprise = $parentBusiness->is_enterprise_plan ?? false; + + // Get suites enabled + $enabledSuites = $this->getEnabledSuites($parentBusiness); + + // Usage by division + $usageByDivision = []; + if (count($businessIds) > 1) { + $usageByDivision = DB::table('businesses') + ->whereIn('businesses.id', $businessIds) + ->leftJoin('brands', 'brands.business_id', '=', 'businesses.id') + ->leftJoin('products', 'products.brand_id', '=', 'brands.id') + ->select( + 'businesses.id', + 'businesses.name', + DB::raw('COUNT(DISTINCT brands.id) as brand_count'), + DB::raw('COUNT(DISTINCT products.id) as sku_count') + ) + ->groupBy('businesses.id', 'businesses.name') + ->get(); + } + + return [ + 'brands' => [ + 'current' => $brandCount, + 'limit' => $isEnterprise ? null : $brandLimit, + 'percentage' => $brandLimit > 0 ? min(100, ($brandCount / $brandLimit) * 100) : 0, + ], + 'skus' => [ + 'current' => $skuCount, + 'limit' => $isEnterprise ? null : $totalSkuLimit, + 'percentage' => $totalSkuLimit > 0 ? min(100, ($skuCount / $totalSkuLimit) * 100) : 0, + ], + 'messages' => [ + 'current' => $messageCount, + 'limit' => $isEnterprise ? null : $totalMessageLimit, + 'percentage' => $totalMessageLimit > 0 ? min(100, ($messageCount / $totalMessageLimit) * 100) : 0, + ], + 'menu_sends' => [ + 'current' => $menuSendCount, + 'limit' => $isEnterprise ? null : $totalMenuLimit, + 'percentage' => $totalMenuLimit > 0 ? min(100, ($menuSendCount / $totalMenuLimit) * 100) : 0, + ], + 'contacts' => [ + 'current' => $contactCount, + 'limit' => $isEnterprise ? null : $totalContactLimit, + 'percentage' => $totalContactLimit > 0 ? min(100, ($contactCount / $totalContactLimit) * 100) : 0, + ], + 'is_enterprise' => $isEnterprise, + 'enabled_suites' => $enabledSuites, + 'usage_by_division' => $usageByDivision, + 'billing_period' => [ + 'start' => $startOfMonth->format('M j, Y'), + 'end' => $endOfMonth->format('M j, Y'), + ], + ]; + } + + protected function getEnabledSuites(Business $business): array + { + $suites = []; + + if ($business->hasSalesSuite()) { + $suites[] = ['name' => 'Sales Suite', 'key' => 'sales']; + } + if ($business->hasProcessingSuite()) { + $suites[] = ['name' => 'Processing Suite', 'key' => 'processing']; + } + if ($business->hasManufacturingSuite()) { + $suites[] = ['name' => 'Manufacturing Suite', 'key' => 'manufacturing']; + } + if ($business->hasDeliverySuite()) { + $suites[] = ['name' => 'Delivery Suite', 'key' => 'delivery']; + } + if ($business->hasManagementSuite()) { + $suites[] = ['name' => 'Management Suite', 'key' => 'management']; + } + if ($business->hasDispensarySuite()) { + $suites[] = ['name' => 'Dispensary Suite', 'key' => 'dispensary']; + } + + return $suites; + } + + protected function getBusinessIdsForScope(Business $business, ?Business $selectedDivision, bool $includeChildren): array + { + if ($selectedDivision) { + if ($includeChildren) { + return $selectedDivision->divisions()->pluck('id') + ->prepend($selectedDivision->id) + ->toArray(); + } + + return [$selectedDivision->id]; + } + + if ($includeChildren && $business->hasChildBusinesses()) { + return $business->divisions()->pluck('id') + ->prepend($business->id) + ->toArray(); + } + + return [$business->id]; + } +} diff --git a/app/Models/Accounting/ArCustomer.php b/app/Models/Accounting/ArCustomer.php index 40d2ccb0..2ca15c16 100644 --- a/app/Models/Accounting/ArCustomer.php +++ b/app/Models/Accounting/ArCustomer.php @@ -15,8 +15,15 @@ class ArCustomer extends Model protected $table = 'ar_customers'; + public const CREDIT_STATUS_GOOD = 'good'; + + public const CREDIT_STATUS_WATCH = 'watch'; + + public const CREDIT_STATUS_HOLD = 'hold'; + protected $fillable = [ 'business_id', + 'linked_business_id', 'name', 'email', 'phone', @@ -32,6 +39,10 @@ class ArCustomer extends Model 'credit_granted', 'credit_limit_approved_by', 'credit_approved_at', + 'on_credit_hold', + 'credit_status', + 'hold_reason', + 'ar_notes', 'notes', 'is_active', ]; @@ -40,6 +51,7 @@ class ArCustomer extends Model 'credit_limit' => 'decimal:2', 'credit_granted' => 'boolean', 'credit_approved_at' => 'datetime', + 'on_credit_hold' => 'boolean', 'is_active' => 'boolean', ]; @@ -48,6 +60,14 @@ class ArCustomer extends Model return $this->belongsTo(Business::class); } + /** + * The buyer business this AR customer is linked to (for B2B transactions). + */ + public function linkedBusiness(): BelongsTo + { + return $this->belongsTo(Business::class, 'linked_business_id'); + } + public function creditApprovedBy(): BelongsTo { return $this->belongsTo(User::class, 'credit_limit_approved_by'); @@ -69,4 +89,53 @@ class ArCustomer extends Model ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) ->sum('balance_due'); } + + public function getPastDueAmountAttribute(): float + { + return (float) $this->invoices() + ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('due_date', '<', now()) + ->sum('balance_due'); + } + + public function isOverCreditLimit(): bool + { + if (! $this->credit_limit || $this->credit_limit <= 0) { + return false; + } + + return $this->outstanding_balance > $this->credit_limit; + } + + public function getAvailableCreditAttribute(): float + { + if (! $this->credit_limit || $this->credit_limit <= 0) { + return 0; + } + + return max(0, $this->credit_limit - $this->outstanding_balance); + } + + public function scopeOnHold($query) + { + return $query->where('on_credit_hold', true); + } + + public function scopeAtRisk($query) + { + return $query->where(function ($q) { + $q->where('on_credit_hold', true) + ->orWhere('credit_status', self::CREDIT_STATUS_WATCH) + ->orWhere('credit_status', self::CREDIT_STATUS_HOLD) + ->orWhereHas('invoices', function ($inv) { + $inv->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('due_date', '<', now()); + }); + }); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } } diff --git a/app/Models/Accounting/BankAccount.php b/app/Models/Accounting/BankAccount.php new file mode 100644 index 00000000..62ee450e --- /dev/null +++ b/app/Models/Accounting/BankAccount.php @@ -0,0 +1,139 @@ + 'decimal:2', + 'available_balance' => 'decimal:2', + 'is_primary' => 'boolean', + 'is_active' => 'boolean', + 'last_synced_at' => 'datetime', + ]; + + // ========================================================================= + // RELATIONSHIPS + // ========================================================================= + + public function business(): BelongsTo + { + return $this->belongsTo(Business::class); + } + + public function glAccount(): BelongsTo + { + return $this->belongsTo(GlAccount::class, 'gl_account_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function outgoingTransfers(): HasMany + { + return $this->hasMany(BankTransfer::class, 'from_bank_account_id'); + } + + public function incomingTransfers(): HasMany + { + return $this->hasMany(BankTransfer::class, 'to_bank_account_id'); + } + + // ========================================================================= + // SCOPES + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeForBusiness($query, $businessId) + { + return $query->where('business_id', $businessId); + } + + // ========================================================================= + // ACCESSORS + // ========================================================================= + + public function getDisplayNameAttribute(): string + { + $name = $this->name; + if ($this->account_number_last4) { + $name .= ' (***'.$this->account_number_last4.')'; + } + + return $name; + } + + public function getAccountTypeDisplayAttribute(): string + { + return match ($this->account_type) { + self::TYPE_CHECKING => 'Checking', + self::TYPE_SAVINGS => 'Savings', + self::TYPE_MONEY_MARKET => 'Money Market', + default => ucfirst($this->account_type), + }; + } + + // ========================================================================= + // METHODS + // ========================================================================= + + public function updateBalance(float $amount, string $type = 'add'): void + { + if ($type === 'add') { + $this->current_balance += $amount; + } else { + $this->current_balance -= $amount; + } + $this->save(); + } + + public function hasPlaidConnection(): bool + { + return ! empty($this->plaid_account_id); + } +} diff --git a/app/Models/Accounting/BankMatchRule.php b/app/Models/Accounting/BankMatchRule.php new file mode 100644 index 00000000..a9251882 --- /dev/null +++ b/app/Models/Accounting/BankMatchRule.php @@ -0,0 +1,284 @@ + 'decimal:2', + 'typical_amount' => 'decimal:2', + 'training_count' => 'integer', + 'auto_enabled' => 'boolean', + 'auto_match_count' => 'integer', + 'rejection_count' => 'integer', + 'last_used_at' => 'datetime', + ]; + + // ========================================================================= + // RELATIONSHIPS + // ========================================================================= + + public function business(): BelongsTo + { + return $this->belongsTo(Business::class); + } + + public function bankAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class); + } + + public function targetGlAccount(): BelongsTo + { + return $this->belongsTo(GlAccount::class, 'target_gl_account_id'); + } + + public function proposedTransactions(): HasMany + { + return $this->hasMany(PlaidTransaction::class, 'matched_by_rule_id'); + } + + // ========================================================================= + // SCOPES + // ========================================================================= + + public function scopeAutoEnabled($query) + { + return $query->where('auto_enabled', true); + } + + public function scopeEligibleForAutoEnable($query) + { + return $query->where('auto_enabled', false) + ->where('training_count', '>=', self::MIN_TRAINING_COUNT); + } + + public function scopeForBankAccount($query, $bankAccountId) + { + return $query->where('bank_account_id', $bankAccountId); + } + + public function scopeForDirection($query, string $direction) + { + return $query->where('direction', $direction); + } + + // ========================================================================= + // ACCESSORS + // ========================================================================= + + public function getPatternTypeDisplayAttribute(): string + { + return match ($this->pattern_type) { + self::PATTERN_EXACT => 'Exact Match', + self::PATTERN_CONTAINS => 'Contains', + self::PATTERN_STARTS_WITH => 'Starts With', + default => ucfirst($this->pattern_type), + }; + } + + public function getDirectionDisplayAttribute(): string + { + return $this->direction === self::DIRECTION_DEBIT ? 'Debit (Money Out)' : 'Credit (Money In)'; + } + + public function getTargetTypeDisplayAttribute(): ?string + { + if (! $this->target_type) { + return null; + } + + return match ($this->target_type) { + self::TARGET_AP_PAYMENT => 'AP Payment', + self::TARGET_AR_PAYMENT => 'AR Payment', + self::TARGET_JOURNAL_ENTRY => 'Journal Entry', + self::TARGET_EXPENSE => 'Expense', + default => ucfirst(str_replace('_', ' ', $this->target_type)), + }; + } + + public function getSuccessRateAttribute(): ?float + { + $total = $this->auto_match_count + $this->rejection_count; + if ($total === 0) { + return null; + } + + return ($this->auto_match_count / $total) * 100; + } + + public function getIsEligibleForAutoEnableAttribute(): bool + { + return ! $this->auto_enabled && $this->training_count >= self::MIN_TRAINING_COUNT; + } + + // ========================================================================= + // METHODS + // ========================================================================= + + public function matchesTransaction(PlaidTransaction $transaction): bool + { + // Check direction + if ($this->direction !== $transaction->direction) { + return false; + } + + // Check pattern + if (! $this->matchesPattern($transaction->normalized_name)) { + return false; + } + + // Check amount tolerance if we have a typical amount + if ($this->typical_amount !== null) { + $difference = abs($transaction->absolute_amount - (float) $this->typical_amount); + if ($difference > (float) $this->amount_tolerance) { + return false; + } + } + + return true; + } + + protected function matchesPattern(string $normalizedName): bool + { + $pattern = strtolower($this->pattern_name); + $name = strtolower($normalizedName); + + return match ($this->pattern_type) { + self::PATTERN_EXACT => $name === $pattern, + self::PATTERN_CONTAINS => str_contains($name, $pattern), + self::PATTERN_STARTS_WITH => str_starts_with($name, $pattern), + default => $name === $pattern, + }; + } + + public function incrementTrainingCount(): void + { + $this->increment('training_count'); + $this->update(['last_used_at' => now()]); + } + + public function recordSuccessfulMatch(): void + { + $this->increment('auto_match_count'); + $this->update(['last_used_at' => now()]); + } + + public function recordRejection(): void + { + $this->increment('rejection_count'); + + // If too many rejections, disable auto-matching + if ($this->rejection_count >= 3 && $this->auto_enabled) { + $this->update(['auto_enabled' => false]); + } + } + + public function enableAutoMatching(): void + { + if ($this->training_count >= self::MIN_TRAINING_COUNT) { + $this->update(['auto_enabled' => true]); + } + } + + public function disableAutoMatching(): void + { + $this->update(['auto_enabled' => false]); + } + + public function updateTypicalAmount(float $amount): void + { + if ($this->typical_amount === null) { + $newAverage = $amount; + } else { + // Running average + $newAverage = (((float) $this->typical_amount * ($this->training_count - 1)) + $amount) / $this->training_count; + } + + $this->update(['typical_amount' => $newAverage]); + } + + /** + * Find or create a rule from a manual match + */ + public static function learnFromMatch( + PlaidTransaction $transaction, + BankAccount $bankAccount, + ?string $targetType = null, + ?int $targetId = null, + ?GlAccount $glAccount = null + ): self { + $rule = self::firstOrCreate( + [ + 'business_id' => $bankAccount->business_id, + 'bank_account_id' => $bankAccount->id, + 'pattern_name' => $transaction->normalized_name, + 'direction' => $transaction->direction, + ], + [ + 'pattern_type' => self::PATTERN_EXACT, + 'amount_tolerance' => 0.50, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'target_gl_account_id' => $glAccount?->id, + ] + ); + + $rule->incrementTrainingCount(); + $rule->updateTypicalAmount($transaction->absolute_amount); + + return $rule; + } +} diff --git a/app/Models/Accounting/BankTransfer.php b/app/Models/Accounting/BankTransfer.php new file mode 100644 index 00000000..fd510717 --- /dev/null +++ b/app/Models/Accounting/BankTransfer.php @@ -0,0 +1,132 @@ + 'decimal:2', + 'transfer_date' => 'date', + 'approved_at' => 'datetime', + ]; + + // ========================================================================= + // RELATIONSHIPS + // ========================================================================= + + public function business(): BelongsTo + { + return $this->belongsTo(Business::class); + } + + public function fromAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'from_bank_account_id'); + } + + public function toAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class, 'to_bank_account_id'); + } + + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by_user_id'); + } + + // ========================================================================= + // SCOPES + // ========================================================================= + + public function scopeForBusiness($query, $businessId) + { + return $query->where('business_id', $businessId); + } + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeCompleted($query) + { + return $query->where('status', self::STATUS_COMPLETED); + } + + // ========================================================================= + // ACCESSORS + // ========================================================================= + + public function getStatusBadgeAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'badge-warning', + self::STATUS_COMPLETED => 'badge-success', + self::STATUS_CANCELLED => 'badge-error', + default => 'badge-ghost', + }; + } + + // ========================================================================= + // METHODS + // ========================================================================= + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public function canBeCompleted(): bool + { + return $this->isPending() && $this->fromAccount && $this->toAccount; + } +} diff --git a/app/Models/Accounting/PlaidAccount.php b/app/Models/Accounting/PlaidAccount.php new file mode 100644 index 00000000..cd557ebc --- /dev/null +++ b/app/Models/Accounting/PlaidAccount.php @@ -0,0 +1,171 @@ + 'decimal:2', + 'available_balance' => 'decimal:2', + 'limit' => 'decimal:2', + 'is_active' => 'boolean', + 'last_synced_at' => 'datetime', + ]; + + // ========================================================================= + // RELATIONSHIPS + // ========================================================================= + + public function plaidItem(): BelongsTo + { + return $this->belongsTo(PlaidItem::class); + } + + public function bankAccount(): BelongsTo + { + return $this->belongsTo(BankAccount::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(PlaidTransaction::class); + } + + // ========================================================================= + // SCOPES + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeUnlinked($query) + { + return $query->whereNull('bank_account_id'); + } + + public function scopeLinked($query) + { + return $query->whereNotNull('bank_account_id'); + } + + // ========================================================================= + // ACCESSORS + // ========================================================================= + + public function getDisplayNameAttribute(): string + { + $name = $this->official_name ?? $this->name; + if ($this->mask) { + $name .= ' (***'.$this->mask.')'; + } + + return $name; + } + + public function getTypeDisplayAttribute(): string + { + return match ($this->type) { + self::TYPE_DEPOSITORY => 'Bank Account', + self::TYPE_CREDIT => 'Credit Card', + self::TYPE_LOAN => 'Loan', + self::TYPE_INVESTMENT => 'Investment', + default => ucfirst($this->type), + }; + } + + public function getSubtypeDisplayAttribute(): string + { + return ucfirst(str_replace('_', ' ', $this->subtype ?? '')); + } + + // ========================================================================= + // METHODS + // ========================================================================= + + public function isLinked(): bool + { + return $this->bank_account_id !== null; + } + + public function isDepository(): bool + { + return $this->type === self::TYPE_DEPOSITORY; + } + + public function isCredit(): bool + { + return $this->type === self::TYPE_CREDIT; + } + + public function linkToBankAccount(BankAccount $bankAccount): void + { + $this->update(['bank_account_id' => $bankAccount->id]); + + // Also update the bank account's plaid_account_id reference + $bankAccount->update(['plaid_account_id' => $this->plaid_account_id]); + } + + public function unlink(): void + { + if ($this->bankAccount) { + $this->bankAccount->update(['plaid_account_id' => null]); + } + $this->update(['bank_account_id' => null]); + } + + public function updateBalances(float $current, ?float $available = null): void + { + $this->update([ + 'current_balance' => $current, + 'available_balance' => $available, + 'last_synced_at' => now(), + ]); + + // Also update linked bank account if exists + if ($this->bankAccount) { + $this->bankAccount->update([ + 'current_balance' => $current, + 'available_balance' => $available ?? $current, + 'last_synced_at' => now(), + ]); + } + } +} diff --git a/app/Models/Accounting/PlaidItem.php b/app/Models/Accounting/PlaidItem.php new file mode 100644 index 00000000..435c6c58 --- /dev/null +++ b/app/Models/Accounting/PlaidItem.php @@ -0,0 +1,141 @@ + 'datetime', + 'last_synced_at' => 'datetime', + ]; + + protected $hidden = [ + 'plaid_access_token', + ]; + + // ========================================================================= + // RELATIONSHIPS + // ========================================================================= + + public function business(): BelongsTo + { + return $this->belongsTo(Business::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function accounts(): HasMany + { + return $this->hasMany(PlaidAccount::class); + } + + public function transactions(): HasManyThrough + { + return $this->hasManyThrough(PlaidTransaction::class, PlaidAccount::class); + } + + // ========================================================================= + // SCOPES + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + public function scopeForBusiness($query, $businessId) + { + return $query->where('business_id', $businessId); + } + + // ========================================================================= + // ACCESSORS + // ========================================================================= + + public function getStatusBadgeAttribute(): string + { + return match ($this->status) { + self::STATUS_ACTIVE => 'badge-success', + self::STATUS_REVOKED => 'badge-warning', + self::STATUS_ERROR => 'badge-error', + default => 'badge-ghost', + }; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_ACTIVE => 'Active', + self::STATUS_REVOKED => 'Revoked', + self::STATUS_ERROR => 'Error', + default => ucfirst($this->status), + }; + } + + // ========================================================================= + // METHODS + // ========================================================================= + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function hasError(): bool + { + return $this->status === self::STATUS_ERROR; + } + + public function needsReconnection(): bool + { + return $this->status === self::STATUS_REVOKED + || ($this->consent_expires_at && $this->consent_expires_at->isPast()); + } + + public function markSynced(): void + { + $this->update(['last_synced_at' => now()]); + } + + public function markError(string $message): void + { + $this->update([ + 'status' => self::STATUS_ERROR, + 'error_message' => $message, + ]); + } +} diff --git a/app/Models/Accounting/PlaidTransaction.php b/app/Models/Accounting/PlaidTransaction.php new file mode 100644 index 00000000..147f67f6 --- /dev/null +++ b/app/Models/Accounting/PlaidTransaction.php @@ -0,0 +1,328 @@ + 'date', + 'authorized_date' => 'date', + 'amount' => 'decimal:2', + 'pending' => 'boolean', + 'category' => 'array', + 'location' => 'array', + 'raw_data' => 'array', + 'matched_at' => 'datetime', + ]; + + // ========================================================================= + // RELATIONSHIPS + // ========================================================================= + + public function plaidAccount(): BelongsTo + { + return $this->belongsTo(PlaidAccount::class); + } + + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class, 'linked_journal_entry_id'); + } + + public function apPayment(): BelongsTo + { + return $this->belongsTo(ApPayment::class, 'linked_ap_payment_id'); + } + + public function matchedByRule(): BelongsTo + { + return $this->belongsTo(BankMatchRule::class, 'matched_by_rule_id'); + } + + public function matchedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'matched_by_user_id'); + } + + // ========================================================================= + // SCOPES + // ========================================================================= + + public function scopeUnmatched($query) + { + return $query->where('status', self::STATUS_UNMATCHED); + } + + public function scopeMatched($query) + { + return $query->where('status', self::STATUS_MATCHED); + } + + public function scopeProposedAuto($query) + { + return $query->where('status', self::STATUS_PROPOSED_AUTO); + } + + public function scopeNeedsReview($query) + { + return $query->whereIn('status', [self::STATUS_UNMATCHED, self::STATUS_PROPOSED_AUTO]); + } + + public function scopeForAccount($query, $plaidAccountId) + { + return $query->where('plaid_account_id', $plaidAccountId); + } + + public function scopeNotPending($query) + { + return $query->where('pending', false); + } + + public function scopeInDateRange($query, $startDate, $endDate) + { + return $query->whereBetween('date', [$startDate, $endDate]); + } + + // ========================================================================= + // ACCESSORS + // ========================================================================= + + public function getDisplayNameAttribute(): string + { + return $this->merchant_name ?? $this->name; + } + + public function getIsDebitAttribute(): bool + { + // Plaid convention: positive = money out (debit), negative = money in (credit) + return $this->amount > 0; + } + + public function getIsCreditAttribute(): bool + { + return $this->amount < 0; + } + + public function getDirectionAttribute(): string + { + return $this->is_debit ? 'debit' : 'credit'; + } + + public function getAbsoluteAmountAttribute(): float + { + return abs((float) $this->amount); + } + + public function getFormattedAmountAttribute(): string + { + $prefix = $this->is_credit ? '+' : '-'; + + return $prefix.'$'.number_format($this->absolute_amount, 2); + } + + public function getStatusBadgeAttribute(): string + { + return match ($this->status) { + self::STATUS_MATCHED => 'badge-success', + self::STATUS_PROPOSED_AUTO => 'badge-info', + self::STATUS_IGNORED => 'badge-ghost', + default => 'badge-warning', + }; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_MATCHED => 'Matched', + self::STATUS_PROPOSED_AUTO => 'Auto-Match Proposed', + self::STATUS_IGNORED => 'Ignored', + default => 'Unmatched', + }; + } + + public function getCategoryDisplayAttribute(): ?string + { + if (empty($this->category)) { + return null; + } + + return implode(' > ', $this->category); + } + + // ========================================================================= + // PATTERN EXTRACTION (for auto-matching) + // ========================================================================= + + public function getNormalizedNameAttribute(): string + { + $name = $this->merchant_name ?? $this->name; + + // Normalize: lowercase, remove extra spaces, strip common prefixes + $name = strtolower(trim($name)); + $name = preg_replace('/\s+/', ' ', $name); + + // Remove common transaction prefixes + $prefixes = ['pos ', 'payment to ', 'payment from ', 'ach ', 'wire ', 'check ']; + foreach ($prefixes as $prefix) { + if (str_starts_with($name, $prefix)) { + $name = substr($name, strlen($prefix)); + } + } + + // Remove trailing reference numbers (e.g., "vendor #12345") + $name = preg_replace('/\s*#?\d{4,}$/', '', $name); + + return trim($name); + } + + // ========================================================================= + // METHODS + // ========================================================================= + + public function isUnmatched(): bool + { + return $this->status === self::STATUS_UNMATCHED; + } + + public function isMatched(): bool + { + return $this->status === self::STATUS_MATCHED; + } + + public function isProposedAuto(): bool + { + return $this->status === self::STATUS_PROPOSED_AUTO; + } + + public function isIgnored(): bool + { + return $this->status === self::STATUS_IGNORED; + } + + public function hasLinkedRecord(): bool + { + return $this->linked_journal_entry_id !== null + || $this->linked_ap_payment_id !== null + || $this->linked_ar_payment_id !== null; + } + + public function matchToJournalEntry(JournalEntry $entry, ?User $user = null): void + { + $this->update([ + 'status' => self::STATUS_MATCHED, + 'linked_journal_entry_id' => $entry->id, + 'matched_by_user_id' => $user?->id, + 'matched_at' => now(), + ]); + } + + public function matchToApPayment(ApPayment $payment, ?User $user = null): void + { + $this->update([ + 'status' => self::STATUS_MATCHED, + 'linked_ap_payment_id' => $payment->id, + 'matched_by_user_id' => $user?->id, + 'matched_at' => now(), + ]); + } + + public function proposeAutoMatch(BankMatchRule $rule): void + { + $this->update([ + 'status' => self::STATUS_PROPOSED_AUTO, + 'matched_by_rule_id' => $rule->id, + ]); + } + + public function confirmAutoMatch(?User $user = null): void + { + if ($this->matched_by_rule_id) { + $rule = $this->matchedByRule; + if ($rule) { + $rule->recordSuccessfulMatch(); + } + } + + $this->update([ + 'status' => self::STATUS_MATCHED, + 'matched_by_user_id' => $user?->id, + 'matched_at' => now(), + ]); + } + + public function rejectAutoMatch(?User $user = null): void + { + if ($this->matched_by_rule_id) { + $rule = $this->matchedByRule; + if ($rule) { + $rule->recordRejection(); + } + } + + $this->update([ + 'status' => self::STATUS_UNMATCHED, + 'matched_by_rule_id' => null, + ]); + } + + public function ignore(): void + { + $this->update(['status' => self::STATUS_IGNORED]); + } + + public function unmatch(): void + { + $this->update([ + 'status' => self::STATUS_UNMATCHED, + 'linked_journal_entry_id' => null, + 'linked_ap_payment_id' => null, + 'linked_ar_payment_id' => null, + 'matched_by_rule_id' => null, + 'matched_by_user_id' => null, + 'matched_at' => null, + 'match_notes' => null, + ]); + } +} diff --git a/app/Services/Accounting/ArService.php b/app/Services/Accounting/ArService.php new file mode 100644 index 00000000..03c080c9 --- /dev/null +++ b/app/Services/Accounting/ArService.php @@ -0,0 +1,504 @@ + $customer->id, + 'customer_name' => $customer->name, + 'on_credit_hold' => $customer->on_credit_hold ?? false, + 'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD, + 'credit_limit' => $customer->credit_limit ?? 0, + 'current_balance' => $customer->outstanding_balance, + 'past_due_amount' => $customer->past_due_amount, + 'additional_amount' => $additionalAmount, + 'projected_balance' => $customer->outstanding_balance + $additionalAmount, + ]; + + // Check 1: Credit Hold + if ($customer->on_credit_hold) { + return [ + 'can_extend' => false, + 'reason' => 'Account is on credit hold'.($customer->hold_reason ? ": {$customer->hold_reason}" : ''), + 'details' => $details, + ]; + } + + // Check 2: Credit Status is Hold + if (($customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD) === ArCustomer::CREDIT_STATUS_HOLD) { + return [ + 'can_extend' => false, + 'reason' => 'Account credit status is HOLD - contact management', + 'details' => $details, + ]; + } + + // Check 3: Over Credit Limit + $creditLimit = $customer->credit_limit ?? 0; + if ($creditLimit > 0) { + $projectedBalance = $customer->outstanding_balance + $additionalAmount; + if ($projectedBalance > $creditLimit) { + $overBy = $projectedBalance - $creditLimit; + + return [ + 'can_extend' => false, + 'reason' => "Order would exceed credit limit by \${$overBy}. Current balance: \${$customer->outstanding_balance}, Limit: \${$creditLimit}", + 'details' => $details, + ]; + } + } + + // Check 4: Significant Past Due (optional warning) + $pastDue = $customer->past_due_amount; + if ($pastDue > 0 && $creditLimit > 0 && ($pastDue / $creditLimit) > 0.5) { + // More than 50% of credit limit is past due - still allow but warn + $details['warning'] = "Customer has significant past due balance: \${$pastDue}"; + } + + return [ + 'can_extend' => true, + 'reason' => null, + 'details' => $details, + ]; + } + + /** + * Get accounts with balances for a business. + * + * @param array|null $businessIds + */ + public function getAccountsWithBalances( + Business $business, + ?array $businessIds = null, + array $filters = [] + ): Collection { + $ids = $businessIds ?? [$business->id]; + + $query = ArCustomer::whereIn('business_id', $ids) + ->where('is_active', true); + + // Apply filters + if (! empty($filters['on_hold'])) { + $query->where('on_credit_hold', true); + } + + if (! empty($filters['at_risk'])) { + $query->atRisk(); + } + + if (! empty($filters['search'])) { + $query->where('name', 'ilike', '%'.$filters['search'].'%'); + } + + return $query->get()->map(function ($customer) { + return [ + 'customer' => $customer, + 'balance' => $customer->outstanding_balance, + 'past_due' => $customer->past_due_amount, + 'credit_limit' => $customer->credit_limit ?? 0, + 'available_credit' => $customer->available_credit, + 'on_credit_hold' => $customer->on_credit_hold ?? false, + 'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD, + 'payment_terms' => $customer->payment_terms ?? 'Net 30', + ]; + })->sortByDesc('balance')->values(); + } + + /** + * Get aging breakdown for a business. + * + * @param array|null $businessIds + */ + public function getAging(Business $business, ?array $businessIds = null, bool $includeChildren = true): array + { + $ids = $businessIds; + + if ($ids === null) { + $ids = [$business->id]; + if ($includeChildren && $business->parent_id === null) { + $childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray(); + $ids = array_merge($ids, $childIds); + } + } + + $invoices = ArInvoice::whereIn('business_id', $ids) + ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0) + ->get(); + + $aging = [ + 'current' => ['amount' => 0, 'count' => 0], + 'days_1_30' => ['amount' => 0, 'count' => 0], + 'days_31_60' => ['amount' => 0, 'count' => 0], + 'days_61_90' => ['amount' => 0, 'count' => 0], + 'over_90' => ['amount' => 0, 'count' => 0], + 'total' => ['amount' => 0, 'count' => 0], + ]; + + foreach ($invoices as $invoice) { + $balance = (float) $invoice->balance_due; + $aging['total']['amount'] += $balance; + $aging['total']['count']++; + + if (! $invoice->due_date || ! $invoice->due_date->isPast()) { + $aging['current']['amount'] += $balance; + $aging['current']['count']++; + } else { + $daysOverdue = $invoice->due_date->diffInDays(now()); + + if ($daysOverdue <= 30) { + $aging['days_1_30']['amount'] += $balance; + $aging['days_1_30']['count']++; + } elseif ($daysOverdue <= 60) { + $aging['days_31_60']['amount'] += $balance; + $aging['days_31_60']['count']++; + } elseif ($daysOverdue <= 90) { + $aging['days_61_90']['amount'] += $balance; + $aging['days_61_90']['count']++; + } else { + $aging['over_90']['amount'] += $balance; + $aging['over_90']['count']++; + } + } + } + + return $aging; + } + + /** + * Get account summary for a single customer. + */ + public function getAccountSummary(ArCustomer $customer): array + { + $invoices = $customer->invoices() + ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0) + ->get(); + + $aging = [ + 'current' => 0, + '1_30' => 0, + '31_60' => 0, + '61_90' => 0, + '90_plus' => 0, + ]; + + foreach ($invoices as $invoice) { + $balance = (float) $invoice->balance_due; + + if (! $invoice->due_date || ! $invoice->due_date->isPast()) { + $aging['current'] += $balance; + } else { + $daysOverdue = $invoice->due_date->diffInDays(now()); + + if ($daysOverdue <= 30) { + $aging['1_30'] += $balance; + } elseif ($daysOverdue <= 60) { + $aging['31_60'] += $balance; + } elseif ($daysOverdue <= 90) { + $aging['61_90'] += $balance; + } else { + $aging['90_plus'] += $balance; + } + } + } + + return [ + 'customer' => $customer, + 'total_balance' => $customer->outstanding_balance, + 'past_due' => $customer->past_due_amount, + 'aging' => $aging, + 'credit_limit' => $customer->credit_limit ?? 0, + 'available_credit' => $customer->available_credit, + 'on_credit_hold' => $customer->on_credit_hold ?? false, + 'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD, + 'hold_reason' => $customer->hold_reason, + 'payment_terms' => $customer->payment_terms ?? 'Net 30', + 'open_invoice_count' => $invoices->count(), + ]; + } + + /** + * Place a customer on credit hold. + */ + public function placeCreditHold(ArCustomer $customer, string $reason, int $userId): ArCustomer + { + $customer->update([ + 'on_credit_hold' => true, + 'credit_status' => ArCustomer::CREDIT_STATUS_HOLD, + 'hold_reason' => $reason, + ]); + + return $customer->fresh(); + } + + /** + * Remove credit hold from a customer. + */ + public function removeCreditHold(ArCustomer $customer, int $userId): ArCustomer + { + $customer->update([ + 'on_credit_hold' => false, + 'credit_status' => ArCustomer::CREDIT_STATUS_GOOD, + 'hold_reason' => null, + ]); + + return $customer->fresh(); + } + + /** + * Update credit limit for a customer. + */ + public function updateCreditLimit(ArCustomer $customer, float $newLimit, int $userId): ArCustomer + { + $customer->update([ + 'credit_limit' => $newLimit, + 'credit_limit_approved_by' => $userId, + 'credit_approved_at' => now(), + 'credit_granted' => $newLimit > 0, + ]); + + return $customer->fresh(); + } + + /** + * Update payment terms for a customer. + */ + public function updatePaymentTerms(ArCustomer $customer, string $terms, int $userId): ArCustomer + { + $termsDays = match ($terms) { + 'Net 15', 'net_15' => 15, + 'Net 30', 'net_30' => 30, + 'Net 45', 'net_45' => 45, + 'Net 60', 'net_60' => 60, + 'Due on Receipt', 'due_on_receipt' => 0, + 'COD', 'cod' => 0, + default => 30, + }; + + $customer->update([ + 'payment_terms' => $terms, + 'payment_terms_days' => $termsDays, + ]); + + return $customer->fresh(); + } + + /** + * Get AR summary for Finance Overview dashboard. + * + * @return array{total_ar: float, total_past_due: float, at_risk_count: int, on_hold_count: int} + */ + public function getArSummary(Business $business, bool $includeChildren = true): array + { + $businessIds = [$business->id]; + + if ($includeChildren && $business->parent_id === null) { + $childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray(); + $businessIds = array_merge($businessIds, $childIds); + } + + $totalAr = ArInvoice::whereIn('business_id', $businessIds) + ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0) + ->sum('balance_due'); + + $totalPastDue = ArInvoice::whereIn('business_id', $businessIds) + ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0) + ->where('due_date', '<', now()) + ->sum('balance_due'); + + $atRiskCount = ArCustomer::whereIn('business_id', $businessIds) + ->where('is_active', true) + ->where(function ($query) { + $query->where('on_credit_hold', true) + ->orWhereHas('invoices', function ($q) { + $q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0) + ->where('due_date', '<', now()); + }); + }) + ->count(); + + $onHoldCount = ArCustomer::whereIn('business_id', $businessIds) + ->where('on_credit_hold', true) + ->count(); + + return [ + 'total_ar' => (float) $totalAr, + 'total_past_due' => (float) $totalPastDue, + 'at_risk_count' => $atRiskCount, + 'on_hold_count' => $onHoldCount, + ]; + } + + /** + * Get top AR accounts by balance for Finance Overview. + */ + public function getTopArAccounts(Business $business, int $limit = 5, bool $includeChildren = true): Collection + { + $businessIds = [$business->id]; + + if ($includeChildren && $business->parent_id === null) { + $childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray(); + $businessIds = array_merge($businessIds, $childIds); + } + + return ArCustomer::whereIn('ar_customers.business_id', $businessIds) + ->where('is_active', true) + ->whereHas('invoices', function ($q) { + $q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0); + }) + ->with('business') + ->get() + ->map(function ($customer) { + $balance = $customer->invoices() + ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0) + ->sum('balance_due'); + + $pastDue = $customer->invoices() + ->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID]) + ->where('balance_due', '>', 0) + ->where('due_date', '<', now()) + ->sum('balance_due'); + + return [ + 'customer' => $customer, + 'business' => $customer->business, + 'balance' => (float) $balance, + 'past_due' => (float) $pastDue, + 'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD, + 'on_credit_hold' => $customer->on_credit_hold ?? false, + ]; + }) + ->sortByDesc('balance') + ->take($limit) + ->values(); + } + + /** + * Find or create an AR customer for a buyer business. + * + * Used to link CRM accounts (Business) to AR customers for credit management. + */ + public function findOrCreateForBusiness(Business $sellerBusiness, Business $buyerBusiness): ArCustomer + { + $arCustomer = ArCustomer::where('business_id', $sellerBusiness->id) + ->where('linked_business_id', $buyerBusiness->id) + ->first(); + + if ($arCustomer) { + return $arCustomer; + } + + // Create new AR customer linked to the buyer business + return ArCustomer::create([ + 'business_id' => $sellerBusiness->id, + 'linked_business_id' => $buyerBusiness->id, + 'name' => $buyerBusiness->name, + 'email' => $buyerBusiness->email, + 'phone' => $buyerBusiness->phone, + 'address_line_1' => $buyerBusiness->address_line_1, + 'address_line_2' => $buyerBusiness->address_line_2, + 'city' => $buyerBusiness->city, + 'state' => $buyerBusiness->state, + 'postal_code' => $buyerBusiness->postal_code, + 'country' => $buyerBusiness->country ?? 'US', + 'payment_terms' => 'Net 30', + 'payment_terms_days' => 30, + 'is_active' => true, + ]); + } + + /** + * Check credit for an account (buyer business) before extending terms. + * + * This is the main entry point for Sales Suite credit enforcement. + * + * @return array{can_extend: bool, reason: string|null, ar_customer: ArCustomer|null, details: array} + */ + public function checkCreditForAccount( + Business $sellerBusiness, + Business $buyerBusiness, + float $orderAmount = 0 + ): array { + $arCustomer = ArCustomer::where('business_id', $sellerBusiness->id) + ->where('linked_business_id', $buyerBusiness->id) + ->first(); + + // If no AR customer exists yet, allow the order (new customer) + if (! $arCustomer) { + return [ + 'can_extend' => true, + 'reason' => null, + 'ar_customer' => null, + 'details' => [ + 'is_new_customer' => true, + 'buyer_name' => $buyerBusiness->name, + 'order_amount' => $orderAmount, + ], + ]; + } + + // Use canExtendTerms to check credit status + $result = $this->canExtendTerms($arCustomer, $orderAmount); + + return [ + 'can_extend' => $result['can_extend'], + 'reason' => $result['reason'], + 'ar_customer' => $arCustomer, + 'details' => $result['details'], + ]; + } + + /** + * Check if business is a parent (management) company. + */ + public function isParentCompany(Business $business): bool + { + return $business->parent_id === null && Business::where('parent_id', $business->id)->exists(); + } + + /** + * Get business IDs including children for a parent. + * + * @return array + */ + public function getBusinessIdsWithChildren(Business $business): array + { + $ids = [$business->id]; + + if ($business->parent_id === null) { + $childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray(); + $ids = array_merge($ids, $childIds); + } + + return $ids; + } +} diff --git a/app/Services/Accounting/BankAccountService.php b/app/Services/Accounting/BankAccountService.php new file mode 100644 index 00000000..6a08e058 --- /dev/null +++ b/app/Services/Accounting/BankAccountService.php @@ -0,0 +1,233 @@ +isParentCompany()) { + $childIds = $business->divisions()->pluck('id')->toArray(); + $businessIds = array_merge([$business->id], $childIds); + $query->whereIn('business_id', $businessIds); + } else { + $query->where('business_id', $business->id); + } + + return $query->with(['glAccount', 'business']) + ->orderBy('is_primary', 'desc') + ->orderBy('name') + ->get(); + } + + /** + * Get total cash balance across all bank accounts. + */ + public function getTotalCashBalance(Business $business, bool $includeChildren = false): float + { + $accounts = $this->getAccountsForBusiness($business, $includeChildren); + + return $accounts->where('is_active', true)->sum('current_balance'); + } + + /** + * Create a new bank account. + */ + public function createAccount(Business $business, array $data, User $user): BankAccount + { + // If this is the first account, make it primary + $existingCount = BankAccount::where('business_id', $business->id)->count(); + if ($existingCount === 0) { + $data['is_primary'] = true; + } + + // If this is set as primary, unset others + if (! empty($data['is_primary'])) { + BankAccount::where('business_id', $business->id) + ->update(['is_primary' => false]); + } + + return BankAccount::create([ + 'business_id' => $business->id, + 'name' => $data['name'], + 'account_type' => $data['account_type'] ?? BankAccount::TYPE_CHECKING, + 'bank_name' => $data['bank_name'] ?? null, + 'account_number_last4' => $data['account_number_last4'] ?? null, + 'routing_number' => $data['routing_number'] ?? null, + 'current_balance' => $data['current_balance'] ?? 0, + 'available_balance' => $data['available_balance'] ?? 0, + 'gl_account_id' => $data['gl_account_id'] ?? null, + 'currency' => $data['currency'] ?? 'USD', + 'is_primary' => $data['is_primary'] ?? false, + 'is_active' => $data['is_active'] ?? true, + 'notes' => $data['notes'] ?? null, + 'created_by_user_id' => $user->id, + ]); + } + + /** + * Update a bank account. + */ + public function updateAccount(BankAccount $account, array $data): BankAccount + { + // If this is set as primary, unset others + if (! empty($data['is_primary']) && ! $account->is_primary) { + BankAccount::where('business_id', $account->business_id) + ->where('id', '!=', $account->id) + ->update(['is_primary' => false]); + } + + $account->update($data); + + return $account->fresh(); + } + + /** + * Create a bank transfer. + */ + public function createTransfer(Business $business, array $data, User $user): BankTransfer + { + return DB::transaction(function () use ($business, $data, $user) { + $transfer = BankTransfer::create([ + 'business_id' => $business->id, + 'from_bank_account_id' => $data['from_bank_account_id'], + 'to_bank_account_id' => $data['to_bank_account_id'], + 'amount' => $data['amount'], + 'transfer_date' => $data['transfer_date'], + 'reference' => $data['reference'] ?? null, + 'status' => BankTransfer::STATUS_PENDING, + 'memo' => $data['memo'] ?? null, + 'created_by_user_id' => $user->id, + ]); + + return $transfer; + }); + } + + /** + * Complete a bank transfer (post to GL and update balances). + */ + public function completeTransfer(BankTransfer $transfer, User $approver): BankTransfer + { + if (! $transfer->canBeCompleted()) { + throw new \Exception('Transfer cannot be completed.'); + } + + return DB::transaction(function () use ($transfer, $approver) { + $fromAccount = $transfer->fromAccount; + $toAccount = $transfer->toAccount; + + // Create journal entry if GL accounts are linked + if ($fromAccount->gl_account_id && $toAccount->gl_account_id) { + $journalEntry = $this->journalEntryService->createEntry( + $transfer->business, + [ + 'entry_date' => $transfer->transfer_date, + 'memo' => "Bank transfer: {$fromAccount->name} to {$toAccount->name}". + ($transfer->reference ? " (Ref: {$transfer->reference})" : ''), + 'source_type' => 'bank_transfer', + 'source_id' => $transfer->id, + ], + [ + [ + 'gl_account_id' => $toAccount->gl_account_id, + 'debit' => $transfer->amount, + 'credit' => 0, + 'description' => "Transfer from {$fromAccount->name}", + ], + [ + 'gl_account_id' => $fromAccount->gl_account_id, + 'debit' => 0, + 'credit' => $transfer->amount, + 'description' => "Transfer to {$toAccount->name}", + ], + ], + $approver + ); + + $transfer->journal_entry_id = $journalEntry->id; + } + + // Update account balances + $fromAccount->updateBalance($transfer->amount, 'subtract'); + $toAccount->updateBalance($transfer->amount, 'add'); + + // Mark transfer as completed + $transfer->status = BankTransfer::STATUS_COMPLETED; + $transfer->approved_by_user_id = $approver->id; + $transfer->approved_at = now(); + $transfer->save(); + + return $transfer; + }); + } + + /** + * Cancel a pending transfer. + */ + public function cancelTransfer(BankTransfer $transfer): BankTransfer + { + if (! $transfer->isPending()) { + throw new \Exception('Only pending transfers can be cancelled.'); + } + + $transfer->status = BankTransfer::STATUS_CANCELLED; + $transfer->save(); + + return $transfer; + } + + /** + * Get transfers for a business. + */ + public function getTransfersForBusiness(Business $business, array $filters = []): Collection + { + $query = BankTransfer::where('business_id', $business->id) + ->with(['fromAccount', 'toAccount', 'createdBy', 'approvedBy']); + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['from_date'])) { + $query->where('transfer_date', '>=', $filters['from_date']); + } + + if (! empty($filters['to_date'])) { + $query->where('transfer_date', '<=', $filters['to_date']); + } + + return $query->orderBy('transfer_date', 'desc') + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get primary bank account for a business. + */ + public function getPrimaryAccount(Business $business): ?BankAccount + { + return BankAccount::where('business_id', $business->id) + ->where('is_primary', true) + ->where('is_active', true) + ->first(); + } +} diff --git a/app/Services/Accounting/BankReconciliationService.php b/app/Services/Accounting/BankReconciliationService.php new file mode 100644 index 00000000..38a914d5 --- /dev/null +++ b/app/Services/Accounting/BankReconciliationService.php @@ -0,0 +1,382 @@ +id)->first(); + + if (! $plaidAccount) { + return collect(); + } + + $query = PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->unmatched() + ->notPending() + ->orderBy('date', 'desc'); + + if ($startDate) { + $query->where('date', '>=', $startDate); + } + if ($endDate) { + $query->where('date', '<=', $endDate); + } + + return $query->limit($limit)->get(); + } + + /** + * Get proposed auto-match transactions for a bank account. + */ + public function getProposedAutoMatches(BankAccount $bankAccount): Collection + { + $plaidAccount = PlaidAccount::where('bank_account_id', $bankAccount->id)->first(); + + if (! $plaidAccount) { + return collect(); + } + + return PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->proposedAuto() + ->notPending() + ->with('matchedByRule') + ->orderBy('date', 'desc') + ->get(); + } + + /** + * Get all transactions needing review for a bank account. + */ + public function getTransactionsNeedingReview(BankAccount $bankAccount): Collection + { + $plaidAccount = PlaidAccount::where('bank_account_id', $bankAccount->id)->first(); + + if (! $plaidAccount) { + return collect(); + } + + return PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->needsReview() + ->notPending() + ->with('matchedByRule') + ->orderBy('date', 'desc') + ->get(); + } + + /** + * Get matched transactions for a bank account. + */ + public function getMatchedTransactions( + BankAccount $bankAccount, + ?\DateTime $startDate = null, + ?\DateTime $endDate = null, + int $limit = 100 + ): Collection { + $plaidAccount = PlaidAccount::where('bank_account_id', $bankAccount->id)->first(); + + if (! $plaidAccount) { + return collect(); + } + + $query = PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->matched() + ->with(['journalEntry', 'apPayment', 'matchedByUser']) + ->orderBy('date', 'desc'); + + if ($startDate) { + $query->where('date', '>=', $startDate); + } + if ($endDate) { + $query->where('date', '<=', $endDate); + } + + return $query->limit($limit)->get(); + } + + /** + * Match a transaction to an AP payment. + */ + public function matchToApPayment( + PlaidTransaction $transaction, + ApPayment $payment, + User $user + ): PlaidTransaction { + return DB::transaction(function () use ($transaction, $payment, $user) { + $transaction->matchToApPayment($payment, $user); + + // Learn from this match + if ($transaction->plaidAccount?->bankAccount) { + BankMatchRule::learnFromMatch( + $transaction, + $transaction->plaidAccount->bankAccount, + BankMatchRule::TARGET_AP_PAYMENT, + $payment->id + ); + } + + return $transaction->fresh(); + }); + } + + /** + * Match a transaction to a journal entry. + */ + public function matchToJournalEntry( + PlaidTransaction $transaction, + JournalEntry $entry, + User $user + ): PlaidTransaction { + return DB::transaction(function () use ($transaction, $entry, $user) { + $transaction->matchToJournalEntry($entry, $user); + + // Learn from this match + if ($transaction->plaidAccount?->bankAccount) { + BankMatchRule::learnFromMatch( + $transaction, + $transaction->plaidAccount->bankAccount, + BankMatchRule::TARGET_JOURNAL_ENTRY, + $entry->id + ); + } + + return $transaction->fresh(); + }); + } + + /** + * Run auto-matching on unmatched transactions. + */ + public function runAutoMatching(BankAccount $bankAccount): int + { + $plaidAccount = PlaidAccount::where('bank_account_id', $bankAccount->id)->first(); + + if (! $plaidAccount) { + return 0; + } + + $rules = BankMatchRule::where('bank_account_id', $bankAccount->id) + ->autoEnabled() + ->get(); + + if ($rules->isEmpty()) { + return 0; + } + + $transactions = PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->unmatched() + ->notPending() + ->get(); + + $matched = 0; + foreach ($transactions as $transaction) { + foreach ($rules as $rule) { + if ($rule->matchesTransaction($transaction)) { + $transaction->proposeAutoMatch($rule); + $matched++; + break; // Only one rule per transaction + } + } + } + + return $matched; + } + + /** + * Confirm selected auto-matches. + */ + public function confirmAutoMatches(array $transactionIds, User $user): int + { + $confirmed = 0; + + DB::transaction(function () use ($transactionIds, $user, &$confirmed) { + $transactions = PlaidTransaction::whereIn('id', $transactionIds) + ->proposedAuto() + ->get(); + + foreach ($transactions as $transaction) { + $transaction->confirmAutoMatch($user); + $confirmed++; + } + }); + + return $confirmed; + } + + /** + * Reject selected auto-matches. + */ + public function rejectAutoMatches(array $transactionIds, User $user): int + { + $rejected = 0; + + DB::transaction(function () use ($transactionIds, $user, &$rejected) { + $transactions = PlaidTransaction::whereIn('id', $transactionIds) + ->proposedAuto() + ->get(); + + foreach ($transactions as $transaction) { + $transaction->rejectAutoMatch($user); + $rejected++; + } + }); + + return $rejected; + } + + /** + * Ignore selected transactions. + */ + public function ignoreTransactions(array $transactionIds): int + { + return PlaidTransaction::whereIn('id', $transactionIds) + ->whereIn('status', [PlaidTransaction::STATUS_UNMATCHED, PlaidTransaction::STATUS_PROPOSED_AUTO]) + ->update(['status' => PlaidTransaction::STATUS_IGNORED]); + } + + /** + * Get match rules for a bank account. + */ + public function getMatchRules(BankAccount $bankAccount): Collection + { + return BankMatchRule::where('bank_account_id', $bankAccount->id) + ->with('targetGlAccount') + ->orderBy('training_count', 'desc') + ->get(); + } + + /** + * Get rules eligible for auto-enable. + */ + public function getEligibleRules(BankAccount $bankAccount): Collection + { + return BankMatchRule::where('bank_account_id', $bankAccount->id) + ->eligibleForAutoEnable() + ->orderBy('training_count', 'desc') + ->get(); + } + + /** + * Toggle auto-enable for a rule. + */ + public function toggleRuleAutoEnable(BankMatchRule $rule, bool $enabled): void + { + if ($enabled && $rule->training_count < BankMatchRule::MIN_TRAINING_COUNT) { + throw new \Exception('Rule must have at least '.BankMatchRule::MIN_TRAINING_COUNT.' training matches to enable auto-matching.'); + } + + $rule->update(['auto_enabled' => $enabled]); + } + + /** + * Get reconciliation summary for a bank account. + */ + public function getReconciliationSummary(BankAccount $bankAccount): array + { + $plaidAccount = PlaidAccount::where('bank_account_id', $bankAccount->id)->first(); + + if (! $plaidAccount) { + return [ + 'has_plaid' => false, + 'plaid_balance' => null, + 'gl_balance' => $this->getGlBalance($bankAccount), + 'unmatched_count' => 0, + 'proposed_count' => 0, + 'matched_count' => 0, + 'difference' => null, + ]; + } + + $glBalance = $this->getGlBalance($bankAccount); + $plaidBalance = (float) $plaidAccount->current_balance; + + return [ + 'has_plaid' => true, + 'plaid_balance' => $plaidBalance, + 'gl_balance' => $glBalance, + 'difference' => $plaidBalance - $glBalance, + 'unmatched_count' => PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->unmatched()->notPending()->count(), + 'proposed_count' => PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->proposedAuto()->notPending()->count(), + 'matched_count' => PlaidTransaction::where('plaid_account_id', $plaidAccount->id) + ->matched()->count(), + 'last_synced_at' => $plaidAccount->last_synced_at, + ]; + } + + /** + * Get GL balance for a bank account. + */ + protected function getGlBalance(BankAccount $bankAccount): float + { + if (! $bankAccount->gl_account_id) { + return (float) $bankAccount->current_balance; + } + + // Calculate from journal entries + // This would use the JournalEntry service to get the GL account balance + // For now, return the cached balance + return (float) $bankAccount->current_balance; + } + + /** + * Find potential matches for a transaction. + */ + public function findPotentialMatches(PlaidTransaction $transaction, Business $business): array + { + $matches = [ + 'ap_payments' => collect(), + 'journal_entries' => collect(), + ]; + + $amount = $transaction->absolute_amount; + $tolerance = 0.50; + + // Find AP payments with similar amounts (for debits) + if ($transaction->is_debit) { + $matches['ap_payments'] = ApPayment::where('business_id', $business->id) + ->whereBetween('amount', [$amount - $tolerance, $amount + $tolerance]) + ->where('payment_date', '>=', $transaction->date->copy()->subDays(5)) + ->where('payment_date', '<=', $transaction->date->copy()->addDays(5)) + ->whereDoesntHave('plaidTransactions') + ->with('bill.vendor') + ->limit(10) + ->get(); + } + + // Find journal entries with similar amounts + $matches['journal_entries'] = JournalEntry::where('business_id', $business->id) + ->where('entry_date', '>=', $transaction->date->copy()->subDays(5)) + ->where('entry_date', '<=', $transaction->date->copy()->addDays(5)) + ->whereHas('lines', function ($query) use ($amount, $tolerance) { + $query->whereBetween('debit', [$amount - $tolerance, $amount + $tolerance]) + ->orWhereBetween('credit', [$amount - $tolerance, $amount + $tolerance]); + }) + ->limit(10) + ->get(); + + return $matches; + } +} diff --git a/app/Services/Accounting/BillService.php b/app/Services/Accounting/BillService.php index cb1d263d..2f196565 100644 --- a/app/Services/Accounting/BillService.php +++ b/app/Services/Accounting/BillService.php @@ -6,12 +6,17 @@ use App\Models\Accounting\ApBill; use App\Models\Accounting\ApBillItem; use App\Models\PurchaseOrder; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * Service for managing AP Bills. */ class BillService { + public function __construct( + protected ?JournalEntryService $journalEntryService = null + ) {} + /** * Create a bill from a Purchase Order. */ @@ -138,6 +143,10 @@ class BillService /** * Approve a bill for payment. + * + * Creates a journal entry: + * DR: Expense accounts (from bill items) + * CR: Accounts Payable */ public function approveBill(ApBill $bill, ?int $approverId = null): ApBill { @@ -145,13 +154,26 @@ class BillService throw new \InvalidArgumentException("Bill {$bill->bill_number} cannot be approved from status: {$bill->status}"); } - $bill->update([ - 'status' => ApBill::STATUS_APPROVED, - 'approved_by_user_id' => $approverId ?? auth()->id(), - 'approved_at' => now(), - ]); + return DB::transaction(function () use ($bill, $approverId) { + $bill->update([ + 'status' => ApBill::STATUS_APPROVED, + 'approved_by_user_id' => $approverId ?? auth()->id(), + 'approved_at' => now(), + ]); - return $bill->fresh(); + // Create journal entry for the approved bill + if ($this->journalEntryService) { + try { + $bill->load('items.glAccount', 'vendor'); + $this->journalEntryService->createBillApprovalEntry($bill); + } catch (\Exception $e) { + Log::error("Failed to create JE for bill {$bill->bill_number}: ".$e->getMessage()); + // Don't fail the approval if JE creation fails + } + } + + return $bill->fresh(); + }); } /** diff --git a/app/Services/Accounting/FinanceAnalyticsService.php b/app/Services/Accounting/FinanceAnalyticsService.php index 9b2c486b..3144a093 100644 --- a/app/Services/Accounting/FinanceAnalyticsService.php +++ b/app/Services/Accounting/FinanceAnalyticsService.php @@ -5,6 +5,8 @@ namespace App\Services\Accounting; use App\Models\Accounting\ApBill; use App\Models\Accounting\ApPayment; use App\Models\Accounting\ApVendor; +use App\Models\Accounting\ArCustomer; +use App\Models\Accounting\ArInvoice; use App\Models\Business; use Illuminate\Support\Collection; @@ -169,6 +171,7 @@ class FinanceAnalyticsService /** * Get divisional rollup (for parent company only). + * Includes both AP and AR metrics for each division. */ public function getDivisionRollup(Business $business): Collection { @@ -180,11 +183,12 @@ class FinanceAnalyticsService $yearStart = now()->startOfYear(); return $children->map(function ($child) use ($yearStart) { - $outstanding = ApBill::where('business_id', $child->id) + // AP Metrics + $apOutstanding = ApBill::where('business_id', $child->id) ->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL]) ->sum('balance_due'); - $overdue = ApBill::where('business_id', $child->id) + $apOverdue = ApBill::where('business_id', $child->id) ->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL]) ->where('due_date', '<', now()) ->sum('balance_due'); @@ -198,12 +202,43 @@ class FinanceAnalyticsService ->whereIn('status', [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING]) ->count(); + // AR Metrics + $arTotal = ArInvoice::where('business_id', $child->id) + ->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE]) + ->sum('balance_due'); + + $arOverdue = ArInvoice::where('business_id', $child->id) + ->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE]) + ->where('due_date', '<', now()) + ->sum('balance_due'); + + // Count at-risk customers (overdue or on credit hold) + $atRiskCustomers = ArCustomer::where('business_id', $child->id) + ->where(function ($query) { + $query->where('on_credit_hold', true) + ->orWhereHas('invoices', function ($q) { + $q->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE]) + ->where('due_date', '<', now()); + }); + }) + ->count(); + + $onHoldCustomers = ArCustomer::where('business_id', $child->id) + ->where('on_credit_hold', true) + ->count(); + return [ 'division' => $child, - 'ap_outstanding' => (float) $outstanding, - 'ap_overdue' => (float) $overdue, + // AP + 'ap_outstanding' => (float) $apOutstanding, + 'ap_overdue' => (float) $apOverdue, 'ytd_payments' => (float) $ytdPayments, 'pending_approval' => $pendingApproval, + // AR + 'ar_total' => (float) $arTotal, + 'ar_overdue' => (float) $arOverdue, + 'ar_at_risk' => $atRiskCustomers, + 'ar_on_hold' => $onHoldCustomers, ]; })->sortByDesc('ap_outstanding')->values(); } diff --git a/app/Services/Accounting/InventoryValuationService.php b/app/Services/Accounting/InventoryValuationService.php new file mode 100644 index 00000000..0ad80698 --- /dev/null +++ b/app/Services/Accounting/InventoryValuationService.php @@ -0,0 +1,300 @@ +active() + ->inStock() + ->get(); + + $totalValue = $items->sum(fn ($item) => $item->total_cost); + $totalItems = $items->count(); + $totalQuantity = $items->sum('quantity_on_hand'); + + return [ + 'total_value' => (float) $totalValue, + 'total_items' => $totalItems, + 'total_quantity' => (float) $totalQuantity, + 'avg_value_per_item' => $totalItems > 0 ? $totalValue / $totalItems : 0, + ]; + } + + /** + * Get valuation breakdown by item type. + */ + public function getValuationByType(array $businessIds): Collection + { + return InventoryItem::whereIn('business_id', $businessIds) + ->active() + ->select( + 'item_type', + DB::raw('COUNT(*) as item_count'), + DB::raw('SUM(quantity_on_hand) as total_quantity'), + DB::raw('SUM(quantity_on_hand * COALESCE(unit_cost, 0)) as total_value'), + DB::raw('AVG(unit_cost) as avg_unit_cost') + ) + ->groupBy('item_type') + ->orderByDesc('total_value') + ->get() + ->map(function ($row) { + return [ + 'item_type' => $row->item_type, + 'item_type_label' => $this->getItemTypeLabel($row->item_type), + 'item_count' => (int) $row->item_count, + 'total_quantity' => (float) $row->total_quantity, + 'total_value' => (float) $row->total_value, + 'avg_unit_cost' => (float) $row->avg_unit_cost, + ]; + }); + } + + /** + * Get valuation breakdown by division/business. + */ + public function getValuationByDivision(array $businessIds): Collection + { + return InventoryItem::whereIn('inventory_items.business_id', $businessIds) + ->join('businesses', 'inventory_items.business_id', '=', 'businesses.id') + ->active() + ->select( + 'businesses.id as business_id', + 'businesses.name as business_name', + DB::raw('COUNT(*) as item_count'), + DB::raw('SUM(inventory_items.quantity_on_hand) as total_quantity'), + DB::raw('SUM(inventory_items.quantity_on_hand * COALESCE(inventory_items.unit_cost, 0)) as total_value') + ) + ->groupBy('businesses.id', 'businesses.name') + ->orderByDesc('total_value') + ->get() + ->map(function ($row) { + return [ + 'business_id' => $row->business_id, + 'business_name' => $row->business_name, + 'item_count' => (int) $row->item_count, + 'total_quantity' => (float) $row->total_quantity, + 'total_value' => (float) $row->total_value, + ]; + }); + } + + /** + * Get valuation breakdown by category. + */ + public function getValuationByCategory(array $businessIds): Collection + { + return InventoryItem::whereIn('business_id', $businessIds) + ->active() + ->whereNotNull('category') + ->select( + 'category', + DB::raw('COUNT(*) as item_count'), + DB::raw('SUM(quantity_on_hand) as total_quantity'), + DB::raw('SUM(quantity_on_hand * COALESCE(unit_cost, 0)) as total_value'), + DB::raw('AVG(unit_cost) as avg_unit_cost') + ) + ->groupBy('category') + ->orderByDesc('total_value') + ->get() + ->map(function ($row) { + return [ + 'category' => $row->category, + 'item_count' => (int) $row->item_count, + 'total_quantity' => (float) $row->total_quantity, + 'total_value' => (float) $row->total_value, + 'avg_unit_cost' => (float) $row->avg_unit_cost, + ]; + }); + } + + /** + * Get valuation breakdown by location. + */ + public function getValuationByLocation(array $businessIds): Collection + { + return InventoryItem::whereIn('inventory_items.business_id', $businessIds) + ->leftJoin('locations', 'inventory_items.location_id', '=', 'locations.id') + ->active() + ->select( + 'locations.id as location_id', + DB::raw("COALESCE(locations.name, 'No Location') as location_name"), + DB::raw('COUNT(*) as item_count'), + DB::raw('SUM(inventory_items.quantity_on_hand) as total_quantity'), + DB::raw('SUM(inventory_items.quantity_on_hand * COALESCE(inventory_items.unit_cost, 0)) as total_value') + ) + ->groupBy('locations.id', 'locations.name') + ->orderByDesc('total_value') + ->get() + ->map(function ($row) { + return [ + 'location_id' => $row->location_id, + 'location_name' => $row->location_name, + 'item_count' => (int) $row->item_count, + 'total_quantity' => (float) $row->total_quantity, + 'total_value' => (float) $row->total_value, + ]; + }); + } + + /** + * Get top inventory items by value. + */ + public function getTopItemsByValue(array $businessIds, int $limit = 10): Collection + { + return InventoryItem::whereIn('inventory_items.business_id', $businessIds) + ->join('businesses', 'inventory_items.business_id', '=', 'businesses.id') + ->leftJoin('products', 'inventory_items.product_id', '=', 'products.id') + ->active() + ->inStock() + ->whereNotNull('inventory_items.unit_cost') + ->where('inventory_items.unit_cost', '>', 0) + ->select( + 'inventory_items.*', + 'businesses.name as business_name', + 'products.name as product_name', + 'products.sku as product_sku', + DB::raw('(inventory_items.quantity_on_hand * inventory_items.unit_cost) as total_value') + ) + ->orderByDesc(DB::raw('inventory_items.quantity_on_hand * inventory_items.unit_cost')) + ->limit($limit) + ->get() + ->map(function ($item) { + return [ + 'id' => $item->id, + 'name' => $item->name, + 'sku' => $item->sku ?? $item->product_sku, + 'product_name' => $item->product_name, + 'business_name' => $item->business_name, + 'item_type' => $item->item_type, + 'item_type_label' => $this->getItemTypeLabel($item->item_type), + 'category' => $item->category, + 'quantity_on_hand' => (float) $item->quantity_on_hand, + 'unit_of_measure' => $item->unit_of_measure, + 'unit_cost' => (float) $item->unit_cost, + 'total_value' => (float) $item->total_value, + ]; + }); + } + + /** + * Get inventory aging (based on received date). + */ + public function getInventoryAging(array $businessIds): array + { + $now = now(); + + $aging = [ + '0-30' => ['label' => '0-30 Days', 'count' => 0, 'value' => 0], + '31-60' => ['label' => '31-60 Days', 'count' => 0, 'value' => 0], + '61-90' => ['label' => '61-90 Days', 'count' => 0, 'value' => 0], + '91-180' => ['label' => '91-180 Days', 'count' => 0, 'value' => 0], + '180+' => ['label' => '180+ Days', 'count' => 0, 'value' => 0], + 'no_date' => ['label' => 'No Date', 'count' => 0, 'value' => 0], + ]; + + $items = InventoryItem::whereIn('business_id', $businessIds) + ->active() + ->inStock() + ->get(); + + foreach ($items as $item) { + $value = $item->total_cost; + + if (! $item->received_date) { + $aging['no_date']['count']++; + $aging['no_date']['value'] += $value; + + continue; + } + + $days = $item->received_date->diffInDays($now); + + if ($days <= 30) { + $aging['0-30']['count']++; + $aging['0-30']['value'] += $value; + } elseif ($days <= 60) { + $aging['31-60']['count']++; + $aging['31-60']['value'] += $value; + } elseif ($days <= 90) { + $aging['61-90']['count']++; + $aging['61-90']['value'] += $value; + } elseif ($days <= 180) { + $aging['91-180']['count']++; + $aging['91-180']['value'] += $value; + } else { + $aging['180+']['count']++; + $aging['180+']['value'] += $value; + } + } + + return $aging; + } + + /** + * Get inventory at risk (quarantined, expiring, expired). + */ + public function getInventoryAtRisk(array $businessIds): array + { + $quarantined = InventoryItem::whereIn('business_id', $businessIds) + ->active() + ->quarantined() + ->get(); + + $expiringSoon = InventoryItem::whereIn('business_id', $businessIds) + ->active() + ->expiringSoon(30) + ->get(); + + $expired = InventoryItem::whereIn('business_id', $businessIds) + ->active() + ->expired() + ->get(); + + return [ + 'quarantined' => [ + 'count' => $quarantined->count(), + 'value' => (float) $quarantined->sum(fn ($item) => $item->total_cost), + ], + 'expiring_soon' => [ + 'count' => $expiringSoon->count(), + 'value' => (float) $expiringSoon->sum(fn ($item) => $item->total_cost), + ], + 'expired' => [ + 'count' => $expired->count(), + 'value' => (float) $expired->sum(fn ($item) => $item->total_cost), + ], + ]; + } + + /** + * Get human-readable item type label. + */ + protected function getItemTypeLabel(string $type): string + { + return match ($type) { + 'raw_material' => 'Raw Materials', + 'work_in_progress' => 'Work in Progress', + 'finished_good' => 'Finished Goods', + 'packaging' => 'Packaging', + 'other' => 'Other', + default => ucfirst(str_replace('_', ' ', $type)), + }; + } +} diff --git a/app/Services/Accounting/JournalEntryService.php b/app/Services/Accounting/JournalEntryService.php new file mode 100644 index 00000000..7de19407 --- /dev/null +++ b/app/Services/Accounting/JournalEntryService.php @@ -0,0 +1,554 @@ + $bill->business_id, + 'entry_number' => JournalEntry::generateEntryNumber($bill->business_id), + 'entry_date' => $bill->bill_date, + 'description' => "Bill {$bill->bill_number} - {$bill->vendor->name}", + 'reference' => $bill->vendor_invoice_number, + 'source_type' => JournalEntry::SOURCE_AP_BILL, + 'source_id' => $bill->id, + 'status' => JournalEntry::STATUS_POSTED, + 'is_inter_company' => false, + 'is_auto_generated' => true, + 'created_by' => auth()->id(), + 'posted_by' => auth()->id(), + 'posted_at' => now(), + ]); + + $lineOrder = 1; + + // Get or create AP account + $apAccount = $this->getOrCreateSystemAccount( + $bill->business_id, + 'accounts_payable', + '2000', + 'Accounts Payable', + GlAccount::TYPE_LIABILITY + ); + + // Debit expense accounts from bill items + foreach ($bill->items as $item) { + if ($item->gl_account_id && bccomp((string) $item->line_total, '0', 2) > 0) { + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'gl_account_id' => $item->gl_account_id, + 'department_id' => $item->department_id, + 'description' => $item->description, + 'debit_amount' => $item->line_total, + 'credit_amount' => 0, + 'line_order' => $lineOrder++, + ]); + } + } + + // Add tax as separate debit if applicable + if (bccomp((string) $bill->tax_amount, '0', 2) > 0) { + $taxAccount = $this->getOrCreateSystemAccount( + $bill->business_id, + 'tax_payable', + '2100', + 'Sales Tax Payable', + GlAccount::TYPE_LIABILITY + ); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'gl_account_id' => $taxAccount->id, + 'department_id' => null, + 'description' => 'Sales Tax', + 'debit_amount' => $bill->tax_amount, + 'credit_amount' => 0, + 'line_order' => $lineOrder++, + ]); + } + + // Credit Accounts Payable for total + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'gl_account_id' => $apAccount->id, + 'department_id' => null, + 'description' => "AP - {$bill->vendor->name}", + 'debit_amount' => 0, + 'credit_amount' => $bill->total, + 'line_order' => $lineOrder, + ]); + + // Link the entry to the bill + $bill->update(['journal_entry_id' => $entry->id]); + + return $entry->load('lines.glAccount'); + }); + } + + /** + * Create journal entries for a payment. + * + * For same-business payment: + * DR: Accounts Payable + * CR: Cash/Bank + * + * For parent-pays-child (inter-company): + * Parent JE: + * DR: Inter-Company Due From (Child) + * CR: Cash/Bank + * Child JE: + * DR: Accounts Payable + * CR: Inter-Company Due To (Parent) + */ + public function createPaymentEntry(ApPayment $payment): array + { + return DB::transaction(function () use ($payment) { + $entries = []; + $payingBusiness = $payment->business; + + // Group applications by bill's business + $applicationsByBusiness = $payment->applications->groupBy(function ($app) { + return $app->bill->business_id; + }); + + foreach ($applicationsByBusiness as $billBusinessId => $applications) { + $billBusiness = Business::find($billBusinessId); + $totalAmount = $applications->sum('amount_applied'); + $totalDiscount = $applications->sum('discount_taken'); + + $isInterCompany = $billBusinessId !== $payingBusiness->id; + + if ($isInterCompany) { + // Inter-company payment: parent paying for child + $entries = array_merge( + $entries, + $this->createInterCompanyPaymentEntries( + $payment, + $payingBusiness, + $billBusiness, + (float) $totalAmount, + (float) $totalDiscount, + $applications + ) + ); + } else { + // Same-business payment + $entry = $this->createSameBusinessPaymentEntry( + $payment, + (float) $totalAmount, + (float) $totalDiscount, + $applications + ); + $entries[] = $entry; + } + } + + // Link primary entry to payment + if (! empty($entries)) { + $payment->update(['journal_entry_id' => $entries[0]->id]); + } + + return $entries; + }); + } + + /** + * Create a payment JE for same-business transaction. + */ + protected function createSameBusinessPaymentEntry( + ApPayment $payment, + float $amount, + float $discount, + $applications + ): JournalEntry { + $entry = JournalEntry::create([ + 'business_id' => $payment->business_id, + 'entry_number' => JournalEntry::generateEntryNumber($payment->business_id), + 'entry_date' => $payment->payment_date, + 'description' => "Payment {$payment->payment_number}", + 'reference' => $payment->reference_number, + 'source_type' => JournalEntry::SOURCE_AP_PAYMENT, + 'source_id' => $payment->id, + 'status' => JournalEntry::STATUS_POSTED, + 'is_inter_company' => false, + 'is_auto_generated' => true, + 'created_by' => auth()->id(), + 'posted_by' => auth()->id(), + 'posted_at' => now(), + ]); + + $lineOrder = 1; + + // Get system accounts + $apAccount = $this->getOrCreateSystemAccount( + $payment->business_id, + 'accounts_payable', + '2000', + 'Accounts Payable', + GlAccount::TYPE_LIABILITY + ); + + $cashAccount = $this->getOrCreateCashAccount($payment); + + // Debit AP for total paid (amount + discount) + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'gl_account_id' => $apAccount->id, + 'department_id' => null, + 'description' => 'Reduce AP', + 'debit_amount' => bcadd((string) $amount, (string) $discount, 2), + 'credit_amount' => 0, + 'line_order' => $lineOrder++, + ]); + + // Credit Cash for actual payment + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'gl_account_id' => $cashAccount->id, + 'department_id' => null, + 'description' => "Payment - {$payment->payment_method}", + 'debit_amount' => 0, + 'credit_amount' => $amount, + 'line_order' => $lineOrder++, + ]); + + // If discount taken, credit discount income + if (bccomp((string) $discount, '0', 2) > 0) { + $discountAccount = $this->getOrCreateSystemAccount( + $payment->business_id, + 'purchase_discounts', + '4900', + 'Purchase Discounts', + GlAccount::TYPE_REVENUE + ); + + JournalEntryLine::create([ + 'journal_entry_id' => $entry->id, + 'gl_account_id' => $discountAccount->id, + 'department_id' => null, + 'description' => 'Early payment discount', + 'debit_amount' => 0, + 'credit_amount' => $discount, + 'line_order' => $lineOrder, + ]); + } + + return $entry->load('lines.glAccount'); + } + + /** + * Create inter-company payment entries (parent pays for child). + * + * Returns two JournalEntry objects: + * 1. Parent entry: DR Due From Child, CR Cash + * 2. Child entry: DR AP, CR Due To Parent + */ + protected function createInterCompanyPaymentEntries( + ApPayment $payment, + Business $parentBusiness, + Business $childBusiness, + float $amount, + float $discount, + $applications + ): array { + $entries = []; + + // Get inter-company accounts + $parentDueFrom = $this->getOrCreateInterCompanyAccount( + $parentBusiness->id, + $childBusiness, + 'due_from' + ); + + $childDueTo = $this->getOrCreateInterCompanyAccount( + $childBusiness->id, + $parentBusiness, + 'due_to' + ); + + $parentCash = $this->getOrCreateCashAccount($payment); + + $childAp = $this->getOrCreateSystemAccount( + $childBusiness->id, + 'accounts_payable', + '2000', + 'Accounts Payable', + GlAccount::TYPE_LIABILITY + ); + + // Parent entry: DR Due From Child, CR Cash + $parentEntry = JournalEntry::create([ + 'business_id' => $parentBusiness->id, + 'entry_number' => JournalEntry::generateEntryNumber($parentBusiness->id), + 'entry_date' => $payment->payment_date, + 'description' => "IC Payment {$payment->payment_number} for {$childBusiness->name}", + 'reference' => $payment->reference_number, + 'source_type' => JournalEntry::SOURCE_INTER_COMPANY, + 'source_id' => $payment->id, + 'status' => JournalEntry::STATUS_POSTED, + 'is_inter_company' => true, + 'is_auto_generated' => true, + 'created_by' => auth()->id(), + 'posted_by' => auth()->id(), + 'posted_at' => now(), + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $parentEntry->id, + 'gl_account_id' => $parentDueFrom->id, + 'department_id' => null, + 'description' => "Due From {$childBusiness->name}", + 'debit_amount' => $amount, + 'credit_amount' => 0, + 'line_order' => 1, + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $parentEntry->id, + 'gl_account_id' => $parentCash->id, + 'department_id' => null, + 'description' => "Payment - {$payment->payment_method}", + 'debit_amount' => 0, + 'credit_amount' => $amount, + 'line_order' => 2, + ]); + + $entries[] = $parentEntry->load('lines.glAccount'); + + // Child entry: DR AP, CR Due To Parent + $childEntry = JournalEntry::create([ + 'business_id' => $childBusiness->id, + 'entry_number' => JournalEntry::generateEntryNumber($childBusiness->id), + 'entry_date' => $payment->payment_date, + 'description' => "IC Payment from {$parentBusiness->name} - {$payment->payment_number}", + 'reference' => $payment->reference_number, + 'source_type' => JournalEntry::SOURCE_INTER_COMPANY, + 'source_id' => $payment->id, + 'status' => JournalEntry::STATUS_POSTED, + 'is_inter_company' => true, + 'is_auto_generated' => true, + 'created_by' => auth()->id(), + 'posted_by' => auth()->id(), + 'posted_at' => now(), + ]); + + $totalReduction = bcadd((string) $amount, (string) $discount, 2); + + JournalEntryLine::create([ + 'journal_entry_id' => $childEntry->id, + 'gl_account_id' => $childAp->id, + 'department_id' => null, + 'description' => 'Reduce AP', + 'debit_amount' => $totalReduction, + 'credit_amount' => 0, + 'line_order' => 1, + ]); + + JournalEntryLine::create([ + 'journal_entry_id' => $childEntry->id, + 'gl_account_id' => $childDueTo->id, + 'department_id' => null, + 'description' => "Due To {$parentBusiness->name}", + 'debit_amount' => 0, + 'credit_amount' => $amount, + 'line_order' => 2, + ]); + + // Handle discount on child side + if (bccomp((string) $discount, '0', 2) > 0) { + $discountAccount = $this->getOrCreateSystemAccount( + $childBusiness->id, + 'purchase_discounts', + '4900', + 'Purchase Discounts', + GlAccount::TYPE_REVENUE + ); + + JournalEntryLine::create([ + 'journal_entry_id' => $childEntry->id, + 'gl_account_id' => $discountAccount->id, + 'department_id' => null, + 'description' => 'Early payment discount', + 'debit_amount' => 0, + 'credit_amount' => $discount, + 'line_order' => 3, + ]); + } + + $entries[] = $childEntry->load('lines.glAccount'); + + return $entries; + } + + /** + * Get or create a system GL account. + */ + protected function getOrCreateSystemAccount( + int $businessId, + string $subtype, + string $defaultNumber, + string $defaultName, + string $type + ): GlAccount { + $account = GlAccount::where('business_id', $businessId) + ->where('account_subtype', $subtype) + ->where('is_header', false) + ->first(); + + if (! $account) { + $account = GlAccount::create([ + 'business_id' => $businessId, + 'account_number' => $defaultNumber, + 'name' => $defaultName, + 'account_type' => $type, + 'account_subtype' => $subtype, + 'normal_balance' => in_array($type, [GlAccount::TYPE_ASSET, GlAccount::TYPE_EXPENSE]) + ? GlAccount::BALANCE_DEBIT + : GlAccount::BALANCE_CREDIT, + 'is_header' => false, + 'is_active' => true, + ]); + } + + return $account; + } + + /** + * Get or create an inter-company account (Due To / Due From). + */ + protected function getOrCreateInterCompanyAccount( + int $businessId, + Business $relatedBusiness, + string $direction + ): GlAccount { + $subtype = "ic_{$direction}_{$relatedBusiness->id}"; + + $account = GlAccount::where('business_id', $businessId) + ->where('account_subtype', $subtype) + ->first(); + + if (! $account) { + if ($direction === 'due_from') { + // Due From = Asset (we are owed money) + $account = GlAccount::create([ + 'business_id' => $businessId, + 'account_number' => "1500-{$relatedBusiness->id}", + 'name' => "Due From {$relatedBusiness->name}", + 'account_type' => GlAccount::TYPE_ASSET, + 'account_subtype' => $subtype, + 'normal_balance' => GlAccount::BALANCE_DEBIT, + 'is_header' => false, + 'is_active' => true, + ]); + } else { + // Due To = Liability (we owe money) + $account = GlAccount::create([ + 'business_id' => $businessId, + 'account_number' => "2500-{$relatedBusiness->id}", + 'name' => "Due To {$relatedBusiness->name}", + 'account_type' => GlAccount::TYPE_LIABILITY, + 'account_subtype' => $subtype, + 'normal_balance' => GlAccount::BALANCE_CREDIT, + 'is_header' => false, + 'is_active' => true, + ]); + } + } + + return $account; + } + + /** + * Get or create cash/bank account for a payment. + */ + protected function getOrCreateCashAccount(ApPayment $payment): GlAccount + { + // If payment has a bank account, use its GL account + if ($payment->bank_account_id && $payment->bankAccount?->gl_account_id) { + return $payment->bankAccount->glAccount; + } + + // Default to general cash account + return $this->getOrCreateSystemAccount( + $payment->business_id, + 'cash', + '1000', + 'Cash', + GlAccount::TYPE_ASSET + ); + } + + /** + * Create a reversal entry for an existing journal entry. + */ + public function createReversalEntry(JournalEntry $originalEntry, ?string $reason = null): JournalEntry + { + return DB::transaction(function () use ($originalEntry, $reason) { + $reversalEntry = JournalEntry::create([ + 'business_id' => $originalEntry->business_id, + 'entry_number' => JournalEntry::generateEntryNumber($originalEntry->business_id), + 'entry_date' => now(), + 'description' => "Reversal of {$originalEntry->entry_number}", + 'reference' => $originalEntry->entry_number, + 'source_type' => $originalEntry->source_type, + 'source_id' => $originalEntry->source_id, + 'status' => JournalEntry::STATUS_POSTED, + 'is_inter_company' => $originalEntry->is_inter_company, + 'is_auto_generated' => true, + 'created_by' => auth()->id(), + 'posted_by' => auth()->id(), + 'posted_at' => now(), + ]); + + // Create reversed lines (swap debits and credits) + foreach ($originalEntry->lines as $line) { + JournalEntryLine::create([ + 'journal_entry_id' => $reversalEntry->id, + 'gl_account_id' => $line->gl_account_id, + 'department_id' => $line->department_id, + 'description' => "Reversal: {$line->description}", + 'debit_amount' => $line->credit_amount, + 'credit_amount' => $line->debit_amount, + 'line_order' => $line->line_order, + ]); + } + + // Mark original as reversed + $originalEntry->update([ + 'status' => JournalEntry::STATUS_REVERSED, + 'reversed_by' => auth()->id(), + 'reversed_at' => now(), + 'reversal_reason' => $reason, + ]); + + return $reversalEntry->load('lines.glAccount'); + }); + } +} diff --git a/app/Services/Accounting/PaymentService.php b/app/Services/Accounting/PaymentService.php index 837609bb..c802a47a 100644 --- a/app/Services/Accounting/PaymentService.php +++ b/app/Services/Accounting/PaymentService.php @@ -7,6 +7,7 @@ use App\Models\Accounting\ApPayment; use App\Models\Accounting\ApPaymentApplication; use App\Models\Business; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * Service for managing AP Payments. @@ -14,7 +15,8 @@ use Illuminate\Support\Facades\DB; class PaymentService { public function __construct( - protected BillService $billService + protected BillService $billService, + protected ?JournalEntryService $journalEntryService = null ) {} /** @@ -66,6 +68,7 @@ class PaymentService /** * Apply a payment to a specific bill. + * Bills must belong to the paying business or its child businesses. */ public function applyPaymentToBill( ApPayment $payment, @@ -73,7 +76,13 @@ class PaymentService float $amount, float $discount = 0 ): ApPaymentApplication { - $bill = ApBill::findOrFail($billId); + // Get allowed business IDs: the paying business and its children + $payingBusinessId = $payment->business_id; + $childBusinessIds = Business::where('parent_id', $payingBusinessId)->pluck('id')->toArray(); + $allowedBusinessIds = array_merge([$payingBusinessId], $childBusinessIds); + + // Find bill scoped to allowed businesses + $bill = ApBill::whereIn('business_id', $allowedBusinessIds)->findOrFail($billId); if (! in_array($bill->status, [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])) { throw new \InvalidArgumentException( @@ -102,6 +111,12 @@ class PaymentService /** * Complete a pending payment. + * + * Creates journal entries: + * - Same-business: DR AP, CR Cash + * - Inter-company (parent pays child): + * - Parent: DR Due From Child, CR Cash + * - Child: DR AP, CR Due To Parent */ public function completePayment(ApPayment $payment): ApPayment { @@ -111,9 +126,22 @@ class PaymentService ); } - $payment->update(['status' => ApPayment::STATUS_COMPLETED]); + return DB::transaction(function () use ($payment) { + $payment->update(['status' => ApPayment::STATUS_COMPLETED]); - return $payment->fresh(); + // Create journal entries for the payment + if ($this->journalEntryService) { + try { + $payment->load('applications.bill.business', 'business', 'bankAccount.glAccount'); + $this->journalEntryService->createPaymentEntry($payment); + } catch (\Exception $e) { + Log::error("Failed to create JE for payment {$payment->payment_number}: ".$e->getMessage()); + // Don't fail the payment if JE creation fails + } + } + + return $payment->fresh(); + }); } /** diff --git a/app/Services/Accounting/PlaidIntegrationService.php b/app/Services/Accounting/PlaidIntegrationService.php new file mode 100644 index 00000000..d8b64964 --- /dev/null +++ b/app/Services/Accounting/PlaidIntegrationService.php @@ -0,0 +1,369 @@ +plaidClient->itemPublicTokenExchange($publicToken); + + // For now, stub the response + $accessToken = 'access-sandbox-'.bin2hex(random_bytes(16)); + $itemId = 'item-sandbox-'.bin2hex(random_bytes(8)); + + return DB::transaction(function () use ($business, $accessToken, $itemId, $user) { + $plaidItem = PlaidItem::create([ + 'business_id' => $business->id, + 'plaid_item_id' => $itemId, + 'plaid_access_token' => encrypt($accessToken), + 'institution_name' => 'Sandbox Bank', // Would come from Plaid response + 'institution_id' => 'ins_sandbox', + 'status' => PlaidItem::STATUS_ACTIVE, + 'created_by_user_id' => $user->id, + ]); + + // Fetch accounts for this item + $this->syncAccountsForItem($plaidItem); + + return $plaidItem; + }); + } + + /** + * Sync accounts for a Plaid item. + */ + public function syncAccountsForItem(PlaidItem $plaidItem): Collection + { + // TODO: Call Plaid API to get accounts + // $response = $this->plaidClient->accountsGet($plaidItem->plaid_access_token); + + // For now, stub with sample accounts if none exist + if ($plaidItem->accounts()->count() === 0) { + $this->createStubAccounts($plaidItem); + } + + $plaidItem->markSynced(); + + return $plaidItem->accounts; + } + + /** + * Create stub accounts for testing. + */ + protected function createStubAccounts(PlaidItem $plaidItem): void + { + // This would be replaced by actual Plaid API response parsing + $stubAccounts = [ + [ + 'plaid_account_id' => 'account-'.bin2hex(random_bytes(8)), + 'name' => 'Checking', + 'official_name' => 'Business Checking Account', + 'mask' => '1234', + 'type' => PlaidAccount::TYPE_DEPOSITORY, + 'subtype' => 'checking', + 'current_balance' => 10000.00, + 'available_balance' => 9500.00, + ], + [ + 'plaid_account_id' => 'account-'.bin2hex(random_bytes(8)), + 'name' => 'Savings', + 'official_name' => 'Business Savings Account', + 'mask' => '5678', + 'type' => PlaidAccount::TYPE_DEPOSITORY, + 'subtype' => 'savings', + 'current_balance' => 50000.00, + 'available_balance' => 50000.00, + ], + ]; + + foreach ($stubAccounts as $accountData) { + PlaidAccount::create([ + 'plaid_item_id' => $plaidItem->id, + ...$accountData, + 'last_synced_at' => now(), + ]); + } + } + + /** + * Sync balances for all Plaid accounts in a business. + */ + public function syncBalances(Business $business): void + { + $plaidItems = PlaidItem::where('business_id', $business->id) + ->active() + ->with('accounts') + ->get(); + + foreach ($plaidItems as $item) { + try { + $this->syncBalancesForItem($item); + } catch (\Exception $e) { + Log::error('Failed to sync balances for Plaid item', [ + 'item_id' => $item->id, + 'error' => $e->getMessage(), + ]); + $item->markError($e->getMessage()); + } + } + } + + /** + * Sync balances for a specific Plaid item. + */ + public function syncBalancesForItem(PlaidItem $plaidItem): void + { + // TODO: Call Plaid API to get current balances + // $response = $this->plaidClient->accountsBalanceGet($plaidItem->plaid_access_token); + + // For now, stub - balances don't change + foreach ($plaidItem->accounts as $account) { + $account->update(['last_synced_at' => now()]); + + // Also update linked bank account if exists + if ($account->bankAccount) { + $account->bankAccount->update(['last_synced_at' => now()]); + } + } + + $plaidItem->markSynced(); + } + + /** + * Sync transactions for all Plaid accounts in a business. + */ + public function syncTransactions(Business $business, ?\DateTime $sinceDate = null): int + { + $sinceDate = $sinceDate ?? now()->subDays(30); + $totalSynced = 0; + + $plaidItems = PlaidItem::where('business_id', $business->id) + ->active() + ->with('accounts') + ->get(); + + foreach ($plaidItems as $item) { + try { + $synced = $this->syncTransactionsForItem($item, $sinceDate); + $totalSynced += $synced; + } catch (\Exception $e) { + Log::error('Failed to sync transactions for Plaid item', [ + 'item_id' => $item->id, + 'error' => $e->getMessage(), + ]); + $item->markError($e->getMessage()); + } + } + + return $totalSynced; + } + + /** + * Sync transactions for a specific Plaid item. + */ + public function syncTransactionsForItem(PlaidItem $plaidItem, \DateTime $sinceDate): int + { + // TODO: Call Plaid API to get transactions + // $response = $this->plaidClient->transactionsGet( + // $plaidItem->plaid_access_token, + // $sinceDate->format('Y-m-d'), + // now()->format('Y-m-d') + // ); + + $synced = 0; + + // For now, stub - create sample transactions if none exist recently + foreach ($plaidItem->accounts as $account) { + if ($account->transactions()->where('date', '>=', $sinceDate)->count() === 0) { + $synced += $this->createStubTransactions($account); + } + } + + $plaidItem->markSynced(); + + return $synced; + } + + /** + * Create stub transactions for testing. + */ + protected function createStubTransactions(PlaidAccount $plaidAccount): int + { + $stubTransactions = [ + [ + 'name' => 'PAYROLL DIRECT DEP', + 'merchant_name' => 'ADP Payroll', + 'amount' => -5000.00, // Credit (money in) + 'category' => ['Transfer', 'Payroll'], + 'date' => now()->subDays(5), + ], + [ + 'name' => 'UTILITY PAYMENT', + 'merchant_name' => 'City Power & Light', + 'amount' => 450.00, // Debit (money out) + 'category' => ['Service', 'Utilities'], + 'date' => now()->subDays(3), + ], + [ + 'name' => 'OFFICE SUPPLIES', + 'merchant_name' => 'Staples', + 'amount' => 125.50, // Debit + 'category' => ['Shops', 'Office Supplies'], + 'date' => now()->subDays(2), + ], + [ + 'name' => 'CUSTOMER PAYMENT', + 'merchant_name' => null, + 'amount' => -2500.00, // Credit + 'category' => ['Transfer', 'Credit'], + 'date' => now()->subDays(1), + ], + ]; + + $created = 0; + foreach ($stubTransactions as $txData) { + PlaidTransaction::create([ + 'plaid_account_id' => $plaidAccount->id, + 'plaid_transaction_id' => 'tx-'.bin2hex(random_bytes(12)), + 'date' => $txData['date'], + 'name' => $txData['name'], + 'merchant_name' => $txData['merchant_name'], + 'amount' => $txData['amount'], + 'currency' => 'USD', + 'pending' => false, + 'category' => $txData['category'], + 'status' => PlaidTransaction::STATUS_UNMATCHED, + ]); + $created++; + } + + return $created; + } + + /** + * Link a Plaid account to an internal bank account. + */ + public function linkPlaidAccountToBankAccount(PlaidAccount $plaidAccount, BankAccount $bankAccount): void + { + DB::transaction(function () use ($plaidAccount, $bankAccount) { + // Unlink any existing connection on this Plaid account + if ($plaidAccount->bank_account_id && $plaidAccount->bank_account_id !== $bankAccount->id) { + $plaidAccount->unlink(); + } + + // Link to new bank account + $plaidAccount->linkToBankAccount($bankAccount); + + // Sync balances immediately + $plaidAccount->updateBalances( + (float) $plaidAccount->current_balance, + $plaidAccount->available_balance ? (float) $plaidAccount->available_balance : null + ); + }); + } + + /** + * Create a new bank account from a Plaid account. + */ + public function createBankAccountFromPlaid( + PlaidAccount $plaidAccount, + Business $business, + User $user, + ?int $glAccountId = null + ): BankAccount { + return DB::transaction(function () use ($plaidAccount, $business, $user, $glAccountId) { + $bankAccount = BankAccount::create([ + 'business_id' => $business->id, + 'name' => $plaidAccount->official_name ?? $plaidAccount->name, + 'account_type' => $this->mapPlaidSubtypeToAccountType($plaidAccount->subtype), + 'bank_name' => $plaidAccount->plaidItem->institution_name, + 'account_number_last4' => $plaidAccount->mask, + 'current_balance' => $plaidAccount->current_balance ?? 0, + 'available_balance' => $plaidAccount->available_balance ?? 0, + 'gl_account_id' => $glAccountId, + 'currency' => $plaidAccount->currency, + 'is_active' => true, + 'plaid_account_id' => $plaidAccount->plaid_account_id, + 'last_synced_at' => now(), + 'created_by_user_id' => $user->id, + ]); + + $plaidAccount->update(['bank_account_id' => $bankAccount->id]); + + return $bankAccount; + }); + } + + /** + * Map Plaid subtype to our account type. + */ + protected function mapPlaidSubtypeToAccountType(?string $subtype): string + { + return match ($subtype) { + 'checking' => BankAccount::TYPE_CHECKING, + 'savings' => BankAccount::TYPE_SAVINGS, + 'money market' => BankAccount::TYPE_MONEY_MARKET, + default => BankAccount::TYPE_CHECKING, + }; + } + + /** + * Get unlinked Plaid accounts for a business. + */ + public function getUnlinkedAccounts(Business $business): Collection + { + return PlaidAccount::whereHas('plaidItem', function ($query) use ($business) { + $query->where('business_id', $business->id)->active(); + }) + ->unlinked() + ->active() + ->with('plaidItem') + ->get(); + } + + /** + * Disconnect a Plaid item. + */ + public function disconnectItem(PlaidItem $plaidItem): void + { + // TODO: Call Plaid API to revoke access + // $this->plaidClient->itemRemove($plaidItem->plaid_access_token); + + // Unlink all accounts + foreach ($plaidItem->accounts as $account) { + $account->unlink(); + } + + $plaidItem->update(['status' => PlaidItem::STATUS_REVOKED]); + } +} diff --git a/app/Services/BrandAccessService.php b/app/Services/BrandAccessService.php new file mode 100644 index 00000000..1dcc9d87 --- /dev/null +++ b/app/Services/BrandAccessService.php @@ -0,0 +1,247 @@ + + */ + public function getUserBrandIds(User $user, Business $business): array + { + // Business owners/admins can see all brands + if ($this->isBusinessAdmin($user, $business)) { + return Brand::where('business_id', $business->id)->pluck('id')->toArray(); + } + + // Get brands assigned to user via brand_user pivot + return DB::table('brand_user') + ->join('brands', 'brand_user.brand_id', '=', 'brands.id') + ->where('brand_user.user_id', $user->id) + ->where('brands.business_id', $business->id) + ->pluck('brands.id') + ->toArray(); + } + + /** + * Get brands that a user has access to. + */ + public function getUserBrands(User $user, Business $business): Collection + { + $brandIds = $this->getUserBrandIds($user, $business); + + return Brand::whereIn('id', $brandIds)->get(); + } + + /** + * Check if user has access to a specific brand. + */ + public function userHasBrandAccess(User $user, Brand $brand): bool + { + $business = $brand->business; + + // Business owners/admins can access all brands + if ($this->isBusinessAdmin($user, $business)) { + return true; + } + + // Check brand_user pivot + return DB::table('brand_user') + ->where('user_id', $user->id) + ->where('brand_id', $brand->id) + ->exists(); + } + + /** + * Scope a query to only include records for user's brands. + * + * Works with models that have a brand_id column. + */ + public function scopeForUserBrands(Builder $query, User $user, Business $business): Builder + { + $brandIds = $this->getUserBrandIds($user, $business); + + return $query->whereIn('brand_id', $brandIds); + } + + /** + * Scope products query to user's brands. + */ + public function scopeProductsForUser(Builder $query, User $user, Business $business): Builder + { + return $this->scopeForUserBrands($query, $user, $business); + } + + /** + * Scope orders to user's brands (via order items -> products -> brands). + */ + public function scopeOrdersForUser(Builder $query, User $user, Business $business): Builder + { + $brandIds = $this->getUserBrandIds($user, $business); + + return $query->whereHas('items.product', function ($q) use ($brandIds) { + $q->whereIn('brand_id', $brandIds); + }); + } + + /** + * Scope invoices to user's brands (via invoice items -> products -> brands). + */ + public function scopeInvoicesForUser(Builder $query, User $user, Business $business): Builder + { + $brandIds = $this->getUserBrandIds($user, $business); + + return $query->whereHas('items.product', function ($q) use ($brandIds) { + $q->whereIn('brand_id', $brandIds); + }); + } + + /** + * Scope inventory records to user's brands. + */ + public function scopeInventoryForUser(Builder $query, User $user, Business $business): Builder + { + $brandIds = $this->getUserBrandIds($user, $business); + + return $query->whereHas('product', function ($q) use ($brandIds) { + $q->whereIn('brand_id', $brandIds); + }); + } + + /** + * Scope promotions to user's brands. + */ + public function scopePromotionsForUser(Builder $query, User $user, Business $business): Builder + { + $brandIds = $this->getUserBrandIds($user, $business); + + return $query->where(function ($q) use ($brandIds) { + $q->whereIn('brand_id', $brandIds) + ->orWhereHas('products', function ($pq) use ($brandIds) { + $pq->whereIn('brand_id', $brandIds); + }); + }); + } + + /** + * Check if user is a Brand Manager for this business. + */ + public function isBrandManager(User $user, Business $business): bool + { + // Check if user has brand_manager role in business_user pivot + $pivot = $user->businesses() + ->where('businesses.id', $business->id) + ->first() + ?->pivot; + + if ($pivot && in_array($pivot->role ?? '', ['brand_manager', 'brand-manager'])) { + return true; + } + + // Check if user has brand assignments but is not an owner/admin + if (! $this->isBusinessAdmin($user, $business)) { + $brandCount = DB::table('brand_user') + ->join('brands', 'brand_user.brand_id', '=', 'brands.id') + ->where('brand_user.user_id', $user->id) + ->where('brands.business_id', $business->id) + ->count(); + + return $brandCount > 0; + } + + return false; + } + + /** + * Check if user is a business owner or admin (full access). + */ + protected function isBusinessAdmin(User $user, Business $business): bool + { + // Super admin + if ($user->user_type === 'admin') { + return true; + } + + // Check business_user pivot for owner/admin role + $pivot = $user->businesses() + ->where('businesses.id', $business->id) + ->first() + ?->pivot; + + if (! $pivot) { + return false; + } + + $role = $pivot->role ?? $pivot->contact_type ?? ''; + + return in_array($role, ['owner', 'primary', 'admin', 'company-owner']); + } + + /** + * Get user's role for a specific brand. + */ + public function getUserBrandRole(User $user, Brand $brand): ?string + { + return DB::table('brand_user') + ->where('user_id', $user->id) + ->where('brand_id', $brand->id) + ->value('role'); + } + + /** + * Check if user can modify brand data (Brand Managers are view-only). + */ + public function canModifyBrand(User $user, Brand $brand): bool + { + $business = $brand->business; + + // Business admins can modify + if ($this->isBusinessAdmin($user, $business)) { + return true; + } + + // Brand Managers are view-only + return false; + } + + /** + * Assign a user to a brand. + */ + public function assignUserToBrand(User $user, Brand $brand, string $role = 'member'): void + { + DB::table('brand_user')->updateOrInsert( + ['user_id' => $user->id, 'brand_id' => $brand->id], + ['role' => $role, 'updated_at' => now()] + ); + } + + /** + * Remove a user from a brand. + */ + public function removeUserFromBrand(User $user, Brand $brand): void + { + DB::table('brand_user') + ->where('user_id', $user->id) + ->where('brand_id', $brand->id) + ->delete(); + } +} diff --git a/app/Services/SuiteMenuResolver.php b/app/Services/SuiteMenuResolver.php index 939c65b9..b708f1fe 100644 --- a/app/Services/SuiteMenuResolver.php +++ b/app/Services/SuiteMenuResolver.php @@ -325,6 +325,17 @@ class SuiteMenuResolver 'requires_route' => true, ], + // ═══════════════════════════════════════════════════════════════ + // MANAGEMENT SUITE ITEMS - CFO DASHBOARD + // ═══════════════════════════════════════════════════════════════ + 'cfo_dashboard' => [ + 'label' => 'CFO Dashboard', + 'icon' => 'heroicon-o-presentation-chart-bar', + 'route' => 'seller.business.management.cfo-dashboard', + 'section' => 'Management', + 'order' => 850, + ], + // ═══════════════════════════════════════════════════════════════ // MANAGEMENT SUITE ITEMS - FINANCE SECTION // ═══════════════════════════════════════════════════════════════ @@ -501,6 +512,13 @@ class SuiteMenuResolver 'section' => 'Management', 'order' => 918, ], + 'inventory_valuation' => [ + 'label' => 'Inventory Valuation', + 'icon' => 'heroicon-o-cube-transparent', + 'route' => 'seller.business.management.inventory-valuation', + 'section' => 'Management', + 'order' => 919, + ], 'directory_customers' => [ 'label' => 'AR Customers', 'icon' => 'heroicon-o-user-group', @@ -537,6 +555,44 @@ class SuiteMenuResolver 'order' => 985, ], + // ═══════════════════════════════════════════════════════════════ + // MANAGEMENT SUITE ITEMS - ANALYTICS & OPERATIONS + // ═══════════════════════════════════════════════════════════════ + 'cross_analytics' => [ + 'label' => 'Cross-Business Analytics', + 'icon' => 'heroicon-o-chart-bar-square', + 'route' => 'seller.business.management.analytics.index', + 'section' => 'Analytics', + 'order' => 990, + 'requires_route' => true, + ], + 'forecasting' => [ + 'label' => 'Forecasting', + 'icon' => 'heroicon-o-arrow-trending-up', + 'route' => 'seller.business.management.forecasting.index', + 'section' => 'Analytics', + 'order' => 991, + 'requires_route' => true, + ], + 'operations_overview' => [ + 'label' => 'Operations Overview', + 'icon' => 'heroicon-o-cog-6-tooth', + 'route' => 'seller.business.management.operations.index', + 'section' => 'Operations', + 'order' => 992, + 'requires_route' => true, + ], + // Usage & Billing removed from sidebar - accessible via Owner Settings only + + 'intercompany_ledger' => [ + 'label' => 'Intercompany Ledger', + 'icon' => 'heroicon-o-arrows-right-left', + 'route' => 'seller.business.management.intercompany.ledger', + 'section' => 'Accounting', + 'order' => 915, + 'requires_route' => true, + ], + // ═══════════════════════════════════════════════════════════════ // BRAND PORTAL SUITE ITEMS // These are specifically for external brand partner users who have @@ -591,6 +647,44 @@ class SuiteMenuResolver 'section' => 'Messaging', 'order' => 410, ], + + // ═══════════════════════════════════════════════════════════════ + // BRAND MANAGER SUITE ITEMS + // These are for external brand team members with view-only access + // scoped to their assigned brands. + // ═══════════════════════════════════════════════════════════════ + 'brand_portal_brands' => [ + 'label' => 'My Brands', + 'icon' => 'heroicon-o-building-storefront', + 'route' => 'seller.business.brand-manager.brands.index', + 'section' => 'Brand Portal', + 'order' => 20, + 'requires_route' => true, + ], + 'brand_portal_products' => [ + 'label' => 'Products', + 'icon' => 'heroicon-o-cube', + 'route' => 'seller.business.brand-manager.products.index', + 'section' => 'Brand Portal', + 'order' => 30, + 'requires_route' => true, + ], + 'brand_portal_invoices' => [ + 'label' => 'Invoices', + 'icon' => 'heroicon-o-document-text', + 'route' => 'seller.business.brand-manager.invoices.index', + 'section' => 'Brand Portal', + 'order' => 120, + 'requires_route' => true, + ], + 'brand_portal_analytics' => [ + 'label' => 'Analytics', + 'icon' => 'heroicon-o-chart-bar', + 'route' => 'seller.business.brand-manager.analytics.index', + 'section' => 'Brand Portal', + 'order' => 500, + 'requires_route' => true, + ], ]; /** diff --git a/config/suites.php b/config/suites.php index 296cd786..5f763792 100644 --- a/config/suites.php +++ b/config/suites.php @@ -94,6 +94,8 @@ return [ ], 'management' => [ + // CFO Dashboard (top of Management) + 'cfo_dashboard', // Finance section 'finance_dashboard', 'finance_ap_aging', @@ -126,12 +128,29 @@ return [ 'fixed_assets', 'expenses', 'recurring', - // Analytics & Operations + // Inventory + 'inventory_valuation', + // AP/AR Management + 'ap_bills', + 'ar_invoices', + // Analytics & Operations (no usage_billing - moved to owner settings) 'cross_analytics', 'forecasting', 'operations_overview', - 'usage_billing', - 'settings', + // Intercompany + 'intercompany_ledger', + ], + + 'brand_manager' => [ + // Brand Portal - View-only access for external brand partners + 'brand_portal_dashboard', + 'brand_portal_brands', + 'brand_portal_products', + 'brand_portal_orders', + 'brand_portal_invoices', + 'brand_portal_inventory', + 'brand_portal_promotions', + 'brand_portal_analytics', ], // Note: Enterprise is NOT a suite - it's a plan limit override (is_enterprise_plan). diff --git a/database/migrations/2025_12_06_200001_create_bank_accounts_table.php b/database/migrations/2025_12_06_200001_create_bank_accounts_table.php new file mode 100644 index 00000000..7124c944 --- /dev/null +++ b/database/migrations/2025_12_06_200001_create_bank_accounts_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('business_id')->constrained()->cascadeOnDelete(); + $table->string('name'); // e.g., "Operating Account", "Payroll Account" + $table->string('account_type')->default('checking'); // checking, savings, money_market + $table->string('bank_name')->nullable(); + $table->string('account_number_last4')->nullable(); // Last 4 digits only for display + $table->string('routing_number')->nullable(); + $table->decimal('current_balance', 14, 2)->default(0); + $table->decimal('available_balance', 14, 2)->default(0); + $table->foreignId('gl_account_id')->nullable()->constrained('gl_accounts')->nullOnDelete(); + $table->string('currency', 3)->default('USD'); + $table->boolean('is_primary')->default(false); + $table->boolean('is_active')->default(true); + $table->string('plaid_account_id')->nullable()->unique(); + $table->timestamp('last_synced_at')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['business_id', 'is_active']); + $table->index(['business_id', 'is_primary']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_accounts'); + } +}; diff --git a/database/migrations/2025_12_06_200002_create_bank_transfers_table.php b/database/migrations/2025_12_06_200002_create_bank_transfers_table.php new file mode 100644 index 00000000..a4bab911 --- /dev/null +++ b/database/migrations/2025_12_06_200002_create_bank_transfers_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('business_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_bank_account_id')->constrained('bank_accounts')->cascadeOnDelete(); + $table->foreignId('to_bank_account_id')->constrained('bank_accounts')->cascadeOnDelete(); + $table->decimal('amount', 14, 2); + $table->date('transfer_date'); + $table->string('reference')->nullable(); + $table->string('status')->default('pending'); // pending, completed, cancelled + $table->text('memo')->nullable(); + $table->foreignId('journal_entry_id')->nullable()->constrained('journal_entries')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['business_id', 'status']); + $table->index(['business_id', 'transfer_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bank_transfers'); + } +}; diff --git a/database/migrations/2025_12_06_220001_create_plaid_items_table.php b/database/migrations/2025_12_06_220001_create_plaid_items_table.php new file mode 100644 index 00000000..cb8c1df8 --- /dev/null +++ b/database/migrations/2025_12_06_220001_create_plaid_items_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('business_id')->constrained()->cascadeOnDelete(); + $table->string('plaid_item_id')->unique(); + $table->text('plaid_access_token'); // Will be encrypted at application level + $table->string('institution_name')->nullable(); + $table->string('institution_id')->nullable(); + $table->string('status')->default('active'); // active, revoked, error + $table->text('error_message')->nullable(); + $table->timestamp('consent_expires_at')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['business_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('plaid_items'); + } +}; diff --git a/database/migrations/2025_12_06_220002_create_plaid_accounts_table.php b/database/migrations/2025_12_06_220002_create_plaid_accounts_table.php new file mode 100644 index 00000000..0a74ecc0 --- /dev/null +++ b/database/migrations/2025_12_06_220002_create_plaid_accounts_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('plaid_item_id')->constrained()->cascadeOnDelete(); + $table->string('plaid_account_id')->unique(); + $table->foreignId('bank_account_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('official_name')->nullable(); + $table->string('mask', 10)->nullable(); // Last 4 digits + $table->string('type'); // depository, credit, loan, investment, other + $table->string('subtype')->nullable(); // checking, savings, credit card, etc. + $table->string('currency', 3)->default('USD'); + $table->decimal('current_balance', 14, 2)->nullable(); + $table->decimal('available_balance', 14, 2)->nullable(); + $table->decimal('limit', 14, 2)->nullable(); // For credit accounts + $table->boolean('is_active')->default(true); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->index(['plaid_item_id', 'is_active']); + $table->index('bank_account_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('plaid_accounts'); + } +}; diff --git a/database/migrations/2025_12_06_220003_create_plaid_transactions_table.php b/database/migrations/2025_12_06_220003_create_plaid_transactions_table.php new file mode 100644 index 00000000..1c50664a --- /dev/null +++ b/database/migrations/2025_12_06_220003_create_plaid_transactions_table.php @@ -0,0 +1,62 @@ +id(); + $table->foreignId('plaid_account_id')->constrained()->cascadeOnDelete(); + $table->string('plaid_transaction_id')->unique(); + $table->date('date'); + $table->date('authorized_date')->nullable(); + $table->string('name'); + $table->string('merchant_name')->nullable(); + $table->decimal('amount', 14, 2); // Positive = money out, Negative = money in (Plaid convention) + $table->string('currency', 3)->default('USD'); + $table->boolean('pending')->default(false); + $table->string('payment_channel')->nullable(); // online, in store, etc. + $table->json('category')->nullable(); // Plaid category array + $table->string('category_id')->nullable(); + $table->json('location')->nullable(); + $table->json('raw_data')->nullable(); // Full Plaid response for reference + + // Matching/Reconciliation + $table->string('status')->default('unmatched'); // unmatched, matched, proposed_auto, ignored + $table->foreignId('linked_journal_entry_id')->nullable()->constrained('journal_entries')->nullOnDelete(); + $table->foreignId('linked_ap_payment_id')->nullable()->constrained('ap_payments')->nullOnDelete(); + $table->foreignId('linked_ar_payment_id')->nullable(); // Will constrain when ar_payments table exists + $table->foreignId('matched_by_rule_id')->nullable(); // Will constrain to bank_match_rules + $table->foreignId('matched_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('matched_at')->nullable(); + $table->text('match_notes')->nullable(); + + $table->timestamps(); + + $table->index(['plaid_account_id', 'date']); + $table->index(['plaid_account_id', 'status']); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('plaid_transactions'); + } +}; diff --git a/database/migrations/2025_12_06_220004_create_bank_match_rules_table.php b/database/migrations/2025_12_06_220004_create_bank_match_rules_table.php new file mode 100644 index 00000000..08a919c5 --- /dev/null +++ b/database/migrations/2025_12_06_220004_create_bank_match_rules_table.php @@ -0,0 +1,72 @@ +id(); + $table->foreignId('business_id')->constrained()->cascadeOnDelete(); + $table->foreignId('bank_account_id')->constrained()->cascadeOnDelete(); + $table->string('pattern_name'); // Normalized merchant/transaction name + $table->string('pattern_type')->default('exact'); // exact, contains, starts_with + $table->string('direction'); // debit, credit + $table->decimal('amount_tolerance', 10, 2)->default(0.50); // Match within this tolerance + $table->decimal('typical_amount', 14, 2)->nullable(); // Average matched amount + $table->string('target_type')->nullable(); // ap_payment, ar_payment, journal_entry, expense + $table->unsignedBigInteger('target_id')->nullable(); // Template target if applicable + $table->foreignId('target_gl_account_id')->nullable()->constrained('gl_accounts')->nullOnDelete(); + $table->integer('training_count')->default(0); // Times this pattern was manually matched + $table->boolean('auto_enabled')->default(false); // Only true after user approves + $table->integer('auto_match_count')->default(0); // Times auto-matched successfully + $table->integer('rejection_count')->default(0); // Times auto-match was rejected + $table->timestamp('last_used_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique(['business_id', 'bank_account_id', 'pattern_name', 'direction'], 'bank_match_rules_unique'); + $table->index(['bank_account_id', 'auto_enabled']); + $table->index(['business_id', 'training_count']); + }); + } + + // Add foreign key to plaid_transactions for matched_by_rule_id (if table exists and FK not present) + if (Schema::hasTable('plaid_transactions')) { + $hasFk = collect(\Illuminate\Support\Facades\DB::select( + "SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'plaid_transactions_matched_by_rule_id_foreign'" + ))->isNotEmpty(); + + if (! $hasFk) { + Schema::table('plaid_transactions', function (Blueprint $table) { + $table->foreign('matched_by_rule_id') + ->references('id') + ->on('bank_match_rules') + ->nullOnDelete(); + }); + } + } + } + + public function down(): void + { + if (Schema::hasTable('plaid_transactions')) { + Schema::table('plaid_transactions', function (Blueprint $table) { + $table->dropForeign(['matched_by_rule_id']); + }); + } + + Schema::dropIfExists('bank_match_rules'); + } +}; diff --git a/database/migrations/2025_12_07_010000_add_credit_hold_fields_to_ar_customers.php b/database/migrations/2025_12_07_010000_add_credit_hold_fields_to_ar_customers.php new file mode 100644 index 00000000..7a70f7fa --- /dev/null +++ b/database/migrations/2025_12_07_010000_add_credit_hold_fields_to_ar_customers.php @@ -0,0 +1,39 @@ +boolean('on_credit_hold')->default(false)->after('credit_approved_at'); + } + if (! Schema::hasColumn('ar_customers', 'credit_status')) { + $table->string('credit_status', 20)->default('good')->after('on_credit_hold'); // good, watch, hold + } + if (! Schema::hasColumn('ar_customers', 'hold_reason')) { + $table->text('hold_reason')->nullable()->after('credit_status'); + } + if (! Schema::hasColumn('ar_customers', 'ar_notes')) { + $table->text('ar_notes')->nullable()->after('hold_reason'); + } + }); + } + + public function down(): void + { + Schema::table('ar_customers', function (Blueprint $table) { + $table->dropColumn(['on_credit_hold', 'credit_status', 'hold_reason', 'ar_notes']); + }); + } +}; diff --git a/database/migrations/2025_12_07_020000_add_linked_business_to_ar_customers.php b/database/migrations/2025_12_07_020000_add_linked_business_to_ar_customers.php new file mode 100644 index 00000000..4391dfa9 --- /dev/null +++ b/database/migrations/2025_12_07_020000_add_linked_business_to_ar_customers.php @@ -0,0 +1,35 @@ +foreignId('linked_business_id') + ->nullable() + ->after('business_id') + ->constrained('businesses') + ->nullOnDelete(); + + // Unique constraint: one AR customer per seller-buyer pair + $table->unique(['business_id', 'linked_business_id'], 'ar_customers_business_linked_unique'); + } + }); + } + + public function down(): void + { + Schema::table('ar_customers', function (Blueprint $table) { + if (Schema::hasColumn('ar_customers', 'linked_business_id')) { + $table->dropUnique('ar_customers_business_linked_unique'); + $table->dropConstrainedForeignId('linked_business_id'); + } + }); + } +}; diff --git a/database/migrations/2025_12_07_100000_add_journal_entry_id_to_ap_tables.php b/database/migrations/2025_12_07_100000_add_journal_entry_id_to_ap_tables.php new file mode 100644 index 00000000..6f159158 --- /dev/null +++ b/database/migrations/2025_12_07_100000_add_journal_entry_id_to_ap_tables.php @@ -0,0 +1,57 @@ +foreignId('journal_entry_id') + ->nullable() + ->after('notes') + ->constrained('journal_entries') + ->nullOnDelete(); + }); + } + + // Add journal_entry_id to ap_payments if not exists + if (! Schema::hasColumn('ap_payments', 'journal_entry_id')) { + Schema::table('ap_payments', function (Blueprint $table) { + $table->foreignId('journal_entry_id') + ->nullable() + ->after('memo') + ->constrained('journal_entries') + ->nullOnDelete(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('ap_bills', 'journal_entry_id')) { + Schema::table('ap_bills', function (Blueprint $table) { + $table->dropConstrainedForeignId('journal_entry_id'); + }); + } + + if (Schema::hasColumn('ap_payments', 'journal_entry_id')) { + Schema::table('ap_payments', function (Blueprint $table) { + $table->dropConstrainedForeignId('journal_entry_id'); + }); + } + } +}; diff --git a/resources/views/seller/management/analytics/index.blade.php b/resources/views/seller/management/analytics/index.blade.php new file mode 100644 index 00000000..e1446058 --- /dev/null +++ b/resources/views/seller/management/analytics/index.blade.php @@ -0,0 +1,163 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Cross-Business Analytics - Management') + +@section('content') +
+ {{-- Page Header --}} +
+
+

Cross-Business Analytics

+

Consolidated financial performance across all divisions

+
+
+ + {{-- Division Filter --}} + @include('seller.management.partials.division-filter') + + {{-- KPI Summary Cards --}} +
+
+
+
Total Revenue
+
${{ number_format($analytics['totals']['revenue'], 2) }}
+
+
+
+
+
Total Expenses
+
${{ number_format($analytics['totals']['expenses'], 2) }}
+
+
+
+
+
Net Income
+
+ ${{ number_format($analytics['totals']['net_income'], 2) }} +
+
+
+
+
+
Outstanding AR
+
${{ number_format($analytics['totals']['outstanding_ar'], 2) }}
+
+
+
+ +
+ {{-- Revenue by Division --}} +
+
+

Revenue by Division

+ @if($analytics['revenue_by_division']->count() > 0) +
+ + + + + + + + + + @foreach($analytics['revenue_by_division'] as $row) + + + + + + @endforeach + +
DivisionOrdersRevenue
{{ $row->division_name }}{{ number_format($row->order_count) }}${{ number_format($row->total_revenue, 2) }}
+
+ @else +
+ + No revenue data available +
+ @endif +
+
+ + {{-- Expenses by Division --}} +
+
+

Expenses by Division

+ @if($analytics['expenses_by_division']->count() > 0) +
+ + + + + + + + + + @foreach($analytics['expenses_by_division'] as $row) + + + + + + @endforeach + +
DivisionBillsExpenses
{{ $row->division_name }}{{ number_format($row->bill_count) }}${{ number_format($row->total_expenses, 2) }}
+
+ @else +
+ + No expense data available +
+ @endif +
+
+ + {{-- AR by Division --}} +
+
+

Accounts Receivable by Division

+ @if($analytics['ar_by_division']->count() > 0) +
+ + + + + + + + + + + @foreach($analytics['ar_by_division'] as $row) + @php + $collectionRate = $row->total_ar > 0 + ? (($row->total_ar - $row->outstanding_ar) / $row->total_ar) * 100 + : 100; + @endphp + + + + + + + @endforeach + +
DivisionTotal AROutstandingCollection Rate
{{ $row->division_name }}${{ number_format($row->total_ar, 2) }}${{ number_format($row->outstanding_ar, 2) }} + + {{ number_format($collectionRate, 1) }}% + +
+
+ @else +
+ + No accounts receivable data available +
+ @endif +
+
+
+
+@endsection diff --git a/resources/views/seller/management/ap/vendors/index.blade.php b/resources/views/seller/management/ap/vendors/index.blade.php index ff995052..dfb621de 100644 --- a/resources/views/seller/management/ap/vendors/index.blade.php +++ b/resources/views/seller/management/ap/vendors/index.blade.php @@ -76,6 +76,9 @@ Code Name + @if($isParent ?? false) + Divisions + @endif Contact Payment Terms Bills @@ -93,6 +96,19 @@
{{ $vendor->legal_name }}
@endif + @if($isParent ?? false) + + @if($vendor->divisions_using && $vendor->divisions_using->count() > 0) +
+ @foreach($vendor->divisions_using as $division) + {{ $division->name }} + @endforeach +
+ @else + - + @endif + + @endif @if($vendor->contact_email)
{{ $vendor->contact_email }}
@@ -147,7 +163,7 @@ @empty - +

No vendors found

+ + + @elseif(($summary['past_due_total'] ?? 0) > 0) +
+ + This account has ${{ number_format($summary['past_due_total'], 2) }} past due +
+ @endif + + {{-- Summary Cards --}} +
+
+
+
Total Balance
+
${{ number_format($summary['total_open_ar'] ?? 0, 2) }}
+
+
+
+
+
Past Due
+
${{ number_format($summary['past_due_total'] ?? 0, 2) }}
+
+
+
+
+
Credit Limit
+
+ @if(($summary['credit_limit'] ?? 0) > 0) + ${{ number_format($summary['credit_limit'], 2) }} + @else + Not set + @endif +
+
+
+
+
+
Available Credit
+
+ @if(($summary['credit_limit'] ?? 0) > 0) + ${{ number_format($summary['credit_available'] ?? 0, 2) }} + @else + N/A + @endif +
+
+
+
+ +
+ {{-- Left Column: Account Info & Controls --}} +
+ {{-- Account Information --}} +
+
+

Account Information

+
+
+ Payment Terms + {{ $customer->payment_terms ?? 'Net 30' }} +
+
+ Credit Status + + @if(($summary['credit_status'] ?? 'good') === 'hold') + Hold + @elseif(($summary['credit_status'] ?? 'good') === 'watch') + Watch + @else + Good + @endif + +
+
+ Open Invoices + {{ $summary['open_invoice_count'] ?? 0 }} +
+ @if($customer->email) +
+ Email + {{ $customer->email }} +
+ @endif + @if($customer->phone) +
+ Phone + {{ $customer->phone }} +
+ @endif +
+
+
+ + {{-- Aging Breakdown --}} +
+
+

Aging Breakdown

+
+
+ Current + ${{ number_format($summary['aging']['current'] ?? 0, 2) }} +
+
+ 1-30 Days + ${{ number_format($summary['aging']['1_30'] ?? 0, 2) }} +
+
+ 31-60 Days + ${{ number_format($summary['aging']['31_60'] ?? 0, 2) }} +
+
+ 61-90 Days + ${{ number_format($summary['aging']['61_90'] ?? 0, 2) }} +
+
+ 90+ Days + ${{ number_format($summary['aging']['90_plus'] ?? 0, 2) }} +
+
+
+
+ + {{-- Credit Management --}} +
+
+

Credit Management

+ + {{-- Update Credit Limit --}} +
+ @csrf + +
+ $ + + +
+
+ + {{-- Update Payment Terms --}} +
+ @csrf + +
+ + +
+
+ + {{-- Place/Remove Hold --}} + @if(!$customer->on_credit_hold) +
+
+ @csrf + + + +
+ @endif +
+
+
+ + {{-- Right Column: Invoices & Payments --}} +
+ {{-- Open Invoices --}} +
+
+

Open Invoices

+ @if($invoices->count() > 0) +
+ + + + + + + + + + + + + @foreach($invoices as $invoice) + + + + + + + + + @endforeach + +
Invoice #DateDue DateAmountBalanceStatus
{{ $invoice->invoice_number }}{{ $invoice->invoice_date?->format('M j, Y') }} + {{ $invoice->due_date?->format('M j, Y') }} + @if($invoice->due_date?->isPast()) + ({{ $invoice->due_date->diffForHumans() }}) + @endif + ${{ number_format($invoice->total_amount, 2) }}${{ number_format($invoice->balance_due, 2) }} + @if($invoice->status === 'overdue' || ($invoice->due_date && $invoice->due_date->isPast())) + Overdue + @elseif($invoice->status === 'partial') + Partial + @else + Open + @endif +
+
+ @else +
+ +

No open invoices

+
+ @endif +
+
+ + {{-- Recent Payments --}} +
+
+

Recent Payments

+ @if($payments->count() > 0) +
+ + + + + + + + + + + @foreach($payments as $payment) + + + + + + + @endforeach + +
DateReferenceMethodAmount
{{ $payment->payment_date?->format('M j, Y') }}{{ $payment->reference_number ?? '-' }}{{ ucfirst($payment->payment_method ?? 'Unknown') }}${{ number_format($payment->amount, 2) }}
+
+ @else +
+ +

No recent payments

+
+ @endif +
+
+ + {{-- Activity Log --}} + @if($activities->count() > 0) +
+
+

Recent Activity

+
+ @foreach($activities as $activity) +
+
+ +
+
+
{{ $activity->action_label }}
+
{{ $activity->description }}
+
+ {{ $activity->created_at?->diffForHumans() }} + @if($activity->user) + by {{ $activity->user->name }} + @endif +
+
+
+ @endforeach +
+
+
+ @endif + + {{-- AR Notes --}} +
+
+

AR Notes

+
+ {{ $customer->ar_notes ?? 'No notes recorded.' }} +
+
+
+
+
+ +@endsection diff --git a/resources/views/seller/management/ar/accounts.blade.php b/resources/views/seller/management/ar/accounts.blade.php new file mode 100644 index 00000000..cefe0ce4 --- /dev/null +++ b/resources/views/seller/management/ar/accounts.blade.php @@ -0,0 +1,163 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'AR Accounts') + +@section('content') +
+ {{-- Page Header --}} +
+
+

AR Accounts

+

Customer accounts with balances

+
+ +
+ + {{-- Division Filter --}} + @if($isParent && $divisions->count() > 0) + @include('seller.management.partials.division-filter', [ + 'business' => $business, + 'divisions' => $divisions, + 'selectedDivision' => $selectedDivision, + 'includeChildren' => $includeChildren, + 'routeName' => 'seller.business.management.ar.accounts', + ]) + @endif + + {{-- Summary Stats --}} +
+
+
+
Total AR
+
${{ number_format($metrics['total_outstanding'] ?? 0, 2) }}
+
+
+
+
+
Past Due
+
${{ number_format($metrics['overdue_amount'] ?? 0, 2) }}
+
+
+
+
+
Accounts
+
{{ $accounts->count() }}
+
+
+
+
+
On Hold
+
+ {{ $accounts->where('on_credit_hold', true)->count() }} +
+
+
+
+ + {{-- Filters --}} +
+
+
+
+ +
+ + + + @if(($filters['search'] ?? '') || ($filters['on_hold'] ?? false) || ($filters['at_risk'] ?? false)) + Clear + @endif +
+
+
+ + {{-- Accounts Table --}} +
+
+ @if($accounts->count() > 0) +
+ + + + + + + + + + + + + + @foreach($accounts as $account) + + + + + + + + + + @endforeach + +
AccountBalancePast DueCredit LimitTermsStatus
+
{{ $account['customer']->name }}
+ @if($isParent && $account['customer']->business) +
{{ $account['customer']->business->division_name ?? $account['customer']->business->name }}
+ @endif +
${{ number_format($account['balance'], 2) }} + ${{ number_format($account['past_due'], 2) }} + + @if($account['credit_limit'] > 0) + ${{ number_format($account['credit_limit'], 2) }} + @if($account['balance'] > $account['credit_limit']) + Over + @endif + @else + - + @endif + {{ $account['payment_terms'] }} + @if($account['on_credit_hold']) + Hold + @elseif($account['credit_status'] === 'watch') + Watch + @elseif($account['past_due'] > 0) + Past Due + @else + Good + @endif + + + + +
+
+ @else +
+ +

No accounts found matching your criteria.

+
+ @endif +
+
+
+@endsection diff --git a/resources/views/seller/management/bank-accounts/create.blade.php b/resources/views/seller/management/bank-accounts/create.blade.php new file mode 100644 index 00000000..8fe80b68 --- /dev/null +++ b/resources/views/seller/management/bank-accounts/create.blade.php @@ -0,0 +1,174 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Add Bank Account - Management') + +@section('content') +
+ {{-- Page Header --}} +
+ + + Back to Bank Accounts + +

Add Bank Account

+

Create a new bank account for tracking cash positions

+
+ +
+ @csrf + +
+
+ {{-- Account Name --}} +
+ + + @error('name') + + @enderror +
+ + {{-- Account Type --}} +
+ + + @error('account_type') + + @enderror +
+ + {{-- Bank Name --}} +
+ + + @error('bank_name') + + @enderror +
+ +
+ {{-- Account Number (Last 4) --}} +
+ + + @error('account_number_last4') + + @enderror +
+ + {{-- Routing Number --}} +
+ + + @error('routing_number') + + @enderror +
+
+ + {{-- Current Balance --}} +
+ +
+ $ + +
+ @error('current_balance') + + @enderror +
+ + {{-- GL Account Link --}} +
+ + + + @error('gl_account_id') + + @enderror +
+ + {{-- Options --}} +
Options
+ +
+ +
+ +
+ +
+ + {{-- Notes --}} +
+ + + @error('notes') + + @enderror +
+
+ +
+ + Cancel + + +
+
+
+
+@endsection diff --git a/resources/views/seller/management/bank-accounts/edit.blade.php b/resources/views/seller/management/bank-accounts/edit.blade.php new file mode 100644 index 00000000..1f5fdc25 --- /dev/null +++ b/resources/views/seller/management/bank-accounts/edit.blade.php @@ -0,0 +1,171 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Edit Bank Account - Management') + +@section('content') +
+ {{-- Page Header --}} +
+ + + Back to Bank Accounts + +

Edit Bank Account

+

{{ $account->name }}

+
+ +
+ @csrf + @method('PUT') + +
+
+ {{-- Account Name --}} +
+ + + @error('name') + + @enderror +
+ + {{-- Account Type --}} +
+ + + @error('account_type') + + @enderror +
+ + {{-- Bank Name --}} +
+ + + @error('bank_name') + + @enderror +
+ +
+ {{-- Account Number (Last 4) --}} +
+ + + @error('account_number_last4') + + @enderror +
+ + {{-- Routing Number --}} +
+ + + @error('routing_number') + + @enderror +
+
+ + {{-- Current Balance (Read-only) --}} +
+ +
+ $ + +
+ +
+ + {{-- GL Account Link --}} +
+ + + @error('gl_account_id') + + @enderror +
+ + {{-- Options --}} +
Options
+ +
+ +
+ +
+ +
+ + {{-- Notes --}} +
+ + + @error('notes') + + @enderror +
+
+ +
+ + Cancel + + +
+
+
+
+@endsection diff --git a/resources/views/seller/management/bank-accounts/index.blade.php b/resources/views/seller/management/bank-accounts/index.blade.php new file mode 100644 index 00000000..82fe7588 --- /dev/null +++ b/resources/views/seller/management/bank-accounts/index.blade.php @@ -0,0 +1,148 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Bank Accounts - Management') + +@section('content') +
+ {{-- Page Header --}} +
+
+

Bank Accounts

+

Manage bank accounts and cash positions

+
+ +
+ + {{-- Division Filter --}} + @include('seller.management.partials.division-filter') + + {{-- Summary Card --}} +
+
+
+
+
Total Cash Balance
+
${{ number_format($totalBalance, 2) }}
+
+
+ +
+
+
+
+ + {{-- Bank Accounts Table --}} +
+
+ @if($accounts->count() > 0) +
+ + + + + + + + + + + + + + @foreach($accounts as $account) + + + + + + + + + + @endforeach + +
AccountBankTypeBalanceGL AccountStatusActions
+
+ @if($account->is_primary) + Primary + @endif + + {{ $account->name }} + +
+ @if($account->account_number_last4) +
***{{ $account->account_number_last4 }}
+ @endif +
{{ $account->bank_name ?? '-' }}{{ $account->account_type_display }} + ${{ number_format($account->current_balance, 2) }} + + @if($account->glAccount) + {{ $account->glAccount->account_number }} + @else + Not linked + @endif + + @if($account->is_active) + Active + @else + Inactive + @endif + + +
+
+ @else +
+ +

No Bank Accounts

+

Add your first bank account to track cash positions.

+ +
+ @endif +
+
+
+@endsection diff --git a/resources/views/seller/management/bank-accounts/match-rules.blade.php b/resources/views/seller/management/bank-accounts/match-rules.blade.php new file mode 100644 index 00000000..969d9033 --- /dev/null +++ b/resources/views/seller/management/bank-accounts/match-rules.blade.php @@ -0,0 +1,156 @@ +@extends('layouts.seller') + +@section('title', 'Match Rules - ' . $account->name) + +@section('content') +
+ {{-- Header --}} +
+
+ +

Auto-Match Rules

+

{{ $account->name }}

+
+ + + Back to Reconciliation + +
+ + {{-- Eligible for Auto-Enable --}} + @if($eligibleRules->isNotEmpty()) +
+ +
+

Rules Ready for Auto-Matching

+

These rules have been trained {{ \App\Models\Accounting\BankMatchRule::MIN_TRAINING_COUNT }}+ times and can be enabled for auto-matching.

+
+
+ @endif + + {{-- All Rules --}} +
+
+

Match Rules

+ + @if($rules->isEmpty()) +
+ +

No rules learned yet

+

Rules are automatically created when you manually match transactions.

+
+ @else +
+ + + + + + + + + + + + + + + @foreach($rules as $rule) + + + + + + + + + + + @endforeach + +
PatternDirectionTypical AmountTrainingAuto-MatchesRejectionsAuto-EnabledLast Used
+
{{ $rule->pattern_name }}
+
{{ $rule->pattern_type_display }}
+
+ + {{ $rule->direction_display }} + + + @if($rule->typical_amount) + ${{ number_format($rule->typical_amount, 2) }} + ±${{ number_format($rule->amount_tolerance, 2) }} + @else + — + @endif + + {{ $rule->training_count }} + + {{ $rule->auto_match_count }} + + {{ $rule->rejection_count }} + +
+ @csrf + + +
+
+ @if($rule->last_used_at) + {{ $rule->last_used_at->diffForHumans() }} + @else + Never + @endif +
+
+ @endif +
+
+ + {{-- How It Works --}} +
+
+

+ + How Auto-Matching Works +

+
+
+
1
+
+

Learn from Manual Matches

+

When you manually match a transaction, a rule is created or updated based on the transaction pattern.

+
+
+
+
2
+
+

Training Threshold

+

After {{ \App\Models\Accounting\BankMatchRule::MIN_TRAINING_COUNT }} successful manual matches, the rule becomes eligible for auto-matching.

+
+
+
+
3
+
+

Human Review Required

+

Auto-matched transactions are proposed but never committed automatically. You must confirm or reject each match.

+
+
+
+
+
+
+@endsection diff --git a/resources/views/seller/management/bank-accounts/reconciliation.blade.php b/resources/views/seller/management/bank-accounts/reconciliation.blade.php new file mode 100644 index 00000000..8143a4a8 --- /dev/null +++ b/resources/views/seller/management/bank-accounts/reconciliation.blade.php @@ -0,0 +1,291 @@ +@extends('layouts.seller') + +@section('title', 'Bank Reconciliation - ' . $account->name) + +@section('content') +
+ {{-- Header --}} +
+
+
+ + Bank Accounts + + + {{ $account->name }} +
+

Bank Reconciliation

+
+
+ + + Match Rules + +
+ @csrf + +
+
+
+ + {{-- Summary Cards --}} +
+
+
+
Plaid Balance
+
+ @if($summary['has_plaid']) + ${{ number_format($summary['plaid_balance'], 2) }} + @else + Not Connected + @endif +
+
+
+
+
+
GL Balance
+
${{ number_format($summary['gl_balance'], 2) }}
+
+
+
+
+
Difference
+
+ @if($summary['difference'] !== null) + ${{ number_format(abs($summary['difference']), 2) }} + @if($summary['difference'] != 0) + {{ $summary['difference'] > 0 ? 'over' : 'under' }} + @endif + @else + — + @endif +
+
+
+
+
+
Pending Review
+
+ {{ $summary['unmatched_count'] + $summary['proposed_count'] }} + transactions +
+
+
+
+ + {{-- Tabs --}} +
+ + + +
+ + {{-- Unmatched Transactions --}} +
+
+

Unmatched Transactions

+ + @if($unmatchedTransactions->isEmpty()) +
+ +

All transactions are matched!

+
+ @else +
+ + + + + + + + + + + + @foreach($unmatchedTransactions as $tx) + + + + + + + + @endforeach + +
DateDescriptionAmountCategoryActions
{{ $tx->date->format('M d, Y') }} +
{{ $tx->display_name }}
+ @if($tx->name !== $tx->merchant_name) +
{{ $tx->name }}
+ @endif +
+ {{ $tx->formatted_amount }} + {{ $tx->category_display }} + +
+
+ @endif +
+
+ + {{-- Auto-Match Review --}} +
+
+
+

Auto-Match Review

+ @if($proposedMatches->isNotEmpty()) +
+
+ @csrf + + +
+
+ @csrf + + +
+
+ @endif +
+ + @if($proposedMatches->isEmpty()) +
+ +

No auto-match proposals pending

+
+ @else +
+ + + + + + + + + + + + @foreach($proposedMatches as $tx) + + + + + + + + @endforeach + +
+ + DateDescriptionAmountSuggested Rule
+ + {{ $tx->date->format('M d, Y') }} +
{{ $tx->display_name }}
+
+ {{ $tx->formatted_amount }} + + @if($tx->matchedByRule) +
{{ $tx->matchedByRule->pattern_name }}
+ @endif +
+
+ @endif +
+
+ + {{-- Matched Transactions (Placeholder) --}} +
+
+

Matched Transactions

+
+

View matched transactions history here.

+
+
+
+
+ + +@endsection diff --git a/resources/views/seller/management/bank-accounts/show.blade.php b/resources/views/seller/management/bank-accounts/show.blade.php new file mode 100644 index 00000000..90f9ba96 --- /dev/null +++ b/resources/views/seller/management/bank-accounts/show.blade.php @@ -0,0 +1,188 @@ +@extends('layouts.app-with-sidebar') + +@section('title', $account->name . ' - Bank Accounts') + +@section('content') +
+ {{-- Page Header --}} +
+
+ + + Back to Bank Accounts + +

+ {{ $account->name }} + @if($account->is_primary) + Primary + @endif + @if(!$account->is_active) + Inactive + @endif +

+

+ {{ $account->bank_name ?? 'Bank account' }} + @if($account->account_number_last4) + • ***{{ $account->account_number_last4 }} + @endif +

+
+ +
+ +
+ {{-- Main Info --}} +
+ {{-- Balance Card --}} +
+
+

Current Balance

+
+ ${{ number_format($account->current_balance, 2) }} +
+ @if($account->available_balance != $account->current_balance) +
+ Available: ${{ number_format($account->available_balance, 2) }} +
+ @endif + @if($account->last_synced_at) +
+ Last synced: {{ $account->last_synced_at->diffForHumans() }} +
+ @endif +
+
+ + {{-- Recent Transfers --}} +
+
+
+

Recent Transfers

+ + View All + +
+ + @if($recentTransfers->count() > 0) +
+ + + + + + + + + + + @foreach($recentTransfers as $transfer) + + + + + + + @endforeach + +
DateFrom/ToAmountStatus
{{ $transfer->transfer_date->format('M j, Y') }} + @if($transfer->from_bank_account_id === $account->id) + To: {{ $transfer->toAccount->name }} + @else + From: {{ $transfer->fromAccount->name }} + @endif + + @if($transfer->from_bank_account_id === $account->id) + -${{ number_format($transfer->amount, 2) }} + @else + +${{ number_format($transfer->amount, 2) }} + @endif + + + {{ ucfirst($transfer->status) }} + +
+
+ @else +
+ + No recent transfers +
+ @endif +
+
+
+ + {{-- Sidebar Info --}} +
+ {{-- Account Details --}} +
+
+

Account Details

+
+
+
Type
+
{{ $account->account_type_display }}
+
+
+
Currency
+
{{ $account->currency }}
+
+ @if($account->routing_number) +
+
Routing Number
+
{{ $account->routing_number }}
+
+ @endif + @if($account->glAccount) +
+
GL Account
+
{{ $account->glAccount->account_number }} - {{ $account->glAccount->name }}
+
+ @endif +
+
+
+ + {{-- Notes --}} + @if($account->notes) +
+
+

Notes

+

{{ $account->notes }}

+
+
+ @endif + + {{-- Plaid Status --}} +
+
+

Bank Connection

+ @if($account->hasPlaidConnection()) +
+ + Connected via Plaid +
+ @else +
+ + Not connected +
+

+ Connect via Plaid to enable automatic balance sync and transaction import. +

+ @endif +
+
+
+
+
+@endsection diff --git a/resources/views/seller/management/bank-transfers/create.blade.php b/resources/views/seller/management/bank-transfers/create.blade.php new file mode 100644 index 00000000..65bacb1e --- /dev/null +++ b/resources/views/seller/management/bank-transfers/create.blade.php @@ -0,0 +1,135 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'New Transfer - Bank Transfers') + +@section('content') +
+ {{-- Page Header --}} +
+ + + Back to Transfers + +

New Bank Transfer

+

Transfer funds between bank accounts

+
+ + @if($accounts->count() < 2) +
+ + You need at least 2 bank accounts to make a transfer. Add another account. +
+ @else +
+ @csrf + +
+
+ {{-- From Account --}} +
+ + + @error('from_bank_account_id') + + @enderror +
+ + {{-- To Account --}} +
+ + + @error('to_bank_account_id') + + @enderror +
+ + {{-- Amount --}} +
+ +
+ $ + +
+ @error('amount') + + @enderror +
+ + {{-- Transfer Date --}} +
+ + + @error('transfer_date') + + @enderror +
+ + {{-- Reference --}} +
+ + + @error('reference') + + @enderror +
+ + {{-- Notes --}} +
+ + + @error('notes') + + @enderror +
+
+ +
+ + Cancel + + +
+
+
+ @endif +
+@endsection diff --git a/resources/views/seller/management/bank-transfers/index.blade.php b/resources/views/seller/management/bank-transfers/index.blade.php new file mode 100644 index 00000000..b98102f4 --- /dev/null +++ b/resources/views/seller/management/bank-transfers/index.blade.php @@ -0,0 +1,133 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Bank Transfers - Management') + +@section('content') +
+ {{-- Page Header --}} +
+
+

Bank Transfers

+

Transfer funds between bank accounts

+
+ +
+ + {{-- Division Filter --}} + @include('seller.management.partials.division-filter') + + {{-- Filters --}} +
+
+
+
+ + +
+
+ + +
+
+ + +
+ + @if(!empty($filters['status']) || !empty($filters['from_date']) || !empty($filters['to_date'])) + + Clear + + @endif +
+
+
+ + {{-- Transfers Table --}} +
+
+ @if($transfers->count() > 0) +
+ + + + + + + + + + + + + + @foreach($transfers as $transfer) + + + + + + + + + + @endforeach + +
DateFromToAmountReferenceStatusActions
{{ $transfer->transfer_date->format('M j, Y') }} +
{{ $transfer->fromAccount->name }}
+ @if($transfer->fromAccount->account_number_last4) +
***{{ $transfer->fromAccount->account_number_last4 }}
+ @endif +
+
{{ $transfer->toAccount->name }}
+ @if($transfer->toAccount->account_number_last4) +
***{{ $transfer->toAccount->account_number_last4 }}
+ @endif +
+ ${{ number_format($transfer->amount, 2) }} + {{ $transfer->reference ?? '-' }} + + {{ ucfirst($transfer->status) }} + + + + + +
+
+ @else +
+ +

No Transfers

+

Create a transfer to move funds between accounts.

+ +
+ @endif +
+
+
+@endsection diff --git a/resources/views/seller/management/bank-transfers/show.blade.php b/resources/views/seller/management/bank-transfers/show.blade.php new file mode 100644 index 00000000..a68f9baa --- /dev/null +++ b/resources/views/seller/management/bank-transfers/show.blade.php @@ -0,0 +1,161 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Transfer Details - Bank Transfers') + +@section('content') +
+ {{-- Page Header --}} +
+
+ + + Back to Transfers + +

+ Transfer #{{ $transfer->id }} + {{ ucfirst($transfer->status) }} +

+

{{ $transfer->transfer_date->format('F j, Y') }}

+
+ @if($transfer->isPending()) +
+
+ @csrf + +
+
+ @csrf + +
+
+ @endif +
+ +
+ {{-- Transfer Details --}} +
+
+

Transfer Details

+
+
+ Amount + ${{ number_format($transfer->amount, 2) }} +
+ +
+ From Account +
+
{{ $transfer->fromAccount->name }}
+ @if($transfer->fromAccount->account_number_last4) +
***{{ $transfer->fromAccount->account_number_last4 }}
+ @endif +
+
+ +
+ To Account +
+
{{ $transfer->toAccount->name }}
+ @if($transfer->toAccount->account_number_last4) +
***{{ $transfer->toAccount->account_number_last4 }}
+ @endif +
+
+ +
+ Transfer Date + {{ $transfer->transfer_date->format('M j, Y') }} +
+ + @if($transfer->reference) +
+ Reference + {{ $transfer->reference }} +
+ @endif + +
+ Status + {{ ucfirst($transfer->status) }} +
+
+
+
+ + {{-- Additional Info --}} +
+ {{-- Notes --}} + @if($transfer->notes) +
+
+

Notes

+

{{ $transfer->notes }}

+
+
+ @endif + + {{-- Journal Entry Link --}} + @if($transfer->journalEntry) +
+
+

Journal Entry

+ +

+ Posted {{ $transfer->journalEntry->entry_date->format('M j, Y') }} +

+
+
+ @elseif($transfer->status === 'completed') +
+ + This transfer was completed but no journal entry was created. +
+ @endif + + {{-- Audit Info --}} +
+
+

Audit Trail

+
+
+ Created + {{ $transfer->created_at->format('M j, Y g:i A') }} +
+ @if($transfer->created_by) +
+ Created By + {{ $transfer->creator->name ?? 'Unknown' }} +
+ @endif + @if($transfer->completed_at) +
+ Completed + {{ $transfer->completed_at->format('M j, Y g:i A') }} +
+ @endif + @if($transfer->approved_by) +
+ Completed By + {{ $transfer->approver->name ?? 'Unknown' }} +
+ @endif +
+
+
+
+
+
+@endsection diff --git a/resources/views/seller/management/directory/vendors/index.blade.php b/resources/views/seller/management/directory/vendors/index.blade.php index 7a217f22..2dbc78b7 100644 --- a/resources/views/seller/management/directory/vendors/index.blade.php +++ b/resources/views/seller/management/directory/vendors/index.blade.php @@ -51,7 +51,26 @@ @endif - @if($vendor->business) + @if($isParent ?? false) + {{-- For parent companies, show which divisions are using this vendor --}} +
+ @if($vendor->business) + + {{ $vendor->business->division_name ?? $vendor->business->name }} + + @endif + @if(isset($vendorDivisionUsage[$vendor->id])) + @foreach($vendorDivisionUsage[$vendor->id] as $usage) + @if(!$vendor->business || $usage['business']->id !== $vendor->business->id) + + {{ $usage['business']->division_name ?? $usage['business']->name }} + ({{ $usage['bill_count'] }}) + + @endif + @endforeach + @endif +
+ @elseif($vendor->business) {{ $vendor->business->division_name ?? $vendor->business->name }} @else - diff --git a/resources/views/seller/management/finance/divisions.blade.php b/resources/views/seller/management/finance/divisions.blade.php index 084a3c18..4f2279eb 100644 --- a/resources/views/seller/management/finance/divisions.blade.php +++ b/resources/views/seller/management/finance/divisions.blade.php @@ -6,7 +6,7 @@

Divisional Rollup

-

AP summary across all divisions

+

AP & AR summary across all divisions

- {{-- Summary Totals --}} -
-
-
Total AP Outstanding
-
${{ number_format($totals['ap_outstanding'], 2) }}
-
All divisions combined
-
-
-
Total Overdue
-
- ${{ number_format($totals['ap_overdue'], 2) }} + {{-- Summary Totals - Two Row Layout --}} +
+ {{-- AP Summary --}} +
+
+

+ + Accounts Payable (AP) +

+
+
+
Outstanding
+
${{ number_format($totals['ap_outstanding'], 2) }}
+
+
+
Overdue
+
${{ number_format($totals['ap_overdue'], 2) }}
+
+
+
YTD Payments
+
${{ number_format($totals['ytd_payments'], 2) }}
+
+
+
Pending Approval
+
+ {{ $totals['pending_approval'] }} {{ Str::plural('bill', $totals['pending_approval']) }} +
+
+
-
Past due date
-
-
YTD Payments
-
${{ number_format($totals['ytd_payments'], 2) }}
-
{{ now()->format('Y') }} to date
-
-
-
Pending Approval
-
- {{ $totals['pending_approval'] }} + + {{-- AR Summary --}} +
+
+

+ + Accounts Receivable (AR) +

+
+
+
Outstanding
+
${{ number_format($totals['ar_total'], 2) }}
+
+
+
Overdue
+
${{ number_format($totals['ar_overdue'], 2) }}
+
+
+
At-Risk Customers
+
+ {{ $totals['ar_at_risk'] }} +
+
+
+
On Credit Hold
+
+ {{ $totals['ar_on_hold'] }} +
+
+
-
{{ Str::plural('bill', $totals['pending_approval']) }} awaiting review
- {{-- Divisions Table --}} -
-
-

Division Breakdown

-
- - - - - - - - - - - - - @forelse($divisions as $div) - - - - - - - - - @empty - - - - @endforelse - - @if($divisions->count() > 0) - - - - - - - - - - - @endif -
DivisionAP OutstandingOverdueYTD PaymentsPending Approval
-
{{ $div['division']->division_name ?? $div['division']->name }}
-
{{ $div['division']->slug }}
-
${{ number_format($div['ap_outstanding'], 2) }} - ${{ number_format($div['ap_overdue'], 2) }} - ${{ number_format($div['ytd_payments'], 2) }} + {{-- Divisions Cards --}} +
+

Division Breakdown

+ @forelse($divisions as $div) +
+
+ {{-- Division Header --}} +
+
+

{{ $div['division']->division_name ?? $div['division']->name }}

+
{{ $div['division']->slug }}
+
+ +
+ + {{-- AP vs AR Side by Side --}} +
+ {{-- AP Section --}} +
+
+ + Accounts Payable +
+
+
+
Outstanding
+
${{ number_format($div['ap_outstanding'], 2) }}
+
+
+
Overdue
+
+ ${{ number_format($div['ap_overdue'], 2) }} +
+
+
+
YTD Payments
+
${{ number_format($div['ytd_payments'], 2) }}
+
+
+
Pending Approval
@if($div['pending_approval'] > 0) - {{ $div['pending_approval'] }} + {{ $div['pending_approval'] }} @else 0 @endif -
- - - Spend - -
- No divisions found. This dashboard is for parent companies only. -
Total ({{ $divisions->count() }} divisions)${{ number_format($totals['ap_outstanding'], 2) }} - ${{ number_format($totals['ap_overdue'], 2) }} - ${{ number_format($totals['ytd_payments'], 2) }} - @if($totals['pending_approval'] > 0) - {{ $totals['pending_approval'] }} + + + + + + {{-- AR Section --}} +
+
+ + Accounts Receivable +
+
+
+
Outstanding
+
${{ number_format($div['ar_total'], 2) }}
+
+
+
Overdue
+
+ ${{ number_format($div['ar_overdue'], 2) }} +
+
+
+
At-Risk
+ @if($div['ar_at_risk'] > 0) + {{ $div['ar_at_risk'] }} customers @else 0 @endif -
+
+
+
On Hold
+ @if($div['ar_on_hold'] > 0) + {{ $div['ar_on_hold'] }} + @else + 0 + @endif +
+
+
+
+
+
-
+ @empty +
+
+ +

No divisions found. This dashboard is for parent companies only.

+
+
+ @endforelse
- {{-- Division Comparison Chart --}} + {{-- Totals Footer --}} + @if($divisions->count() > 0) +
+
+
+
+ Totals ({{ $divisions->count() }} {{ Str::plural('division', $divisions->count()) }}) +
+
+
+
AP Outstanding
+
${{ number_format($totals['ap_outstanding'], 2) }}
+
+
+
AP Overdue
+
${{ number_format($totals['ap_overdue'], 2) }}
+
+
+
AR Outstanding
+
${{ number_format($totals['ar_total'], 2) }}
+
+
+
AR Overdue
+
${{ number_format($totals['ar_overdue'], 2) }}
+
+
+
+
+
+ @endif + + {{-- Visual Comparison Chart --}} @if($divisions->count() > 0)
-

AP Outstanding by Division

+

AP vs AR by Division

@php - $maxOutstanding = $divisions->max('ap_outstanding') ?: 1; + $maxAmount = max($divisions->max('ap_outstanding'), $divisions->max('ar_total')) ?: 1; @endphp @foreach($divisions as $div) @php - $pct = ($div['ap_outstanding'] / $maxOutstanding) * 100; - $overduePct = $div['ap_outstanding'] > 0 - ? ($div['ap_overdue'] / $div['ap_outstanding']) * 100 - : 0; + $apPct = ($div['ap_outstanding'] / $maxAmount) * 100; + $arPct = ($div['ar_total'] / $maxAmount) * 100; @endphp
{{ $div['division']->division_name ?? $div['division']->name }} - ${{ number_format($div['ap_outstanding'], 2) }}
-
-
- @if($overduePct > 0) -
- @endif -
+
+
+
AP: ${{ number_format($div['ap_outstanding'], 0) }}
+
+
+
+
+
+
AR: ${{ number_format($div['ar_total'], 0) }}
+
+
+
@@ -156,10 +271,10 @@
- Current + AP (Payables) - Overdue + AR (Receivables)
diff --git a/resources/views/seller/management/finance/index.blade.php b/resources/views/seller/management/finance/index.blade.php index 892a1bce..ef584893 100644 --- a/resources/views/seller/management/finance/index.blade.php +++ b/resources/views/seller/management/finance/index.blade.php @@ -9,35 +9,153 @@
- {{-- Quick Stats --}} -
-
-
AP Outstanding
-
${{ number_format($aging['total'], 2) }}
-
Total payables
-
-
-
Overdue Bills
-
- {{ $aging['overdue_bills']->count() }} + {{-- Accounts Receivable Section --}} +
+
+

+ + Accounts Receivable +

+ + {{-- AR Stats - Clickable drill-down links --}} + -
Needs attention
+ + {{-- Top AR Accounts --}} + @if(isset($topArAccounts) && $topArAccounts->count() > 0) +
+

Top AR Accounts

+ + + + + @if($isParent) + + @endif + + + + + + + @foreach($topArAccounts as $account) + + + @if($isParent) + + @endif + + + + + @endforeach + +
AccountDivisionBalancePast DueStatus
{{ $account['customer']->name ?? 'N/A' }}{{ $account['business']->name ?? 'N/A' }}${{ number_format($account['balance'], 2) }}${{ number_format($account['past_due'], 2) }} + @if($account['on_credit_hold']) + Hold + @elseif($account['past_due'] > 0) + Past Due + @else + Good + @endif +
+
+ @endif
-
-
Due (Next 7 Days)
-
${{ number_format($forecast['total'], 2) }}
-
{{ $forecast['bill_count'] }} {{ Str::plural('bill', $forecast['bill_count']) }}
-
- @if($isParent) -
-
- +
+ + {{-- Accounts Payable Section --}} + {{-- Quick Links --}} @@ -45,21 +163,36 @@

Finance Reports

+ {{-- AR Reports --}} + + + AR Aging + + + + AR Accounts + + + {{-- AP Reports --}} - + AP Aging + + + Vendor Spend + + + {{-- Cash & Forecasting --}} Cash Forecast - - - Vendor Spend - + + {{-- Parent-only links --}} @if($isParent) - + Divisions @endif diff --git a/resources/views/seller/management/forecasting/index.blade.php b/resources/views/seller/management/forecasting/index.blade.php new file mode 100644 index 00000000..8d2e3785 --- /dev/null +++ b/resources/views/seller/management/forecasting/index.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Forecasting - Management') + +@section('content') +
+ {{-- Page Header --}} +
+
+

Financial Forecasting

+

12-month revenue and expense projections based on historical trends

+
+
+ + {{-- Division Filter --}} + @include('seller.management.partials.division-filter') + + {{-- Trends Summary --}} +
+
+
+
Revenue Trend
+
+ @if($forecast['trends']['revenue'] >= 0) + +{{ number_format($forecast['trends']['revenue'], 1) }}% + + @else + {{ number_format($forecast['trends']['revenue'], 1) }}% + + @endif +
+
Based on last 12 months
+
+
+
+
+
Expense Trend
+
+ @if($forecast['trends']['expenses'] <= 0) + {{ number_format($forecast['trends']['expenses'], 1) }}% + + @else + +{{ number_format($forecast['trends']['expenses'], 1) }}% + + @endif +
+
Based on last 12 months
+
+
+
+
+
Projected Net Income (12mo)
+
+ ${{ number_format($forecast['summary']['total_projected_net'], 0) }} +
+
Next 12 months total
+
+
+
+ + {{-- Forecast Table --}} +
+
+

12-Month Forecast

+
+ + + + + + + + + + + + @foreach($forecast['forecast'] as $month) + @php + $margin = $month['projected_revenue'] > 0 + ? ($month['projected_net'] / $month['projected_revenue']) * 100 + : 0; + @endphp + + + + + + + + @endforeach + + + + + + + + + + +
MonthProjected RevenueProjected ExpensesProjected NetMargin
{{ $month['month'] }}${{ number_format($month['projected_revenue'], 0) }}${{ number_format($month['projected_expenses'], 0) }} + ${{ number_format($month['projected_net'], 0) }} + + + {{ number_format($margin, 1) }}% + +
Total${{ number_format($forecast['summary']['total_projected_revenue'], 0) }}${{ number_format($forecast['summary']['total_projected_expenses'], 0) }} + ${{ number_format($forecast['summary']['total_projected_net'], 0) }} + + @php + $totalMargin = $forecast['summary']['total_projected_revenue'] > 0 + ? ($forecast['summary']['total_projected_net'] / $forecast['summary']['total_projected_revenue']) * 100 + : 0; + @endphp + {{ number_format($totalMargin, 1) }}% +
+
+
+
+ + {{-- Historical Data --}} +
+
+

Historical Data (Last 12 Months)

+
+ + + + + + + + + + + @foreach($forecast['historical']['revenue'] as $index => $revenueMonth) + @php + $expenses = $forecast['historical']['expenses'][$index]['amount'] ?? 0; + $net = $revenueMonth['amount'] - $expenses; + @endphp + + + + + + + @endforeach + +
MonthRevenueExpensesNet
{{ $revenueMonth['month'] }}${{ number_format($revenueMonth['amount'], 0) }}${{ number_format($expenses, 0) }} + ${{ number_format($net, 0) }} +
+
+
+
+ + {{-- Methodology Note --}} +
+ +
+
Forecast Methodology
+
+ Projections are based on linear regression analysis of the past 12 months of revenue and expense data. + Actual results may vary based on market conditions, business decisions, and external factors. +
+
+
+
+@endsection diff --git a/resources/views/seller/management/inventory-valuation/index.blade.php b/resources/views/seller/management/inventory-valuation/index.blade.php new file mode 100644 index 00000000..8f107e8f --- /dev/null +++ b/resources/views/seller/management/inventory-valuation/index.blade.php @@ -0,0 +1,371 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Inventory Valuation - Management') + +@section('content') +
+ {{-- Page Header --}} +
+
+

Inventory Valuation

+

+ @if($selectedDivision ?? false) + {{ $selectedDivision->division_name ?? $selectedDivision->name }} - Inventory overview + @else + Consolidated inventory overview + @endif +

+
+
+ As of {{ now()->format('M j, Y g:i A') }} +
+
+ + {{-- Division Filter --}} + @include('seller.management.partials.division-filter') + + {{-- Risk Alert Banner --}} + @if(($atRisk['quarantined']['count'] ?? 0) > 0 || ($atRisk['expired']['count'] ?? 0) > 0) +
+ +
+
Inventory At Risk
+
+ @if($atRisk['expired']['count'] > 0) + {{ $atRisk['expired']['count'] }} expired items (${{ number_format($atRisk['expired']['value'], 0) }}) + @endif + @if($atRisk['expired']['count'] > 0 && $atRisk['quarantined']['count'] > 0), @endif + @if($atRisk['quarantined']['count'] > 0) + {{ $atRisk['quarantined']['count'] }} quarantined items (${{ number_format($atRisk['quarantined']['value'], 0) }}) + @endif +
+
+
+ @endif + + {{-- TOP ROW: Summary KPIs --}} +
+ {{-- Total Inventory Value --}} +
+
+
Total Inventory Value
+
${{ number_format($summary['total_value'], 0) }}
+
{{ number_format($summary['total_items']) }} items
+
+
+ + {{-- Total Quantity --}} +
+
+
Total Quantity
+
{{ number_format($summary['total_quantity'], 0) }}
+
units on hand
+
+
+ + {{-- Avg Value per Item --}} +
+
+
Avg Value/Item
+
${{ number_format($summary['avg_value_per_item'], 2) }}
+
per inventory item
+
+
+ + {{-- Expiring Soon --}} +
+
+
Expiring Soon
+
+ {{ $atRisk['expiring_soon']['count'] ?? 0 }} +
+
${{ number_format($atRisk['expiring_soon']['value'] ?? 0, 0) }} value
+
+
+
+ + {{-- SECOND ROW: Charts/Breakdowns --}} +
+ {{-- Valuation by Item Type --}} +
+
+

Valuation by Type

+ @if($byType->count() > 0) +
+ + + + + + + + + + + + @foreach($byType as $type) + + + + + + + + @endforeach + +
TypeItemsQuantityValue%
{{ $type['item_type_label'] }}{{ number_format($type['item_count']) }}{{ number_format($type['total_quantity'], 0) }}${{ number_format($type['total_value'], 0) }} + {{ $summary['total_value'] > 0 ? number_format(($type['total_value'] / $summary['total_value']) * 100, 1) : 0 }}% +
+
+ @else +
No inventory data available
+ @endif +
+
+ + {{-- Valuation by Category --}} +
+
+

Valuation by Category

+ @if($byCategory->count() > 0) +
+ + + + + + + + + + + @foreach($byCategory->take(8) as $cat) + + + + + + + @endforeach + +
CategoryItemsQuantityValue
{{ $cat['category'] }}{{ number_format($cat['item_count']) }}{{ number_format($cat['total_quantity'], 0) }}${{ number_format($cat['total_value'], 0) }}
+
+ @if($byCategory->count() > 8) +
+ + {{ $byCategory->count() - 8 }} more categories +
+ @endif + @else +
No categorized inventory
+ @endif +
+
+
+ + {{-- THIRD ROW: Division & Location --}} + @if($isParent && $byDivision->count() > 0) +
+ {{-- Valuation by Division --}} +
+
+

Valuation by Division

+
+ + + + + + + + + + + @foreach($byDivision as $div) + + + + + + + @endforeach + +
DivisionItemsValue%
{{ $div['business_name'] }}{{ number_format($div['item_count']) }}${{ number_format($div['total_value'], 0) }} + {{ $summary['total_value'] > 0 ? number_format(($div['total_value'] / $summary['total_value']) * 100, 1) : 0 }}% +
+
+
+
+ + {{-- Valuation by Location --}} +
+
+

Valuation by Location

+ @if($byLocation->count() > 0) +
+ + + + + + + + + + @foreach($byLocation->take(6) as $loc) + + + + + + @endforeach + +
LocationItemsValue
{{ $loc['location_name'] }}{{ number_format($loc['item_count']) }}${{ number_format($loc['total_value'], 0) }}
+
+ @else +
No location data
+ @endif +
+
+
+ @elseif($byLocation->count() > 0) +
+
+

Valuation by Location

+
+ + + + + + + + + + + @foreach($byLocation as $loc) + + + + + + + @endforeach + +
LocationItemsQuantityValue
{{ $loc['location_name'] }}{{ number_format($loc['item_count']) }}{{ number_format($loc['total_quantity'], 0) }}${{ number_format($loc['total_value'], 0) }}
+
+
+
+ @endif + + {{-- FOURTH ROW: Aging & Top Items --}} +
+ {{-- Inventory Aging --}} +
+
+

Inventory Aging

+

Based on received date

+
+ @foreach($aging as $bucket => $data) + @if($data['count'] > 0 || $bucket !== 'no_date') +
+
+ {{ $data['label'] }} + ${{ number_format($data['value'], 0) }} +
+
+ + {{ $data['count'] }} +
+
+ @endif + @endforeach +
+
+
+ + {{-- Inventory At Risk --}} +
+
+

Inventory At Risk

+
+
+
+ {{ $atRisk['quarantined']['count'] ?? 0 }} +
+
Quarantined
+
${{ number_format($atRisk['quarantined']['value'] ?? 0, 0) }}
+
+
+
+ {{ $atRisk['expiring_soon']['count'] ?? 0 }} +
+
Expiring Soon
+
${{ number_format($atRisk['expiring_soon']['value'] ?? 0, 0) }}
+
+
+
+ {{ $atRisk['expired']['count'] ?? 0 }} +
+
Expired
+
${{ number_format($atRisk['expired']['value'] ?? 0, 0) }}
+
+
+
+
+
+ + {{-- FIFTH ROW: Top Items by Value --}} +
+
+

Top 10 Items by Value

+ @if($topItems->count() > 0) +
+ + + + + + + @if($isParent) + + @endif + + + + + + + @foreach($topItems as $item) + + + + + @if($isParent) + + @endif + + + + + @endforeach + +
ItemSKUTypeDivisionQtyUnit CostTotal Value
+
{{ $item['name'] }}
+ @if($item['product_name']) +
{{ $item['product_name'] }}
+ @endif +
{{ $item['sku'] ?? '-' }} + {{ $item['item_type_label'] }} + {{ $item['business_name'] }}{{ number_format($item['quantity_on_hand'], 0) }} {{ $item['unit_of_measure'] }}${{ number_format($item['unit_cost'], 2) }}${{ number_format($item['total_value'], 0) }}
+
+ @else +
No inventory items with costs defined
+ @endif +
+
+
+@endsection diff --git a/resources/views/seller/management/operations/index.blade.php b/resources/views/seller/management/operations/index.blade.php new file mode 100644 index 00000000..47cf202e --- /dev/null +++ b/resources/views/seller/management/operations/index.blade.php @@ -0,0 +1,211 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Operations Overview - Management') + +@section('content') +
+ {{-- Page Header --}} +
+
+

Operations Overview

+

Real-time operational metrics across all business units

+
+
+ + + Last updated: {{ now()->format('M j, Y g:i A') }} + +
+
+ + {{-- Division Filter --}} + @include('seller.management.partials.division-filter') + + {{-- Order Status --}} +
+
+
+
+
+
Pending Orders
+
{{ $operations['orders']->pending_orders ?? 0 }}
+
+ +
+
+
+
+
+
+
+
Processing
+
{{ $operations['orders']->processing_orders ?? 0 }}
+
+ +
+
+
+
+
+
+
+
Completed (MTD)
+
{{ $operations['orders']->completed_this_month ?? 0 }}
+
+ +
+
+
+
+
+
+
+
This Week
+
{{ $operations['orders']->orders_this_week ?? 0 }}
+
+ +
+
+
+
+ +
+ {{-- Inventory Status --}} +
+
+

Inventory Status

+
+
+
Total Products
+
{{ $operations['products']->total_products ?? 0 }}
+
{{ $operations['products']->active_products ?? 0 }} active
+
+
+
Low Stock
+
{{ $operations['products']->low_stock_products ?? 0 }}
+
Need reorder
+
+
+
Out of Stock
+
{{ $operations['products']->out_of_stock_products ?? 0 }}
+
Immediate attention required
+
+
+
+
+ + {{-- Customer Metrics --}} +
+
+

Customer Metrics

+
+
+
Total Customers
+
{{ $operations['customers']->total_customers ?? 0 }}
+
+
+
New This Month
+
{{ $operations['customers']->new_this_month ?? 0 }}
+
+
+
+
+
+ +
+ {{-- Accounts Payable Status --}} +
+
+

Accounts Payable

+
+
+ Pending Bills + {{ $operations['bills']->pending_bills ?? 0 }} +
+
+ Approved (Ready to Pay) + {{ $operations['bills']->approved_bills ?? 0 }} +
+
+ Overdue + {{ $operations['bills']->overdue_bills ?? 0 }} +
+
+ Total Pending Amount + ${{ number_format($operations['bills']->pending_amount ?? 0, 2) }} +
+
+
+
+ + {{-- Expense Approvals --}} +
+
+

Expense Approvals

+
+
+ Pending Approval + {{ $operations['expenses']->pending_expenses ?? 0 }} +
+
+ Pending Amount + ${{ number_format($operations['expenses']->pending_amount ?? 0, 2) }} +
+
+ @if(($operations['expenses']->pending_expenses ?? 0) > 0) + + @endif +
+
+
+ + {{-- Recent Orders --}} +
+
+

Recent Orders

+ @if(count($operations['recent_orders']) > 0) +
+ + + + + + + + + + + + @foreach($operations['recent_orders'] as $order) + + + + + + + + @endforeach + +
OrderBusinessStatusTotalDate
#{{ $order->id }}{{ $order->business_name }} + + {{ ucfirst($order->status) }} + + ${{ number_format($order->total, 2) }}{{ \Carbon\Carbon::parse($order->created_at)->diffForHumans() }}
+
+ @else +
+ + No recent orders +
+ @endif +
+
+
+@endsection diff --git a/resources/views/seller/management/usage-billing/index.blade.php b/resources/views/seller/management/usage-billing/index.blade.php new file mode 100644 index 00000000..4da55f8c --- /dev/null +++ b/resources/views/seller/management/usage-billing/index.blade.php @@ -0,0 +1,218 @@ +@extends('layouts.app-with-sidebar') + +@section('title', 'Usage & Billing - Management') + +@section('content') +
+ {{-- Page Header --}} +
+
+

Usage & Billing

+

Monitor platform usage and billing across your organization

+
+
+ + Billing Period: {{ $usage['billing_period']['start'] }} - {{ $usage['billing_period']['end'] }} + +
+
+ + {{-- Division Filter --}} + @include('seller.management.partials.division-filter') + + {{-- Plan Status --}} +
+
+
+
+

Current Plan

+
+ @if($usage['is_enterprise']) + Enterprise Plan + Unlimited usage + @else + Standard Plan + @endif +
+
+
+
Active Suites
+
+ @foreach($usage['enabled_suites'] as $suite) + {{ $suite['name'] }} + @endforeach +
+
+
+
+
+ + {{-- Usage Meters --}} +
+ {{-- Brands --}} +
+
+
+ Brands + + {{ $usage['brands']['current'] }} + @if($usage['brands']['limit']) + / {{ $usage['brands']['limit'] }} + @else + Unlimited + @endif + +
+ @if($usage['brands']['limit']) + + @else + + @endif +
+
+ + {{-- SKUs --}} +
+
+
+ Products (SKUs) + + {{ $usage['skus']['current'] }} + @if($usage['skus']['limit']) + / {{ $usage['skus']['limit'] }} + @else + Unlimited + @endif + +
+ @if($usage['skus']['limit']) + + @else + + @endif +
+
+ + {{-- Messages --}} +
+
+
+ Messages (MTD) + + {{ number_format($usage['messages']['current']) }} + @if($usage['messages']['limit']) + / {{ number_format($usage['messages']['limit']) }} + @else + Unlimited + @endif + +
+ @if($usage['messages']['limit']) + + @else + + @endif +
+
+ + {{-- Menu Sends --}} +
+
+
+ Menu Sends (MTD) + + {{ number_format($usage['menu_sends']['current']) }} + @if($usage['menu_sends']['limit']) + / {{ number_format($usage['menu_sends']['limit']) }} + @else + Unlimited + @endif + +
+ @if($usage['menu_sends']['limit']) + + @else + + @endif +
+
+ + {{-- Contacts --}} +
+
+
+ CRM Contacts + + {{ number_format($usage['contacts']['current']) }} + @if($usage['contacts']['limit']) + / {{ number_format($usage['contacts']['limit']) }} + @else + Unlimited + @endif + +
+ @if($usage['contacts']['limit']) + + @else + + @endif +
+
+
+ + {{-- Usage by Division --}} + @if(count($usage['usage_by_division']) > 0) +
+
+

Usage by Division

+
+ + + + + + + + + + @foreach($usage['usage_by_division'] as $division) + + + + + + @endforeach + + + + + + + + +
DivisionBrandsSKUs
{{ $division->name }}{{ $division->brand_count }}{{ $division->sku_count }}
Total{{ $usage['brands']['current'] }}{{ $usage['skus']['current'] }}
+
+
+
+ @endif + + {{-- Plan Limits Info --}} + @if(!$usage['is_enterprise']) +
+ +
+
Usage Limits
+
+ Your current plan includes {{ $usage['brands']['limit'] ?? 1 }} brand(s) with usage limits per brand. + Contact support to upgrade to Enterprise for unlimited usage. +
+
+
+ @endif +
+@endsection diff --git a/routes/seller.php b/routes/seller.php index 1c7830ec..d7b07bb5 100644 --- a/routes/seller.php +++ b/routes/seller.php @@ -1316,6 +1316,12 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () { Route::prefix('management/ar')->name('management.ar.')->middleware('suite:management')->group(function () { Route::get('/', [\App\Http\Controllers\Seller\Management\ArController::class, 'index'])->name('index'); Route::get('/aging', [\App\Http\Controllers\Seller\Management\ArController::class, 'aging'])->name('aging'); + Route::get('/accounts', [\App\Http\Controllers\Seller\Management\ArController::class, 'accounts'])->name('accounts'); + Route::get('/accounts/{customer}', [\App\Http\Controllers\Seller\Management\ArController::class, 'showAccount'])->name('accounts.show'); + Route::post('/accounts/{customer}/credit-limit', [\App\Http\Controllers\Seller\Management\ArController::class, 'updateCreditLimit'])->name('accounts.update-credit-limit'); + Route::post('/accounts/{customer}/terms', [\App\Http\Controllers\Seller\Management\ArController::class, 'updateTerms'])->name('accounts.update-terms'); + Route::post('/accounts/{customer}/hold', [\App\Http\Controllers\Seller\Management\ArController::class, 'placeHold'])->name('accounts.place-hold'); + Route::delete('/accounts/{customer}/hold', [\App\Http\Controllers\Seller\Management\ArController::class, 'removeHold'])->name('accounts.remove-hold'); }); // ================================================================================ @@ -1336,6 +1342,13 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () { Route::get('/vendor-spend', [\App\Http\Controllers\Seller\Management\FinanceController::class, 'vendorSpend'])->name('vendor-spend'); }); + // ================================================================================ + // MANAGEMENT SUITE - INVENTORY VALUATION + // ================================================================================ + Route::get('/management/inventory-valuation', [\App\Http\Controllers\Seller\Management\InventoryValuationController::class, 'index']) + ->middleware('suite:management') + ->name('management.inventory-valuation'); + // ================================================================================ // MANAGEMENT SUITE - ACCOUNTING / GENERAL LEDGER // ================================================================================ @@ -1443,5 +1456,71 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () { // Cash Flow Forecast Route::get('/cash-flow-forecast', [\App\Http\Controllers\Seller\Management\CashFlowForecastController::class, 'index'])->name('cash-flow-forecast'); }); + + // ================================================================================ + // MANAGEMENT SUITE - BANK ACCOUNTS + // ================================================================================ + Route::prefix('management/bank-accounts')->name('management.bank-accounts.')->middleware('suite:management')->group(function () { + Route::get('/', [\App\Http\Controllers\Seller\Management\BankAccountsController::class, 'index'])->name('index'); + Route::get('/create', [\App\Http\Controllers\Seller\Management\BankAccountsController::class, 'create'])->name('create'); + Route::post('/', [\App\Http\Controllers\Seller\Management\BankAccountsController::class, 'store'])->name('store'); + Route::get('/{bankAccount}', [\App\Http\Controllers\Seller\Management\BankAccountsController::class, 'show'])->name('show'); + Route::get('/{bankAccount}/edit', [\App\Http\Controllers\Seller\Management\BankAccountsController::class, 'edit'])->name('edit'); + Route::put('/{bankAccount}', [\App\Http\Controllers\Seller\Management\BankAccountsController::class, 'update'])->name('update'); + Route::post('/{bankAccount}/toggle-active', [\App\Http\Controllers\Seller\Management\BankAccountsController::class, 'toggleActive'])->name('toggle-active'); + + // Reconciliation routes + Route::get('/{bankAccount}/reconciliation', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'show'])->name('reconciliation'); + Route::post('/{bankAccount}/reconciliation/sync', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'syncTransactions'])->name('reconciliation.sync'); + Route::get('/{bankAccount}/reconciliation/matches/{transaction}', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'findMatches'])->name('reconciliation.find-matches'); + Route::post('/{bankAccount}/reconciliation/match-ap/{transaction}', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'matchToApPayment'])->name('reconciliation.match-ap'); + Route::post('/{bankAccount}/reconciliation/match-je/{transaction}', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'matchToJournalEntry'])->name('reconciliation.match-je'); + Route::post('/{bankAccount}/reconciliation/confirm', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'confirmAutoMatches'])->name('reconciliation.confirm'); + Route::post('/{bankAccount}/reconciliation/reject', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'rejectAutoMatches'])->name('reconciliation.reject'); + Route::post('/{bankAccount}/reconciliation/ignore', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'ignoreTransactions'])->name('reconciliation.ignore'); + Route::get('/{bankAccount}/reconciliation/rules', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'matchRules'])->name('reconciliation.rules'); + Route::post('/{bankAccount}/reconciliation/rules/{rule}/toggle', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'toggleRuleAutoEnable'])->name('reconciliation.rules.toggle'); + Route::post('/{bankAccount}/link-plaid', [\App\Http\Controllers\Seller\Management\BankReconciliationController::class, 'linkPlaidAccount'])->name('link-plaid'); + }); + + // ================================================================================ + // MANAGEMENT SUITE - BANK TRANSFERS + // ================================================================================ + Route::prefix('management/bank-transfers')->name('management.bank-transfers.')->middleware('suite:management')->group(function () { + Route::get('/', [\App\Http\Controllers\Seller\Management\BankTransfersController::class, 'index'])->name('index'); + Route::get('/create', [\App\Http\Controllers\Seller\Management\BankTransfersController::class, 'create'])->name('create'); + Route::post('/', [\App\Http\Controllers\Seller\Management\BankTransfersController::class, 'store'])->name('store'); + Route::get('/{bankTransfer}', [\App\Http\Controllers\Seller\Management\BankTransfersController::class, 'show'])->name('show'); + Route::post('/{bankTransfer}/complete', [\App\Http\Controllers\Seller\Management\BankTransfersController::class, 'complete'])->name('complete'); + Route::post('/{bankTransfer}/cancel', [\App\Http\Controllers\Seller\Management\BankTransfersController::class, 'cancel'])->name('cancel'); + }); + + // ================================================================================ + // MANAGEMENT SUITE - CROSS-BUSINESS ANALYTICS + // ================================================================================ + Route::get('/management/analytics', [\App\Http\Controllers\Seller\Management\AnalyticsController::class, 'index']) + ->middleware('suite:management') + ->name('management.analytics.index'); + + // ================================================================================ + // MANAGEMENT SUITE - FORECASTING + // ================================================================================ + Route::get('/management/forecasting', [\App\Http\Controllers\Seller\Management\ForecastingController::class, 'index']) + ->middleware('suite:management') + ->name('management.forecasting.index'); + + // ================================================================================ + // MANAGEMENT SUITE - OPERATIONS OVERVIEW + // ================================================================================ + Route::get('/management/operations', [\App\Http\Controllers\Seller\Management\OperationsController::class, 'index']) + ->middleware('suite:management') + ->name('management.operations.index'); + + // ================================================================================ + // MANAGEMENT SUITE - USAGE & BILLING + // ================================================================================ + Route::get('/management/usage-billing', [\App\Http\Controllers\Seller\Management\UsageBillingController::class, 'index']) + ->middleware('suite:management') + ->name('management.usage-billing.index'); }); });