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
This commit is contained in:
kelly
2025-12-07 00:34:53 -07:00
parent 08df003b20
commit 6d64d9527a
63 changed files with 9872 additions and 168 deletions

View File

@@ -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)

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
class AnalyticsController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->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];
}
}

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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.');
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Services\Accounting\BankAccountService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankAccountsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BankAccountService $bankAccountService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->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.");
}
}

View File

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

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\BankTransfer;
use App\Models\Business;
use App\Services\Accounting\BankAccountService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankTransfersController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BankAccountService $bankAccountService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->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());
}
}
}

View File

@@ -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));
}

View File

@@ -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'
));
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ForecastingController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->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];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\InventoryValuationService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InventoryValuationController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected InventoryValuationService $valuationService
) {}
/**
* Display the inventory valuation dashboard.
*/
public function index(Request $request, Business $business): View
{
$this->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));
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OperationsController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->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];
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UsageBillingController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->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];
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Models\Accounting;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class BankAccount extends Model implements AuditableContract
{
use Auditable, HasFactory, SoftDeletes;
public const TYPE_CHECKING = 'checking';
public const TYPE_SAVINGS = 'savings';
public const TYPE_MONEY_MARKET = 'money_market';
protected $fillable = [
'business_id',
'name',
'account_type',
'bank_name',
'account_number_last4',
'routing_number',
'current_balance',
'available_balance',
'gl_account_id',
'currency',
'is_primary',
'is_active',
'plaid_account_id',
'last_synced_at',
'notes',
'created_by_user_id',
];
protected $casts = [
'current_balance' => '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);
}
}

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Models\Accounting;
use App\Models\Business;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BankMatchRule extends Model
{
use HasFactory;
public const PATTERN_EXACT = 'exact';
public const PATTERN_CONTAINS = 'contains';
public const PATTERN_STARTS_WITH = 'starts_with';
public const DIRECTION_DEBIT = 'debit';
public const DIRECTION_CREDIT = 'credit';
public const TARGET_AP_PAYMENT = 'ap_payment';
public const TARGET_AR_PAYMENT = 'ar_payment';
public const TARGET_JOURNAL_ENTRY = 'journal_entry';
public const TARGET_EXPENSE = 'expense';
/**
* Minimum training count before a rule can be auto-enabled
*/
public const MIN_TRAINING_COUNT = 3;
protected $fillable = [
'business_id',
'bank_account_id',
'pattern_name',
'pattern_type',
'direction',
'amount_tolerance',
'typical_amount',
'target_type',
'target_id',
'target_gl_account_id',
'training_count',
'auto_enabled',
'auto_match_count',
'rejection_count',
'last_used_at',
'notes',
];
protected $casts = [
'amount_tolerance' => '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;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Models\Accounting;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class BankTransfer extends Model implements AuditableContract
{
use Auditable, HasFactory, SoftDeletes;
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'business_id',
'from_bank_account_id',
'to_bank_account_id',
'amount',
'transfer_date',
'reference',
'status',
'memo',
'journal_entry_id',
'created_by_user_id',
'approved_by_user_id',
'approved_at',
];
protected $casts = [
'amount' => '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;
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Models\Accounting;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PlaidAccount extends Model
{
use HasFactory;
public const TYPE_DEPOSITORY = 'depository';
public const TYPE_CREDIT = 'credit';
public const TYPE_LOAN = 'loan';
public const TYPE_INVESTMENT = 'investment';
public const TYPE_OTHER = 'other';
protected $fillable = [
'plaid_item_id',
'plaid_account_id',
'bank_account_id',
'name',
'official_name',
'mask',
'type',
'subtype',
'currency',
'current_balance',
'available_balance',
'limit',
'is_active',
'last_synced_at',
];
protected $casts = [
'current_balance' => '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(),
]);
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Models\Accounting;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class PlaidItem extends Model
{
use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_REVOKED = 'revoked';
public const STATUS_ERROR = 'error';
protected $fillable = [
'business_id',
'plaid_item_id',
'plaid_access_token',
'institution_name',
'institution_id',
'status',
'error_message',
'consent_expires_at',
'last_synced_at',
'created_by_user_id',
];
protected $casts = [
'consent_expires_at' => '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,
]);
}
}

View File

@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Models\Accounting;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlaidTransaction extends Model
{
use HasFactory;
public const STATUS_UNMATCHED = 'unmatched';
public const STATUS_MATCHED = 'matched';
public const STATUS_PROPOSED_AUTO = 'proposed_auto';
public const STATUS_IGNORED = 'ignored';
protected $fillable = [
'plaid_account_id',
'plaid_transaction_id',
'date',
'authorized_date',
'name',
'merchant_name',
'amount',
'currency',
'pending',
'payment_channel',
'category',
'category_id',
'location',
'raw_data',
'status',
'linked_journal_entry_id',
'linked_ap_payment_id',
'linked_ar_payment_id',
'matched_by_rule_id',
'matched_by_user_id',
'matched_at',
'match_notes',
];
protected $casts = [
'date' => '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,
]);
}
}

View File

@@ -0,0 +1,504 @@
<?php
declare(strict_types=1);
namespace App\Services\Accounting;
use App\Models\Accounting\ArCustomer;
use App\Models\Accounting\ArInvoice;
use App\Models\Business;
use Illuminate\Support\Collection;
/**
* AR Service - Core AR operations and credit enforcement.
*
* Handles credit checks, term extensions, and AR account management.
* Management Suite (Canopy) can manage; Sales Suite is read-only.
*/
class ArService
{
/**
* Check if terms can be extended to a customer for a given amount.
*
* Used before converting quote order invoice with NET terms.
*
* @return array{can_extend: bool, reason: string|null, details: array}
*/
public function canExtendTerms(ArCustomer $customer, float $additionalAmount = 0): array
{
$details = [
'customer_id' => $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<int>|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<int>|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<int>
*/
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;
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Services\Accounting;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\BankTransfer;
use App\Models\Business;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class BankAccountService
{
public function __construct(
protected JournalEntryService $journalEntryService
) {}
/**
* Get all bank accounts for a business (with optional children).
*/
public function getAccountsForBusiness(Business $business, bool $includeChildren = false): Collection
{
$query = BankAccount::query();
if ($includeChildren && $business->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();
}
}

View File

@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
namespace App\Services\Accounting;
use App\Models\Accounting\ApPayment;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\BankMatchRule;
use App\Models\Accounting\JournalEntry;
use App\Models\Accounting\PlaidAccount;
use App\Models\Accounting\PlaidTransaction;
use App\Models\Business;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class BankReconciliationService
{
/**
* Get unmatched transactions for a bank account.
*/
public function getUnmatchedTransactions(
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)
->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;
}
}

View File

@@ -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();
});
}
/**

View File

@@ -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();
}

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\Services\Accounting;
use App\Models\Business;
use App\Models\InventoryItem;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Calculates inventory valuation across businesses for Management Suite.
* Read-only service - no inventory modifications.
*/
class InventoryValuationService
{
/**
* Get comprehensive inventory valuation summary.
*/
public function getValuationSummary(array $businessIds): array
{
$items = InventoryItem::whereIn('business_id', $businessIds)
->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)),
};
}
}

View File

@@ -0,0 +1,554 @@
<?php
declare(strict_types=1);
namespace App\Services\Accounting;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApPayment;
use App\Models\Accounting\GlAccount;
use App\Models\Accounting\JournalEntry;
use App\Models\Accounting\JournalEntryLine;
use App\Models\Business;
use Illuminate\Support\Facades\DB;
/**
* Service for creating and managing Journal Entries.
*
* Handles automatic JE creation for:
* - Bill approvals (DR Expense, CR Accounts Payable)
* - Payments (DR Accounts Payable, CR Cash)
* - Inter-company transactions (Due To / Due From)
*/
class JournalEntryService
{
/**
* Create a journal entry for an approved bill.
*
* DR: Expense accounts (from bill line items)
* CR: Accounts Payable
*/
public function createBillApprovalEntry(ApBill $bill): JournalEntry
{
return DB::transaction(function () use ($bill) {
$entry = JournalEntry::create([
'business_id' => $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');
});
}
}

View File

@@ -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();
});
}
/**

View File

@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace App\Services\Accounting;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\PlaidAccount;
use App\Models\Accounting\PlaidItem;
use App\Models\Accounting\PlaidTransaction;
use App\Models\Business;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* PlaidIntegrationService
*
* Handles Plaid integration including:
* - Token exchange and item storage
* - Balance syncing
* - Transaction syncing
*
* NOTE: Actual Plaid API calls are stubbed for now.
* When ready to integrate with Plaid, inject a Plaid client here.
*/
class PlaidIntegrationService
{
/**
* Link a Plaid item for a business (exchange public token).
*
* @param string $publicToken The public_token from Plaid Link
*/
public function linkItem(Business $business, string $publicToken, User $user): PlaidItem
{
// TODO: Call Plaid API to exchange public token for access token
// $response = $this->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]);
}
}

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Brand;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Service for managing brand-level access control.
*
* Used by Brand Manager Suite to:
* - Determine which brands a user can access
* - Scope queries to user's assigned brands
* - Enforce view-only permissions for external brand partners
*/
class BrandAccessService
{
/**
* Get brand IDs that a user has access to within a business.
*
* @return array<int>
*/
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();
}
}

View File

@@ -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,
],
];
/**

View File

@@ -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).

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('bank_accounts')) {
return;
}
Schema::create('bank_accounts', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('bank_transfers')) {
return;
}
Schema::create('bank_transfers', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('plaid_items')) {
return;
}
Schema::create('plaid_items', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Skip if table already exists
if (Schema::hasTable('plaid_accounts')) {
return;
}
// Ensure parent table exists before creating foreign key
if (! Schema::hasTable('plaid_items')) {
return; // Previous migration should have created it
}
Schema::create('plaid_accounts', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Skip if table already exists
if (Schema::hasTable('plaid_transactions')) {
return;
}
// Ensure parent table exists before creating foreign key
if (! Schema::hasTable('plaid_accounts')) {
return; // Previous migration should have created it
}
Schema::create('plaid_transactions', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Ensure parent tables exist before creating foreign keys
if (! Schema::hasTable('bank_accounts') || ! Schema::hasTable('gl_accounts')) {
return;
}
if (! Schema::hasTable('bank_match_rules')) {
Schema::create('bank_match_rules', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('ar_customers')) {
return;
}
Schema::table('ar_customers', function (Blueprint $table) {
if (! Schema::hasColumn('ar_customers', 'on_credit_hold')) {
$table->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']);
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ar_customers', function (Blueprint $table) {
// Add linked_business_id to connect AR customers to buyer businesses
if (! Schema::hasColumn('ar_customers', 'linked_business_id')) {
$table->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');
}
});
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Adds journal_entry_id to AP bills and payments to link them
* to their corresponding journal entries for audit trail.
*/
public function up(): void
{
// Add journal_entry_id to ap_bills if not exists
if (! Schema::hasColumn('ap_bills', 'journal_entry_id')) {
Schema::table('ap_bills', function (Blueprint $table) {
$table->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');
});
}
}
};

View File

@@ -0,0 +1,163 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Cross-Business Analytics - Management')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Cross-Business Analytics</h1>
<p class="text-base-content/60 mt-1">Consolidated financial performance across all divisions</p>
</div>
</div>
{{-- Division Filter --}}
@include('seller.management.partials.division-filter')
{{-- KPI Summary Cards --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="text-sm text-base-content/60">Total Revenue</div>
<div class="text-2xl font-bold text-success">${{ number_format($analytics['totals']['revenue'], 2) }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="text-sm text-base-content/60">Total Expenses</div>
<div class="text-2xl font-bold text-error">${{ number_format($analytics['totals']['expenses'], 2) }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="text-sm text-base-content/60">Net Income</div>
<div class="text-2xl font-bold {{ $analytics['totals']['net_income'] >= 0 ? 'text-success' : 'text-error' }}">
${{ number_format($analytics['totals']['net_income'], 2) }}
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="text-sm text-base-content/60">Outstanding AR</div>
<div class="text-2xl font-bold text-warning">${{ number_format($analytics['totals']['outstanding_ar'], 2) }}</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Revenue by Division --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Revenue by Division</h2>
@if($analytics['revenue_by_division']->count() > 0)
<div class="overflow-x-auto mt-4">
<table class="table table-sm">
<thead>
<tr>
<th>Division</th>
<th class="text-right">Orders</th>
<th class="text-right">Revenue</th>
</tr>
</thead>
<tbody>
@foreach($analytics['revenue_by_division'] as $row)
<tr>
<td class="font-medium">{{ $row->division_name }}</td>
<td class="text-right">{{ number_format($row->order_count) }}</td>
<td class="text-right text-success font-medium">${{ number_format($row->total_revenue, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--chart-bar] size-12 block mx-auto mb-2 opacity-40"></span>
No revenue data available
</div>
@endif
</div>
</div>
{{-- Expenses by Division --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Expenses by Division</h2>
@if($analytics['expenses_by_division']->count() > 0)
<div class="overflow-x-auto mt-4">
<table class="table table-sm">
<thead>
<tr>
<th>Division</th>
<th class="text-right">Bills</th>
<th class="text-right">Expenses</th>
</tr>
</thead>
<tbody>
@foreach($analytics['expenses_by_division'] as $row)
<tr>
<td class="font-medium">{{ $row->division_name }}</td>
<td class="text-right">{{ number_format($row->bill_count) }}</td>
<td class="text-right text-error font-medium">${{ number_format($row->total_expenses, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--document-minus] size-12 block mx-auto mb-2 opacity-40"></span>
No expense data available
</div>
@endif
</div>
</div>
{{-- AR by Division --}}
<div class="card bg-base-100 shadow-sm lg:col-span-2">
<div class="card-body">
<h2 class="card-title text-lg">Accounts Receivable by Division</h2>
@if($analytics['ar_by_division']->count() > 0)
<div class="overflow-x-auto mt-4">
<table class="table">
<thead>
<tr>
<th>Division</th>
<th class="text-right">Total AR</th>
<th class="text-right">Outstanding</th>
<th class="text-right">Collection Rate</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="font-medium">{{ $row->division_name }}</td>
<td class="text-right">${{ number_format($row->total_ar, 2) }}</td>
<td class="text-right text-warning font-medium">${{ number_format($row->outstanding_ar, 2) }}</td>
<td class="text-right">
<span class="badge {{ $collectionRate >= 80 ? 'badge-success' : ($collectionRate >= 50 ? 'badge-warning' : 'badge-error') }} badge-sm">
{{ number_format($collectionRate, 1) }}%
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--document-currency-dollar] size-12 block mx-auto mb-2 opacity-40"></span>
No accounts receivable data available
</div>
@endif
</div>
</div>
</div>
</div>
@endsection

View File

@@ -76,6 +76,9 @@
<tr>
<th>Code</th>
<th>Name</th>
@if($isParent ?? false)
<th>Divisions</th>
@endif
<th>Contact</th>
<th>Payment Terms</th>
<th class="text-center">Bills</th>
@@ -93,6 +96,19 @@
<div class="text-xs text-base-content/70">{{ $vendor->legal_name }}</div>
@endif
</td>
@if($isParent ?? false)
<td>
@if($vendor->divisions_using && $vendor->divisions_using->count() > 0)
<div class="flex flex-wrap gap-1">
@foreach($vendor->divisions_using as $division)
<span class="badge badge-sm badge-outline">{{ $division->name }}</span>
@endforeach
</div>
@else
<span class="text-base-content/50 text-sm">-</span>
@endif
</td>
@endif
<td>
@if($vendor->contact_email)
<div class="text-sm">{{ $vendor->contact_email }}</div>
@@ -147,7 +163,7 @@
</tr>
@empty
<tr>
<td colspan="7" class="text-center py-8 text-base-content/70">
<td colspan="{{ ($isParent ?? false) ? 8 : 7 }}" class="text-center py-8 text-base-content/70">
<span class="icon-[heroicons--building-storefront] size-12 opacity-50 mb-2"></span>
<p>No vendors found</p>
<button type="button" @click="openCreateModal" class="btn btn-primary btn-sm mt-4">

View File

@@ -0,0 +1,343 @@
@extends('layouts.app-with-sidebar')
@section('title', 'AR Account - ' . $customer->name)
@section('content')
<div class="p-6 space-y-6">
{{-- Page Header --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">{{ $customer->name }}</h1>
<p class="text-base-content/70">AR Account Details</p>
</div>
<div class="flex gap-2">
<a href="{{ route('seller.business.management.ar.accounts', $business) }}" class="btn btn-ghost btn-sm">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to Accounts
</a>
</div>
</div>
{{-- Account Status Banner --}}
@if($customer->on_credit_hold)
<div class="alert alert-error">
<span class="icon-[heroicons--exclamation-triangle] size-5"></span>
<div>
<div class="font-bold">Credit Hold Active</div>
<div class="text-sm">{{ $customer->hold_reason ?? 'No reason provided' }}</div>
</div>
<form method="POST" action="{{ route('seller.business.management.ar.accounts.remove-hold', [$business, $customer]) }}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-ghost">Remove Hold</button>
</form>
</div>
@elseif(($summary['past_due_total'] ?? 0) > 0)
<div class="alert alert-warning">
<span class="icon-[heroicons--clock] size-5"></span>
<span>This account has ${{ number_format($summary['past_due_total'], 2) }} past due</span>
</div>
@endif
{{-- Summary Cards --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Total Balance</div>
<div class="text-2xl font-bold text-primary">${{ number_format($summary['total_open_ar'] ?? 0, 2) }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Past Due</div>
<div class="text-2xl font-bold {{ ($summary['past_due_total'] ?? 0) > 0 ? 'text-error' : '' }}">${{ number_format($summary['past_due_total'] ?? 0, 2) }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Credit Limit</div>
<div class="text-2xl font-bold">
@if(($summary['credit_limit'] ?? 0) > 0)
${{ number_format($summary['credit_limit'], 2) }}
@else
<span class="text-base-content/40">Not set</span>
@endif
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Available Credit</div>
<div class="text-2xl font-bold {{ ($summary['credit_available'] ?? 0) <= 0 && ($summary['credit_limit'] ?? 0) > 0 ? 'text-error' : 'text-success' }}">
@if(($summary['credit_limit'] ?? 0) > 0)
${{ number_format($summary['credit_available'] ?? 0, 2) }}
@else
<span class="text-base-content/40">N/A</span>
@endif
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Left Column: Account Info & Controls --}}
<div class="space-y-6">
{{-- Account Information --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Account Information</h2>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-base-content/60">Payment Terms</span>
<span class="font-medium">{{ $customer->payment_terms ?? 'Net 30' }}</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/60">Credit Status</span>
<span>
@if(($summary['credit_status'] ?? 'good') === 'hold')
<span class="badge badge-error">Hold</span>
@elseif(($summary['credit_status'] ?? 'good') === 'watch')
<span class="badge badge-warning">Watch</span>
@else
<span class="badge badge-success">Good</span>
@endif
</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/60">Open Invoices</span>
<span class="font-medium">{{ $summary['open_invoice_count'] ?? 0 }}</span>
</div>
@if($customer->email)
<div class="flex justify-between">
<span class="text-base-content/60">Email</span>
<span class="font-medium">{{ $customer->email }}</span>
</div>
@endif
@if($customer->phone)
<div class="flex justify-between">
<span class="text-base-content/60">Phone</span>
<span class="font-medium">{{ $customer->phone }}</span>
</div>
@endif
</div>
</div>
</div>
{{-- Aging Breakdown --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Aging Breakdown</h2>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-base-content/60">Current</span>
<span class="font-mono">${{ number_format($summary['aging']['current'] ?? 0, 2) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/60">1-30 Days</span>
<span class="font-mono {{ ($summary['aging']['1_30'] ?? 0) > 0 ? 'text-warning' : '' }}">${{ number_format($summary['aging']['1_30'] ?? 0, 2) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/60">31-60 Days</span>
<span class="font-mono {{ ($summary['aging']['31_60'] ?? 0) > 0 ? 'text-warning' : '' }}">${{ number_format($summary['aging']['31_60'] ?? 0, 2) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/60">61-90 Days</span>
<span class="font-mono {{ ($summary['aging']['61_90'] ?? 0) > 0 ? 'text-error' : '' }}">${{ number_format($summary['aging']['61_90'] ?? 0, 2) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/60">90+ Days</span>
<span class="font-mono {{ ($summary['aging']['90_plus'] ?? 0) > 0 ? 'text-error font-bold' : '' }}">${{ number_format($summary['aging']['90_plus'] ?? 0, 2) }}</span>
</div>
</div>
</div>
</div>
{{-- Credit Management --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Credit Management</h2>
{{-- Update Credit Limit --}}
<form method="POST" action="{{ route('seller.business.management.ar.accounts.update-credit-limit', [$business, $customer]) }}" class="space-y-2">
@csrf
<label class="label">
<span class="label-text">Credit Limit</span>
</label>
<div class="join w-full">
<span class="join-item btn btn-sm no-animation">$</span>
<input type="number" name="credit_limit" value="{{ $customer->credit_limit ?? 0 }}" step="0.01" min="0"
class="input input-bordered input-sm join-item w-full">
<button type="submit" class="btn btn-primary btn-sm join-item">Update</button>
</div>
</form>
{{-- Update Payment Terms --}}
<form method="POST" action="{{ route('seller.business.management.ar.accounts.update-terms', [$business, $customer]) }}" class="space-y-2 mt-4">
@csrf
<label class="label">
<span class="label-text">Payment Terms</span>
</label>
<div class="join w-full">
<select name="payment_terms" class="select select-bordered select-sm join-item w-full">
<option value="Due on Receipt" {{ $customer->payment_terms === 'Due on Receipt' ? 'selected' : '' }}>Due on Receipt</option>
<option value="COD" {{ $customer->payment_terms === 'COD' ? 'selected' : '' }}>COD</option>
<option value="Net 15" {{ $customer->payment_terms === 'Net 15' ? 'selected' : '' }}>Net 15</option>
<option value="Net 30" {{ ($customer->payment_terms ?? 'Net 30') === 'Net 30' ? 'selected' : '' }}>Net 30</option>
<option value="Net 45" {{ $customer->payment_terms === 'Net 45' ? 'selected' : '' }}>Net 45</option>
<option value="Net 60" {{ $customer->payment_terms === 'Net 60' ? 'selected' : '' }}>Net 60</option>
</select>
<button type="submit" class="btn btn-primary btn-sm join-item">Update</button>
</div>
</form>
{{-- Place/Remove Hold --}}
@if(!$customer->on_credit_hold)
<div class="divider my-2"></div>
<form method="POST" action="{{ route('seller.business.management.ar.accounts.place-hold', [$business, $customer]) }}" class="space-y-2">
@csrf
<label class="label">
<span class="label-text">Place Credit Hold</span>
</label>
<textarea name="reason" placeholder="Reason for hold..." class="textarea textarea-bordered textarea-sm w-full" rows="2" required></textarea>
<button type="submit" class="btn btn-error btn-sm w-full">
<span class="icon-[heroicons--hand-raised] size-4"></span>
Place Hold
</button>
</form>
@endif
</div>
</div>
</div>
{{-- Right Column: Invoices & Payments --}}
<div class="lg:col-span-2 space-y-6">
{{-- Open Invoices --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Open Invoices</h2>
@if($invoices->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Invoice #</th>
<th>Date</th>
<th>Due Date</th>
<th class="text-right">Amount</th>
<th class="text-right">Balance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($invoices as $invoice)
<tr class="hover">
<td class="font-medium">{{ $invoice->invoice_number }}</td>
<td>{{ $invoice->invoice_date?->format('M j, Y') }}</td>
<td class="{{ $invoice->due_date?->isPast() ? 'text-error' : '' }}">
{{ $invoice->due_date?->format('M j, Y') }}
@if($invoice->due_date?->isPast())
<span class="text-xs">({{ $invoice->due_date->diffForHumans() }})</span>
@endif
</td>
<td class="text-right font-mono">${{ number_format($invoice->total_amount, 2) }}</td>
<td class="text-right font-mono font-bold">${{ number_format($invoice->balance_due, 2) }}</td>
<td>
@if($invoice->status === 'overdue' || ($invoice->due_date && $invoice->due_date->isPast()))
<span class="badge badge-error badge-sm">Overdue</span>
@elseif($invoice->status === 'partial')
<span class="badge badge-warning badge-sm">Partial</span>
@else
<span class="badge badge-info badge-sm">Open</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/50">
<span class="icon-[heroicons--document-check] size-12 mx-auto mb-2 opacity-40"></span>
<p>No open invoices</p>
</div>
@endif
</div>
</div>
{{-- Recent Payments --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Recent Payments</h2>
@if($payments->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Reference</th>
<th>Method</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
@foreach($payments as $payment)
<tr class="hover">
<td>{{ $payment->payment_date?->format('M j, Y') }}</td>
<td class="font-medium">{{ $payment->reference_number ?? '-' }}</td>
<td>{{ ucfirst($payment->payment_method ?? 'Unknown') }}</td>
<td class="text-right font-mono text-success">${{ number_format($payment->amount, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/50">
<span class="icon-[heroicons--banknotes] size-12 mx-auto mb-2 opacity-40"></span>
<p>No recent payments</p>
</div>
@endif
</div>
</div>
{{-- Activity Log --}}
@if($activities->count() > 0)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Recent Activity</h2>
<div class="space-y-3">
@foreach($activities as $activity)
<div class="flex gap-3 items-start">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-base-200 flex items-center justify-center">
<span class="icon-[{{ $activity->action_icon }}] size-4 text-base-content/60"></span>
</div>
<div class="flex-1">
<div class="text-sm font-medium">{{ $activity->action_label }}</div>
<div class="text-sm text-base-content/70">{{ $activity->description }}</div>
<div class="text-xs text-base-content/50">
{{ $activity->created_at?->diffForHumans() }}
@if($activity->user)
by {{ $activity->user->name }}
@endif
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
{{-- AR Notes --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">AR Notes</h2>
<div class="text-sm whitespace-pre-wrap">
{{ $customer->ar_notes ?? 'No notes recorded.' }}
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,163 @@
@extends('layouts.app-with-sidebar')
@section('title', 'AR Accounts')
@section('content')
<div class="p-6 space-y-6">
{{-- Page Header --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">AR Accounts</h1>
<p class="text-base-content/70">Customer accounts with balances</p>
</div>
<div class="flex gap-2">
<a href="{{ route('seller.business.management.ar.index', $business) }}" class="btn btn-ghost btn-sm">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to AR
</a>
</div>
</div>
{{-- 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 --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Total AR</div>
<div class="text-2xl font-bold text-primary">${{ number_format($metrics['total_outstanding'] ?? 0, 2) }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Past Due</div>
<div class="text-2xl font-bold text-error">${{ number_format($metrics['overdue_amount'] ?? 0, 2) }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Accounts</div>
<div class="text-2xl font-bold">{{ $accounts->count() }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">On Hold</div>
<div class="text-2xl font-bold {{ $accounts->where('on_credit_hold', true)->count() > 0 ? 'text-error' : '' }}">
{{ $accounts->where('on_credit_hold', true)->count() }}
</div>
</div>
</div>
</div>
{{-- Filters --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<form method="GET" action="{{ route('seller.business.management.ar.accounts', $business) }}" class="flex flex-wrap items-center gap-4">
<div class="form-control">
<input type="text" name="search" placeholder="Search accounts..."
value="{{ $filters['search'] ?? '' }}"
class="input input-bordered input-sm w-64">
</div>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="on_hold" value="1"
{{ ($filters['on_hold'] ?? false) ? 'checked' : '' }}
class="checkbox checkbox-sm checkbox-error">
<span class="label-text">On Hold</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="at_risk" value="1"
{{ ($filters['at_risk'] ?? false) ? 'checked' : '' }}
class="checkbox checkbox-sm checkbox-warning">
<span class="label-text">At Risk</span>
</label>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
@if(($filters['search'] ?? '') || ($filters['on_hold'] ?? false) || ($filters['at_risk'] ?? false))
<a href="{{ route('seller.business.management.ar.accounts', $business) }}" class="btn btn-ghost btn-sm">Clear</a>
@endif
</form>
</div>
</div>
{{-- Accounts Table --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
@if($accounts->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Account</th>
<th class="text-right">Balance</th>
<th class="text-right">Past Due</th>
<th class="text-right">Credit Limit</th>
<th>Terms</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($accounts as $account)
<tr class="hover">
<td>
<div class="font-medium">{{ $account['customer']->name }}</div>
@if($isParent && $account['customer']->business)
<div class="text-xs text-base-content/50">{{ $account['customer']->business->division_name ?? $account['customer']->business->name }}</div>
@endif
</td>
<td class="text-right font-mono font-bold">${{ number_format($account['balance'], 2) }}</td>
<td class="text-right font-mono {{ $account['past_due'] > 0 ? 'text-error' : '' }}">
${{ number_format($account['past_due'], 2) }}
</td>
<td class="text-right font-mono">
@if($account['credit_limit'] > 0)
${{ number_format($account['credit_limit'], 2) }}
@if($account['balance'] > $account['credit_limit'])
<span class="badge badge-error badge-xs ml-1">Over</span>
@endif
@else
<span class="text-base-content/40">-</span>
@endif
</td>
<td>{{ $account['payment_terms'] }}</td>
<td>
@if($account['on_credit_hold'])
<span class="badge badge-error badge-sm">Hold</span>
@elseif($account['credit_status'] === 'watch')
<span class="badge badge-warning badge-sm">Watch</span>
@elseif($account['past_due'] > 0)
<span class="badge badge-warning badge-sm">Past Due</span>
@else
<span class="badge badge-success badge-sm">Good</span>
@endif
</td>
<td>
<a href="{{ route('seller.business.management.ar.accounts.show', [$business, $account['customer']]) }}"
class="btn btn-ghost btn-xs">
<span class="icon-[heroicons--eye] size-4"></span>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12 text-base-content/50">
<span class="icon-[heroicons--users] size-12 mx-auto mb-4 opacity-40"></span>
<p>No accounts found matching your criteria.</p>
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,174 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Add Bank Account - Management')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- Page Header --}}
<div class="mb-6">
<a href="{{ route('seller.business.management.bank-accounts.index', $business) }}" class="btn btn-ghost btn-sm mb-4">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to Bank Accounts
</a>
<h1 class="text-2xl font-bold">Add Bank Account</h1>
<p class="text-base-content/60 mt-1">Create a new bank account for tracking cash positions</p>
</div>
<form action="{{ route('seller.business.management.bank-accounts.store', $business) }}" method="POST">
@csrf
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
{{-- Account Name --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Account Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" value="{{ old('name') }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="e.g., Operating Account" required>
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Account Type --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Account Type <span class="text-error">*</span></span>
</label>
<select name="account_type" class="select select-bordered @error('account_type') select-error @enderror" required>
@foreach($accountTypes as $value => $label)
<option value="{{ $value }}" {{ old('account_type') === $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
@error('account_type')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Bank Name --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Bank Name</span>
</label>
<input type="text" name="bank_name" value="{{ old('bank_name') }}"
class="input input-bordered @error('bank_name') input-error @enderror"
placeholder="e.g., Chase, Wells Fargo">
@error('bank_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="grid grid-cols-2 gap-4">
{{-- Account Number (Last 4) --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Account # (Last 4)</span>
</label>
<input type="text" name="account_number_last4" value="{{ old('account_number_last4') }}"
class="input input-bordered @error('account_number_last4') input-error @enderror"
placeholder="1234" maxlength="4">
@error('account_number_last4')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Routing Number --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Routing Number</span>
</label>
<input type="text" name="routing_number" value="{{ old('routing_number') }}"
class="input input-bordered @error('routing_number') input-error @enderror"
placeholder="123456789" maxlength="9">
@error('routing_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
{{-- Current Balance --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Opening Balance</span>
</label>
<div class="input-group">
<span>$</span>
<input type="number" name="current_balance" value="{{ old('current_balance', '0.00') }}"
class="input input-bordered w-full @error('current_balance') input-error @enderror"
step="0.01" min="0">
</div>
@error('current_balance')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- GL Account Link --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Link to GL Account</span>
</label>
<select name="gl_account_id" class="select select-bordered @error('gl_account_id') select-error @enderror">
<option value="">-- No GL Link --</option>
@foreach($glAccounts as $glAccount)
<option value="{{ $glAccount->id }}" {{ old('gl_account_id') == $glAccount->id ? 'selected' : '' }}>
{{ $glAccount->account_number }} - {{ $glAccount->name }}
</option>
@endforeach
</select>
<label class="label">
<span class="label-text-alt text-base-content/60">Link to a GL cash/bank account for journal entries</span>
</label>
@error('gl_account_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Options --}}
<div class="divider">Options</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_primary" value="1" class="checkbox checkbox-primary"
{{ old('is_primary') ? 'checked' : '' }}>
<span class="label-text">Set as primary account</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_active" value="1" class="checkbox checkbox-primary"
{{ old('is_active', true) ? 'checked' : '' }}>
<span class="label-text">Active</span>
</label>
</div>
{{-- Notes --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Notes</span>
</label>
<textarea name="notes" class="textarea textarea-bordered @error('notes') textarea-error @enderror"
rows="3" placeholder="Optional notes...">{{ old('notes') }}</textarea>
@error('notes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
<div class="card-actions justify-end p-4 border-t border-base-200">
<a href="{{ route('seller.business.management.bank-accounts.index', $business) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="icon-[heroicons--check] size-4"></span>
Create Account
</button>
</div>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,171 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Edit Bank Account - Management')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- Page Header --}}
<div class="mb-6">
<a href="{{ route('seller.business.management.bank-accounts.index', $business) }}" class="btn btn-ghost btn-sm mb-4">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to Bank Accounts
</a>
<h1 class="text-2xl font-bold">Edit Bank Account</h1>
<p class="text-base-content/60 mt-1">{{ $account->name }}</p>
</div>
<form action="{{ route('seller.business.management.bank-accounts.update', [$business, $account]) }}" method="POST">
@csrf
@method('PUT')
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
{{-- Account Name --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Account Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" value="{{ old('name', $account->name) }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="e.g., Operating Account" required>
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Account Type --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Account Type <span class="text-error">*</span></span>
</label>
<select name="account_type" class="select select-bordered @error('account_type') select-error @enderror" required>
@foreach($accountTypes as $value => $label)
<option value="{{ $value }}" {{ old('account_type', $account->account_type) === $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
@error('account_type')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Bank Name --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Bank Name</span>
</label>
<input type="text" name="bank_name" value="{{ old('bank_name', $account->bank_name) }}"
class="input input-bordered @error('bank_name') input-error @enderror"
placeholder="e.g., Chase, Wells Fargo">
@error('bank_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="grid grid-cols-2 gap-4">
{{-- Account Number (Last 4) --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Account # (Last 4)</span>
</label>
<input type="text" name="account_number_last4" value="{{ old('account_number_last4', $account->account_number_last4) }}"
class="input input-bordered @error('account_number_last4') input-error @enderror"
placeholder="1234" maxlength="4">
@error('account_number_last4')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Routing Number --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Routing Number</span>
</label>
<input type="text" name="routing_number" value="{{ old('routing_number', $account->routing_number) }}"
class="input input-bordered @error('routing_number') input-error @enderror"
placeholder="123456789" maxlength="9">
@error('routing_number')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
{{-- Current Balance (Read-only) --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Current Balance</span>
</label>
<div class="input-group">
<span>$</span>
<input type="text" value="{{ number_format($account->current_balance, 2) }}"
class="input input-bordered w-full bg-base-200" disabled>
</div>
<label class="label">
<span class="label-text-alt text-base-content/60">Balance is updated via transfers and reconciliation</span>
</label>
</div>
{{-- GL Account Link --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Link to GL Account</span>
</label>
<select name="gl_account_id" class="select select-bordered @error('gl_account_id') select-error @enderror">
<option value="">-- No GL Link --</option>
@foreach($glAccounts as $glAccount)
<option value="{{ $glAccount->id }}" {{ old('gl_account_id', $account->gl_account_id) == $glAccount->id ? 'selected' : '' }}>
{{ $glAccount->account_number }} - {{ $glAccount->name }}
</option>
@endforeach
</select>
@error('gl_account_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Options --}}
<div class="divider">Options</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_primary" value="1" class="checkbox checkbox-primary"
{{ old('is_primary', $account->is_primary) ? 'checked' : '' }}>
<span class="label-text">Set as primary account</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_active" value="1" class="checkbox checkbox-primary"
{{ old('is_active', $account->is_active) ? 'checked' : '' }}>
<span class="label-text">Active</span>
</label>
</div>
{{-- Notes --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Notes</span>
</label>
<textarea name="notes" class="textarea textarea-bordered @error('notes') textarea-error @enderror"
rows="3" placeholder="Optional notes...">{{ old('notes', $account->notes) }}</textarea>
@error('notes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
<div class="card-actions justify-end p-4 border-t border-base-200">
<a href="{{ route('seller.business.management.bank-accounts.index', $business) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="icon-[heroicons--check] size-4"></span>
Save Changes
</button>
</div>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,148 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Bank Accounts - Management')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Bank Accounts</h1>
<p class="text-base-content/60 mt-1">Manage bank accounts and cash positions</p>
</div>
<div class="mt-4 md:mt-0">
<a href="{{ route('seller.business.management.bank-accounts.create', $business) }}" class="btn btn-primary">
<span class="icon-[heroicons--plus] size-4"></span>
Add Bank Account
</a>
</div>
</div>
{{-- Division Filter --}}
@include('seller.management.partials.division-filter')
{{-- Summary Card --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-base-content/60">Total Cash Balance</div>
<div class="text-3xl font-bold text-success">${{ number_format($totalBalance, 2) }}</div>
</div>
<div class="text-base-content/20">
<span class="icon-[heroicons--banknotes] size-16"></span>
</div>
</div>
</div>
</div>
{{-- Bank Accounts Table --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
@if($accounts->count() > 0)
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Account</th>
<th>Bank</th>
<th>Type</th>
<th class="text-right">Balance</th>
<th>GL Account</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach($accounts as $account)
<tr>
<td>
<div class="flex items-center gap-2">
@if($account->is_primary)
<span class="badge badge-primary badge-xs">Primary</span>
@endif
<a href="{{ route('seller.business.management.bank-accounts.show', [$business, $account]) }}"
class="font-medium hover:text-primary">
{{ $account->name }}
</a>
</div>
@if($account->account_number_last4)
<div class="text-xs text-base-content/60">***{{ $account->account_number_last4 }}</div>
@endif
</td>
<td>{{ $account->bank_name ?? '-' }}</td>
<td>{{ $account->account_type_display }}</td>
<td class="text-right font-medium">
${{ number_format($account->current_balance, 2) }}
</td>
<td>
@if($account->glAccount)
<span class="text-sm">{{ $account->glAccount->account_number }}</span>
@else
<span class="text-base-content/40">Not linked</span>
@endif
</td>
<td>
@if($account->is_active)
<span class="badge badge-success badge-sm">Active</span>
@else
<span class="badge badge-ghost badge-sm">Inactive</span>
@endif
</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-48">
<li>
<a href="{{ route('seller.business.management.bank-accounts.show', [$business, $account]) }}">
<span class="icon-[heroicons--eye] size-4"></span>
View Details
</a>
</li>
<li>
<a href="{{ route('seller.business.management.bank-accounts.edit', [$business, $account]) }}">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
</li>
<li>
<form action="{{ route('seller.business.management.bank-accounts.toggle-active', [$business, $account]) }}" method="POST">
@csrf
<button type="submit" class="w-full text-left">
@if($account->is_active)
<span class="icon-[heroicons--pause] size-4"></span>
Deactivate
@else
<span class="icon-[heroicons--play] size-4"></span>
Activate
@endif
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12">
<span class="icon-[heroicons--banknotes] size-16 text-base-content/20 block mx-auto mb-4"></span>
<h3 class="font-semibold text-lg">No Bank Accounts</h3>
<p class="text-base-content/60 mt-2">Add your first bank account to track cash positions.</p>
<div class="mt-4">
<a href="{{ route('seller.business.management.bank-accounts.create', $business) }}" class="btn btn-primary">
<span class="icon-[heroicons--plus] size-4"></span>
Add Bank Account
</a>
</div>
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,156 @@
@extends('layouts.seller')
@section('title', 'Match Rules - ' . $account->name)
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<div class="flex items-center gap-2 text-sm text-base-content/60 mb-1">
<a href="{{ route('seller.business.management.bank-accounts.index', $business) }}" class="hover:text-primary">
Bank Accounts
</a>
<span class="icon-[heroicons--chevron-right] size-4"></span>
<a href="{{ route('seller.business.management.bank-accounts.reconciliation', [$business, $account]) }}" class="hover:text-primary">
Reconciliation
</a>
<span class="icon-[heroicons--chevron-right] size-4"></span>
<span>Match Rules</span>
</div>
<h1 class="text-2xl font-bold">Auto-Match Rules</h1>
<p class="text-base-content/60 mt-1">{{ $account->name }}</p>
</div>
<a href="{{ route('seller.business.management.bank-accounts.reconciliation', [$business, $account]) }}"
class="btn btn-outline btn-sm mt-4 md:mt-0">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to Reconciliation
</a>
</div>
{{-- Eligible for Auto-Enable --}}
@if($eligibleRules->isNotEmpty())
<div class="alert alert-info mb-6">
<span class="icon-[heroicons--light-bulb] size-5"></span>
<div>
<h3 class="font-bold">Rules Ready for Auto-Matching</h3>
<p class="text-sm">These rules have been trained {{ \App\Models\Accounting\BankMatchRule::MIN_TRAINING_COUNT }}+ times and can be enabled for auto-matching.</p>
</div>
</div>
@endif
{{-- All Rules --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Match Rules</h3>
@if($rules->isEmpty())
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--academic-cap] size-12 mb-2"></span>
<p>No rules learned yet</p>
<p class="text-sm mt-2">Rules are automatically created when you manually match transactions.</p>
</div>
@else
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Pattern</th>
<th>Direction</th>
<th>Typical Amount</th>
<th class="text-center">Training</th>
<th class="text-center">Auto-Matches</th>
<th class="text-center">Rejections</th>
<th>Auto-Enabled</th>
<th>Last Used</th>
</tr>
</thead>
<tbody>
@foreach($rules as $rule)
<tr class="{{ $rule->is_eligible_for_auto_enable ? 'bg-info/10' : '' }}">
<td>
<div class="font-medium">{{ $rule->pattern_name }}</div>
<div class="text-xs text-base-content/60">{{ $rule->pattern_type_display }}</div>
</td>
<td>
<span class="badge {{ $rule->direction === 'debit' ? 'badge-error' : 'badge-success' }} badge-sm">
{{ $rule->direction_display }}
</span>
</td>
<td class="font-mono">
@if($rule->typical_amount)
${{ number_format($rule->typical_amount, 2) }}
<span class="text-xs text-base-content/60">±${{ number_format($rule->amount_tolerance, 2) }}</span>
@else
@endif
</td>
<td class="text-center">
<span class="badge badge-ghost">{{ $rule->training_count }}</span>
</td>
<td class="text-center">
<span class="badge badge-success badge-outline">{{ $rule->auto_match_count }}</span>
</td>
<td class="text-center">
<span class="badge badge-error badge-outline">{{ $rule->rejection_count }}</span>
</td>
<td>
<form action="{{ route('seller.business.management.bank-accounts.reconciliation.rules.toggle', [$business, $account, $rule]) }}" method="POST">
@csrf
<input type="hidden" name="enabled" value="{{ $rule->auto_enabled ? '0' : '1' }}">
<button type="submit"
class="toggle toggle-sm {{ $rule->auto_enabled ? 'toggle-success' : '' }}"
{{ $rule->training_count < \App\Models\Accounting\BankMatchRule::MIN_TRAINING_COUNT && !$rule->auto_enabled ? 'disabled' : '' }}>
</button>
</form>
</td>
<td class="text-sm text-base-content/60">
@if($rule->last_used_at)
{{ $rule->last_used_at->diffForHumans() }}
@else
Never
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
{{-- How It Works --}}
<div class="card bg-base-200 mt-6">
<div class="card-body">
<h3 class="card-title text-lg">
<span class="icon-[heroicons--question-mark-circle] size-5"></span>
How Auto-Matching Works
</h3>
<div class="grid md:grid-cols-3 gap-4 mt-4">
<div class="flex gap-3">
<div class="badge badge-primary badge-lg">1</div>
<div>
<h4 class="font-medium">Learn from Manual Matches</h4>
<p class="text-sm text-base-content/60">When you manually match a transaction, a rule is created or updated based on the transaction pattern.</p>
</div>
</div>
<div class="flex gap-3">
<div class="badge badge-primary badge-lg">2</div>
<div>
<h4 class="font-medium">Training Threshold</h4>
<p class="text-sm text-base-content/60">After {{ \App\Models\Accounting\BankMatchRule::MIN_TRAINING_COUNT }} successful manual matches, the rule becomes eligible for auto-matching.</p>
</div>
</div>
<div class="flex gap-3">
<div class="badge badge-primary badge-lg">3</div>
<div>
<h4 class="font-medium">Human Review Required</h4>
<p class="text-sm text-base-content/60">Auto-matched transactions are proposed but never committed automatically. You must confirm or reject each match.</p>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,291 @@
@extends('layouts.seller')
@section('title', 'Bank Reconciliation - ' . $account->name)
@section('content')
<div class="container mx-auto px-4 py-6" x-data="reconciliationPage()">
{{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<div class="flex items-center gap-2 text-sm text-base-content/60 mb-1">
<a href="{{ route('seller.business.management.bank-accounts.index', $business) }}" class="hover:text-primary">
Bank Accounts
</a>
<span class="icon-[heroicons--chevron-right] size-4"></span>
<span>{{ $account->name }}</span>
</div>
<h1 class="text-2xl font-bold">Bank Reconciliation</h1>
</div>
<div class="flex gap-2 mt-4 md:mt-0">
<a href="{{ route('seller.business.management.bank-accounts.reconciliation.rules', [$business, $account]) }}"
class="btn btn-outline btn-sm">
<span class="icon-[heroicons--cog-6-tooth] size-4"></span>
Match Rules
</a>
<form action="{{ route('seller.business.management.bank-accounts.reconciliation.sync', [$business, $account]) }}" method="POST" class="inline">
@csrf
<button type="submit" class="btn btn-primary btn-sm">
<span class="icon-[heroicons--arrow-path] size-4"></span>
Sync Transactions
</button>
</form>
</div>
</div>
{{-- Summary Cards --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="text-sm text-base-content/60">Plaid Balance</div>
<div class="text-2xl font-bold">
@if($summary['has_plaid'])
${{ number_format($summary['plaid_balance'], 2) }}
@else
<span class="text-base-content/40">Not Connected</span>
@endif
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="text-sm text-base-content/60">GL Balance</div>
<div class="text-2xl font-bold">${{ number_format($summary['gl_balance'], 2) }}</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="text-sm text-base-content/60">Difference</div>
<div class="text-2xl font-bold {{ ($summary['difference'] ?? 0) != 0 ? 'text-warning' : 'text-success' }}">
@if($summary['difference'] !== null)
${{ number_format(abs($summary['difference']), 2) }}
@if($summary['difference'] != 0)
<span class="text-sm">{{ $summary['difference'] > 0 ? 'over' : 'under' }}</span>
@endif
@else
@endif
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="text-sm text-base-content/60">Pending Review</div>
<div class="text-2xl font-bold">
{{ $summary['unmatched_count'] + $summary['proposed_count'] }}
<span class="text-sm font-normal text-base-content/60">transactions</span>
</div>
</div>
</div>
</div>
{{-- Tabs --}}
<div class="tabs tabs-boxed mb-4">
<button class="tab" :class="{ 'tab-active': activeTab === 'unmatched' }" @click="activeTab = 'unmatched'">
Unmatched
@if($unmatchedTransactions->count() > 0)
<span class="badge badge-warning badge-sm ml-1">{{ $unmatchedTransactions->count() }}</span>
@endif
</button>
<button class="tab" :class="{ 'tab-active': activeTab === 'proposed' }" @click="activeTab = 'proposed'">
Auto-Match Review
@if($proposedMatches->count() > 0)
<span class="badge badge-info badge-sm ml-1">{{ $proposedMatches->count() }}</span>
@endif
</button>
<button class="tab" :class="{ 'tab-active': activeTab === 'matched' }" @click="activeTab = 'matched'">
Matched
</button>
</div>
{{-- Unmatched Transactions --}}
<div x-show="activeTab === 'unmatched'" class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Unmatched Transactions</h3>
@if($unmatchedTransactions->isEmpty())
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--check-circle] size-12 mb-2"></span>
<p>All transactions are matched!</p>
</div>
@else
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="text-right">Amount</th>
<th>Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($unmatchedTransactions as $tx)
<tr>
<td>{{ $tx->date->format('M d, Y') }}</td>
<td>
<div class="font-medium">{{ $tx->display_name }}</div>
@if($tx->name !== $tx->merchant_name)
<div class="text-xs text-base-content/60">{{ $tx->name }}</div>
@endif
</td>
<td class="text-right font-mono {{ $tx->is_credit ? 'text-success' : 'text-error' }}">
{{ $tx->formatted_amount }}
</td>
<td class="text-sm text-base-content/60">{{ $tx->category_display }}</td>
<td>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li>
<button @click="openMatchModal({{ $tx->id }})">
<span class="icon-[heroicons--link] size-4"></span>
Find Match
</button>
</li>
<li>
<form action="{{ route('seller.business.management.bank-accounts.reconciliation.ignore', [$business, $account]) }}" method="POST">
@csrf
<input type="hidden" name="transaction_ids[]" value="{{ $tx->id }}">
<button type="submit" class="text-warning">
<span class="icon-[heroicons--eye-slash] size-4"></span>
Ignore
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
{{-- Auto-Match Review --}}
<div x-show="activeTab === 'proposed'" class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg">Auto-Match Review</h3>
@if($proposedMatches->isNotEmpty())
<div class="flex gap-2">
<form action="{{ route('seller.business.management.bank-accounts.reconciliation.confirm', [$business, $account]) }}" method="POST" class="inline">
@csrf
<template x-for="id in selectedProposed" :key="id">
<input type="hidden" name="transaction_ids[]" :value="id">
</template>
<button type="submit" class="btn btn-success btn-sm" :disabled="selectedProposed.length === 0">
<span class="icon-[heroicons--check] size-4"></span>
Confirm Selected
</button>
</form>
<form action="{{ route('seller.business.management.bank-accounts.reconciliation.reject', [$business, $account]) }}" method="POST" class="inline">
@csrf
<template x-for="id in selectedProposed" :key="id">
<input type="hidden" name="transaction_ids[]" :value="id">
</template>
<button type="submit" class="btn btn-error btn-outline btn-sm" :disabled="selectedProposed.length === 0">
<span class="icon-[heroicons--x-mark] size-4"></span>
Reject Selected
</button>
</form>
</div>
@endif
</div>
@if($proposedMatches->isEmpty())
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--sparkles] size-12 mb-2"></span>
<p>No auto-match proposals pending</p>
</div>
@else
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-sm"
@change="toggleAllProposed($event.target.checked, {{ $proposedMatches->pluck('id')->toJson() }})">
</th>
<th>Date</th>
<th>Description</th>
<th class="text-right">Amount</th>
<th>Suggested Rule</th>
</tr>
</thead>
<tbody>
@foreach($proposedMatches as $tx)
<tr>
<td>
<input type="checkbox" class="checkbox checkbox-sm"
:checked="selectedProposed.includes({{ $tx->id }})"
@change="toggleProposed({{ $tx->id }})">
</td>
<td>{{ $tx->date->format('M d, Y') }}</td>
<td>
<div class="font-medium">{{ $tx->display_name }}</div>
</td>
<td class="text-right font-mono {{ $tx->is_credit ? 'text-success' : 'text-error' }}">
{{ $tx->formatted_amount }}
</td>
<td>
@if($tx->matchedByRule)
<div class="badge badge-ghost badge-sm">{{ $tx->matchedByRule->pattern_name }}</div>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
{{-- Matched Transactions (Placeholder) --}}
<div x-show="activeTab === 'matched'" class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Matched Transactions</h3>
<div class="text-center py-8 text-base-content/60">
<p>View matched transactions history here.</p>
</div>
</div>
</div>
</div>
<script>
function reconciliationPage() {
return {
activeTab: 'unmatched',
selectedProposed: [],
toggleProposed(id) {
const index = this.selectedProposed.indexOf(id);
if (index > -1) {
this.selectedProposed.splice(index, 1);
} else {
this.selectedProposed.push(id);
}
},
toggleAllProposed(checked, ids) {
if (checked) {
this.selectedProposed = [...ids];
} else {
this.selectedProposed = [];
}
},
openMatchModal(transactionId) {
// TODO: Open modal to find and select matches
alert('Match modal for transaction ' + transactionId);
}
}
}
</script>
@endsection

View File

@@ -0,0 +1,188 @@
@extends('layouts.app-with-sidebar')
@section('title', $account->name . ' - Bank Accounts')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<a href="{{ route('seller.business.management.bank-accounts.index', $business) }}" class="btn btn-ghost btn-sm mb-2">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to Bank Accounts
</a>
<h1 class="text-2xl font-bold flex items-center gap-2">
{{ $account->name }}
@if($account->is_primary)
<span class="badge badge-primary">Primary</span>
@endif
@if(!$account->is_active)
<span class="badge badge-ghost">Inactive</span>
@endif
</h1>
<p class="text-base-content/60 mt-1">
{{ $account->bank_name ?? 'Bank account' }}
@if($account->account_number_last4)
&bull; ***{{ $account->account_number_last4 }}
@endif
</p>
</div>
<div class="mt-4 md:mt-0 flex gap-2">
<a href="{{ route('seller.business.management.bank-transfers.create', $business) }}?from={{ $account->id }}" class="btn btn-outline btn-sm">
<span class="icon-[heroicons--arrows-right-left] size-4"></span>
Transfer
</a>
<a href="{{ route('seller.business.management.bank-accounts.edit', [$business, $account]) }}" class="btn btn-primary btn-sm">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main Info --}}
<div class="lg:col-span-2 space-y-6">
{{-- Balance Card --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Current Balance</h2>
<div class="text-4xl font-bold text-success mt-2">
${{ number_format($account->current_balance, 2) }}
</div>
@if($account->available_balance != $account->current_balance)
<div class="text-sm text-base-content/60 mt-1">
Available: ${{ number_format($account->available_balance, 2) }}
</div>
@endif
@if($account->last_synced_at)
<div class="text-xs text-base-content/40 mt-2">
Last synced: {{ $account->last_synced_at->diffForHumans() }}
</div>
@endif
</div>
</div>
{{-- Recent Transfers --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-lg">Recent Transfers</h2>
<a href="{{ route('seller.business.management.bank-transfers.index', $business) }}" class="btn btn-ghost btn-sm">
View All
</a>
</div>
@if($recentTransfers->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>From/To</th>
<th class="text-right">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($recentTransfers as $transfer)
<tr>
<td>{{ $transfer->transfer_date->format('M j, Y') }}</td>
<td>
@if($transfer->from_bank_account_id === $account->id)
<span class="text-error">To: {{ $transfer->toAccount->name }}</span>
@else
<span class="text-success">From: {{ $transfer->fromAccount->name }}</span>
@endif
</td>
<td class="text-right">
@if($transfer->from_bank_account_id === $account->id)
<span class="text-error">-${{ number_format($transfer->amount, 2) }}</span>
@else
<span class="text-success">+${{ number_format($transfer->amount, 2) }}</span>
@endif
</td>
<td>
<span class="badge {{ $transfer->status_badge }} badge-sm">
{{ ucfirst($transfer->status) }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--arrows-right-left] size-8 block mx-auto mb-2 opacity-40"></span>
No recent transfers
</div>
@endif
</div>
</div>
</div>
{{-- Sidebar Info --}}
<div class="space-y-6">
{{-- Account Details --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Account Details</h2>
<div class="space-y-3 mt-4">
<div>
<div class="text-xs text-base-content/60 uppercase">Type</div>
<div class="font-medium">{{ $account->account_type_display }}</div>
</div>
<div>
<div class="text-xs text-base-content/60 uppercase">Currency</div>
<div class="font-medium">{{ $account->currency }}</div>
</div>
@if($account->routing_number)
<div>
<div class="text-xs text-base-content/60 uppercase">Routing Number</div>
<div class="font-medium">{{ $account->routing_number }}</div>
</div>
@endif
@if($account->glAccount)
<div>
<div class="text-xs text-base-content/60 uppercase">GL Account</div>
<div class="font-medium">{{ $account->glAccount->account_number }} - {{ $account->glAccount->name }}</div>
</div>
@endif
</div>
</div>
</div>
{{-- Notes --}}
@if($account->notes)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Notes</h2>
<p class="text-base-content/80 mt-2">{{ $account->notes }}</p>
</div>
</div>
@endif
{{-- Plaid Status --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Bank Connection</h2>
@if($account->hasPlaidConnection())
<div class="flex items-center gap-2 mt-2 text-success">
<span class="icon-[heroicons--check-circle] size-5"></span>
Connected via Plaid
</div>
@else
<div class="flex items-center gap-2 mt-2 text-base-content/60">
<span class="icon-[heroicons--minus-circle] size-5"></span>
Not connected
</div>
<p class="text-sm text-base-content/60 mt-2">
Connect via Plaid to enable automatic balance sync and transaction import.
</p>
@endif
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,135 @@
@extends('layouts.app-with-sidebar')
@section('title', 'New Transfer - Bank Transfers')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- Page Header --}}
<div class="mb-6">
<a href="{{ route('seller.business.management.bank-transfers.index', $business) }}" class="btn btn-ghost btn-sm mb-4">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to Transfers
</a>
<h1 class="text-2xl font-bold">New Bank Transfer</h1>
<p class="text-base-content/60 mt-1">Transfer funds between bank accounts</p>
</div>
@if($accounts->count() < 2)
<div class="alert alert-warning">
<span class="icon-[heroicons--exclamation-triangle] size-5"></span>
<span>You need at least 2 bank accounts to make a transfer. <a href="{{ route('seller.business.management.bank-accounts.create', $business) }}" class="link">Add another account</a>.</span>
</div>
@else
<form action="{{ route('seller.business.management.bank-transfers.store', $business) }}" method="POST">
@csrf
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
{{-- From Account --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">From Account <span class="text-error">*</span></span>
</label>
<select name="from_bank_account_id" class="select select-bordered @error('from_bank_account_id') select-error @enderror" required>
<option value="">-- Select Source Account --</option>
@foreach($accounts as $account)
<option value="{{ $account->id }}"
data-balance="{{ $account->current_balance }}"
{{ old('from_bank_account_id', request('from')) == $account->id ? 'selected' : '' }}>
{{ $account->name }} - ${{ number_format($account->current_balance, 2) }}
@if($account->account_number_last4) (***{{ $account->account_number_last4 }}) @endif
</option>
@endforeach
</select>
@error('from_bank_account_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- To Account --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">To Account <span class="text-error">*</span></span>
</label>
<select name="to_bank_account_id" class="select select-bordered @error('to_bank_account_id') select-error @enderror" required>
<option value="">-- Select Destination Account --</option>
@foreach($accounts as $account)
<option value="{{ $account->id }}" {{ old('to_bank_account_id') == $account->id ? 'selected' : '' }}>
{{ $account->name }} - ${{ number_format($account->current_balance, 2) }}
@if($account->account_number_last4) (***{{ $account->account_number_last4 }}) @endif
</option>
@endforeach
</select>
@error('to_bank_account_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Amount --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Amount <span class="text-error">*</span></span>
</label>
<div class="input-group">
<span>$</span>
<input type="number" name="amount" value="{{ old('amount') }}"
class="input input-bordered w-full @error('amount') input-error @enderror"
step="0.01" min="0.01" placeholder="0.00" required>
</div>
@error('amount')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Transfer Date --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Transfer Date <span class="text-error">*</span></span>
</label>
<input type="date" name="transfer_date" value="{{ old('transfer_date', now()->format('Y-m-d')) }}"
class="input input-bordered @error('transfer_date') input-error @enderror" required>
@error('transfer_date')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Reference --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Reference Number</span>
</label>
<input type="text" name="reference" value="{{ old('reference') }}"
class="input input-bordered @error('reference') input-error @enderror"
placeholder="e.g., Wire confirmation #">
@error('reference')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Notes --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Notes</span>
</label>
<textarea name="notes" class="textarea textarea-bordered @error('notes') textarea-error @enderror"
rows="3" placeholder="Optional transfer notes...">{{ old('notes') }}</textarea>
@error('notes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
<div class="card-actions justify-end p-4 border-t border-base-200">
<a href="{{ route('seller.business.management.bank-transfers.index', $business) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="icon-[heroicons--arrows-right-left] size-4"></span>
Create Transfer
</button>
</div>
</div>
</form>
@endif
</div>
@endsection

View File

@@ -0,0 +1,133 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Bank Transfers - Management')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Bank Transfers</h1>
<p class="text-base-content/60 mt-1">Transfer funds between bank accounts</p>
</div>
<div class="mt-4 md:mt-0">
<a href="{{ route('seller.business.management.bank-transfers.create', $business) }}" class="btn btn-primary">
<span class="icon-[heroicons--plus] size-4"></span>
New Transfer
</a>
</div>
</div>
{{-- Division Filter --}}
@include('seller.management.partials.division-filter')
{{-- Filters --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<form method="GET" class="flex flex-wrap gap-4 items-end">
<div class="form-control">
<label class="label">
<span class="label-text">Status</span>
</label>
<select name="status" class="select select-bordered select-sm">
<option value="">All Statuses</option>
<option value="pending" {{ ($filters['status'] ?? '') === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="completed" {{ ($filters['status'] ?? '') === 'completed' ? 'selected' : '' }}>Completed</option>
<option value="cancelled" {{ ($filters['status'] ?? '') === 'cancelled' ? 'selected' : '' }}>Cancelled</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">From Date</span>
</label>
<input type="date" name="from_date" value="{{ $filters['from_date'] ?? '' }}" class="input input-bordered input-sm">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">To Date</span>
</label>
<input type="date" name="to_date" value="{{ $filters['to_date'] ?? '' }}" class="input input-bordered input-sm">
</div>
<button type="submit" class="btn btn-sm btn-outline">
<span class="icon-[heroicons--funnel] size-4"></span>
Filter
</button>
@if(!empty($filters['status']) || !empty($filters['from_date']) || !empty($filters['to_date']))
<a href="{{ route('seller.business.management.bank-transfers.index', $business) }}" class="btn btn-sm btn-ghost">
Clear
</a>
@endif
</form>
</div>
</div>
{{-- Transfers Table --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
@if($transfers->count() > 0)
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th class="text-right">Amount</th>
<th>Reference</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach($transfers as $transfer)
<tr>
<td>{{ $transfer->transfer_date->format('M j, Y') }}</td>
<td>
<div class="font-medium">{{ $transfer->fromAccount->name }}</div>
@if($transfer->fromAccount->account_number_last4)
<div class="text-xs text-base-content/60">***{{ $transfer->fromAccount->account_number_last4 }}</div>
@endif
</td>
<td>
<div class="font-medium">{{ $transfer->toAccount->name }}</div>
@if($transfer->toAccount->account_number_last4)
<div class="text-xs text-base-content/60">***{{ $transfer->toAccount->account_number_last4 }}</div>
@endif
</td>
<td class="text-right font-medium">
${{ number_format($transfer->amount, 2) }}
</td>
<td>{{ $transfer->reference ?? '-' }}</td>
<td>
<span class="badge {{ $transfer->status_badge }} badge-sm">
{{ ucfirst($transfer->status) }}
</span>
</td>
<td class="text-right">
<a href="{{ route('seller.business.management.bank-transfers.show', [$business, $transfer]) }}"
class="btn btn-ghost btn-sm">
<span class="icon-[heroicons--eye] size-4"></span>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12">
<span class="icon-[heroicons--arrows-right-left] size-16 text-base-content/20 block mx-auto mb-4"></span>
<h3 class="font-semibold text-lg">No Transfers</h3>
<p class="text-base-content/60 mt-2">Create a transfer to move funds between accounts.</p>
<div class="mt-4">
<a href="{{ route('seller.business.management.bank-transfers.create', $business) }}" class="btn btn-primary">
<span class="icon-[heroicons--plus] size-4"></span>
New Transfer
</a>
</div>
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,161 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Transfer Details - Bank Transfers')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-3xl">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<a href="{{ route('seller.business.management.bank-transfers.index', $business) }}" class="btn btn-ghost btn-sm mb-2">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back to Transfers
</a>
<h1 class="text-2xl font-bold flex items-center gap-2">
Transfer #{{ $transfer->id }}
<span class="badge {{ $transfer->status_badge }}">{{ ucfirst($transfer->status) }}</span>
</h1>
<p class="text-base-content/60 mt-1">{{ $transfer->transfer_date->format('F j, Y') }}</p>
</div>
@if($transfer->isPending())
<div class="mt-4 md:mt-0 flex gap-2">
<form action="{{ route('seller.business.management.bank-transfers.cancel', [$business, $transfer]) }}" method="POST"
onsubmit="return confirm('Are you sure you want to cancel this transfer?');">
@csrf
<button type="submit" class="btn btn-ghost btn-sm text-error">
<span class="icon-[heroicons--x-mark] size-4"></span>
Cancel
</button>
</form>
<form action="{{ route('seller.business.management.bank-transfers.complete', [$business, $transfer]) }}" method="POST">
@csrf
<button type="submit" class="btn btn-success btn-sm">
<span class="icon-[heroicons--check] size-4"></span>
Complete Transfer
</button>
</form>
</div>
@endif
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Transfer Details --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Transfer Details</h2>
<div class="space-y-4 mt-4">
<div class="flex justify-between items-center py-3 border-b border-base-200">
<span class="text-base-content/60">Amount</span>
<span class="text-2xl font-bold">${{ number_format($transfer->amount, 2) }}</span>
</div>
<div class="flex justify-between py-3 border-b border-base-200">
<span class="text-base-content/60">From Account</span>
<div class="text-right">
<div class="font-medium">{{ $transfer->fromAccount->name }}</div>
@if($transfer->fromAccount->account_number_last4)
<div class="text-xs text-base-content/60">***{{ $transfer->fromAccount->account_number_last4 }}</div>
@endif
</div>
</div>
<div class="flex justify-between py-3 border-b border-base-200">
<span class="text-base-content/60">To Account</span>
<div class="text-right">
<div class="font-medium">{{ $transfer->toAccount->name }}</div>
@if($transfer->toAccount->account_number_last4)
<div class="text-xs text-base-content/60">***{{ $transfer->toAccount->account_number_last4 }}</div>
@endif
</div>
</div>
<div class="flex justify-between py-3 border-b border-base-200">
<span class="text-base-content/60">Transfer Date</span>
<span>{{ $transfer->transfer_date->format('M j, Y') }}</span>
</div>
@if($transfer->reference)
<div class="flex justify-between py-3 border-b border-base-200">
<span class="text-base-content/60">Reference</span>
<span>{{ $transfer->reference }}</span>
</div>
@endif
<div class="flex justify-between py-3">
<span class="text-base-content/60">Status</span>
<span class="badge {{ $transfer->status_badge }}">{{ ucfirst($transfer->status) }}</span>
</div>
</div>
</div>
</div>
{{-- Additional Info --}}
<div class="space-y-6">
{{-- Notes --}}
@if($transfer->notes)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Notes</h2>
<p class="text-base-content/80 mt-2">{{ $transfer->notes }}</p>
</div>
</div>
@endif
{{-- Journal Entry Link --}}
@if($transfer->journalEntry)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Journal Entry</h2>
<div class="mt-2">
<a href="{{ route('seller.business.management.journal-entries.show', [$business, $transfer->journalEntry]) }}"
class="btn btn-outline btn-sm">
<span class="icon-[heroicons--document-text] size-4"></span>
View JE #{{ $transfer->journalEntry->entry_number }}
</a>
</div>
<p class="text-sm text-base-content/60 mt-2">
Posted {{ $transfer->journalEntry->entry_date->format('M j, Y') }}
</p>
</div>
</div>
@elseif($transfer->status === 'completed')
<div class="alert alert-warning">
<span class="icon-[heroicons--exclamation-triangle] size-5"></span>
<span>This transfer was completed but no journal entry was created.</span>
</div>
@endif
{{-- Audit Info --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Audit Trail</h2>
<div class="space-y-2 mt-2 text-sm">
<div class="flex justify-between">
<span class="text-base-content/60">Created</span>
<span>{{ $transfer->created_at->format('M j, Y g:i A') }}</span>
</div>
@if($transfer->created_by)
<div class="flex justify-between">
<span class="text-base-content/60">Created By</span>
<span>{{ $transfer->creator->name ?? 'Unknown' }}</span>
</div>
@endif
@if($transfer->completed_at)
<div class="flex justify-between">
<span class="text-base-content/60">Completed</span>
<span>{{ $transfer->completed_at->format('M j, Y g:i A') }}</span>
</div>
@endif
@if($transfer->approved_by)
<div class="flex justify-between">
<span class="text-base-content/60">Completed By</span>
<span>{{ $transfer->approver->name ?? 'Unknown' }}</span>
</div>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -51,7 +51,26 @@
@endif
</td>
<td>
@if($vendor->business)
@if($isParent ?? false)
{{-- For parent companies, show which divisions are using this vendor --}}
<div class="flex flex-wrap gap-1">
@if($vendor->business)
<span class="badge badge-primary badge-sm" title="Owner">
{{ $vendor->business->division_name ?? $vendor->business->name }}
</span>
@endif
@if(isset($vendorDivisionUsage[$vendor->id]))
@foreach($vendorDivisionUsage[$vendor->id] as $usage)
@if(!$vendor->business || $usage['business']->id !== $vendor->business->id)
<span class="badge badge-ghost badge-sm" title="{{ $usage['bill_count'] }} bills">
{{ $usage['business']->division_name ?? $usage['business']->name }}
<span class="text-xs opacity-60">({{ $usage['bill_count'] }})</span>
</span>
@endif
@endforeach
@endif
</div>
@elseif($vendor->business)
<span class="badge badge-ghost badge-sm">{{ $vendor->business->division_name ?? $vendor->business->name }}</span>
@else
<span class="text-base-content/40">-</span>

View File

@@ -6,7 +6,7 @@
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">Divisional Rollup</h1>
<p class="text-base-content/70">AP summary across all divisions</p>
<p class="text-base-content/70">AP & AR summary across all divisions</p>
</div>
<div class="flex gap-2">
<a href="{{ route('seller.business.management.finance.index', $business) }}" class="btn btn-ghost btn-sm">
@@ -16,139 +16,254 @@
</div>
</div>
{{-- Summary Totals --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-title">Total AP Outstanding</div>
<div class="stat-value text-lg">${{ number_format($totals['ap_outstanding'], 2) }}</div>
<div class="stat-desc">All divisions combined</div>
</div>
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-title">Total Overdue</div>
<div class="stat-value text-lg {{ $totals['ap_overdue'] > 0 ? 'text-error' : '' }}">
${{ number_format($totals['ap_overdue'], 2) }}
{{-- Summary Totals - Two Row Layout --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- AP Summary --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="card-title text-base flex items-center gap-2">
<span class="icon-[heroicons--document-minus] size-5 text-error"></span>
Accounts Payable (AP)
</h3>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Outstanding</div>
<div class="stat-value text-lg">${{ number_format($totals['ap_outstanding'], 2) }}</div>
</div>
<div class="stat bg-error/10 rounded-lg p-4">
<div class="stat-title text-xs">Overdue</div>
<div class="stat-value text-lg text-error">${{ number_format($totals['ap_overdue'], 2) }}</div>
</div>
<div class="stat bg-success/10 rounded-lg p-4">
<div class="stat-title text-xs">YTD Payments</div>
<div class="stat-value text-lg text-success">${{ number_format($totals['ytd_payments'], 2) }}</div>
</div>
<div class="stat bg-warning/10 rounded-lg p-4">
<div class="stat-title text-xs">Pending Approval</div>
<div class="stat-value text-lg {{ $totals['pending_approval'] > 0 ? 'text-warning' : '' }}">
{{ $totals['pending_approval'] }} {{ Str::plural('bill', $totals['pending_approval']) }}
</div>
</div>
</div>
</div>
<div class="stat-desc">Past due date</div>
</div>
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-title">YTD Payments</div>
<div class="stat-value text-lg text-success">${{ number_format($totals['ytd_payments'], 2) }}</div>
<div class="stat-desc">{{ now()->format('Y') }} to date</div>
</div>
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-title">Pending Approval</div>
<div class="stat-value text-lg {{ $totals['pending_approval'] > 0 ? 'text-warning' : '' }}">
{{ $totals['pending_approval'] }}
{{-- AR Summary --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="card-title text-base flex items-center gap-2">
<span class="icon-[heroicons--document-plus] size-5 text-success"></span>
Accounts Receivable (AR)
</h3>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Outstanding</div>
<div class="stat-value text-lg">${{ number_format($totals['ar_total'], 2) }}</div>
</div>
<div class="stat bg-warning/10 rounded-lg p-4">
<div class="stat-title text-xs">Overdue</div>
<div class="stat-value text-lg text-warning">${{ number_format($totals['ar_overdue'], 2) }}</div>
</div>
<div class="stat bg-error/10 rounded-lg p-4">
<div class="stat-title text-xs">At-Risk Customers</div>
<div class="stat-value text-lg {{ $totals['ar_at_risk'] > 0 ? 'text-error' : '' }}">
{{ $totals['ar_at_risk'] }}
</div>
</div>
<div class="stat bg-ghost rounded-lg p-4">
<div class="stat-title text-xs">On Credit Hold</div>
<div class="stat-value text-lg {{ $totals['ar_on_hold'] > 0 ? 'text-warning' : '' }}">
{{ $totals['ar_on_hold'] }}
</div>
</div>
</div>
</div>
<div class="stat-desc">{{ Str::plural('bill', $totals['pending_approval']) }} awaiting review</div>
</div>
</div>
{{-- Divisions Table --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Division Breakdown</h2>
<div class="overflow-x-auto mt-4">
<table class="table">
<thead>
<tr>
<th>Division</th>
<th class="text-right">AP Outstanding</th>
<th class="text-right">Overdue</th>
<th class="text-right">YTD Payments</th>
<th class="text-right">Pending Approval</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($divisions as $div)
<tr class="hover">
<td>
<div class="font-medium">{{ $div['division']->division_name ?? $div['division']->name }}</div>
<div class="text-sm text-base-content/50">{{ $div['division']->slug }}</div>
</td>
<td class="text-right font-mono">${{ number_format($div['ap_outstanding'], 2) }}</td>
<td class="text-right font-mono {{ $div['ap_overdue'] > 0 ? 'text-error' : '' }}">
${{ number_format($div['ap_overdue'], 2) }}
</td>
<td class="text-right font-mono text-success">${{ number_format($div['ytd_payments'], 2) }}</td>
<td class="text-right">
{{-- Divisions Cards --}}
<div class="space-y-4">
<h2 class="text-lg font-bold">Division Breakdown</h2>
@forelse($divisions as $div)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
{{-- Division Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 mb-4">
<div>
<h3 class="font-bold text-lg">{{ $div['division']->division_name ?? $div['division']->name }}</h3>
<div class="text-sm text-base-content/50">{{ $div['division']->slug }}</div>
</div>
<div class="flex gap-2">
<a href="{{ route('seller.business.management.ap.bills.index', $div['division']) }}"
class="btn btn-outline btn-sm">
<span class="icon-[heroicons--document-minus] size-4"></span>
View AP
</a>
<a href="{{ route('seller.business.management.ar.index', $div['division']) }}"
class="btn btn-outline btn-sm">
<span class="icon-[heroicons--document-plus] size-4"></span>
View AR
</a>
</div>
</div>
{{-- AP vs AR Side by Side --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{{-- AP Section --}}
<div class="bg-base-200 rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--document-minus] size-4 text-error"></span>
<span class="font-medium">Accounts Payable</span>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-base-content/60">Outstanding</div>
<div class="font-mono font-bold">${{ number_format($div['ap_outstanding'], 2) }}</div>
</div>
<div>
<div class="text-base-content/60">Overdue</div>
<div class="font-mono font-bold {{ $div['ap_overdue'] > 0 ? 'text-error' : '' }}">
${{ number_format($div['ap_overdue'], 2) }}
</div>
</div>
<div>
<div class="text-base-content/60">YTD Payments</div>
<div class="font-mono text-success">${{ number_format($div['ytd_payments'], 2) }}</div>
</div>
<div>
<div class="text-base-content/60">Pending Approval</div>
@if($div['pending_approval'] > 0)
<span class="badge badge-warning">{{ $div['pending_approval'] }}</span>
<span class="badge badge-warning badge-sm">{{ $div['pending_approval'] }}</span>
@else
<span class="text-base-content/50">0</span>
@endif
</td>
<td>
<a href="{{ route('seller.business.management.finance.vendor-spend', ['business' => $div['division']]) }}"
class="btn btn-ghost btn-xs">
<span class="icon-[heroicons--chart-bar] size-4"></span>
Spend
</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-base-content/50 py-8">
No divisions found. This dashboard is for parent companies only.
</td>
</tr>
@endforelse
</tbody>
@if($divisions->count() > 0)
<tfoot>
<tr class="font-bold bg-base-200">
<td>Total ({{ $divisions->count() }} divisions)</td>
<td class="text-right font-mono">${{ number_format($totals['ap_outstanding'], 2) }}</td>
<td class="text-right font-mono {{ $totals['ap_overdue'] > 0 ? 'text-error' : '' }}">
${{ number_format($totals['ap_overdue'], 2) }}
</td>
<td class="text-right font-mono text-success">${{ number_format($totals['ytd_payments'], 2) }}</td>
<td class="text-right">
@if($totals['pending_approval'] > 0)
<span class="badge badge-warning">{{ $totals['pending_approval'] }}</span>
</div>
</div>
<div class="mt-3">
<a href="{{ route('seller.business.management.finance.ap-aging', $div['division']) }}"
class="link link-hover text-xs flex items-center gap-1">
<span class="icon-[heroicons--arrow-right] size-3"></span>
View AP Aging
</a>
</div>
</div>
{{-- AR Section --}}
<div class="bg-base-200 rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--document-plus] size-4 text-success"></span>
<span class="font-medium">Accounts Receivable</span>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-base-content/60">Outstanding</div>
<div class="font-mono font-bold">${{ number_format($div['ar_total'], 2) }}</div>
</div>
<div>
<div class="text-base-content/60">Overdue</div>
<div class="font-mono font-bold {{ $div['ar_overdue'] > 0 ? 'text-warning' : '' }}">
${{ number_format($div['ar_overdue'], 2) }}
</div>
</div>
<div>
<div class="text-base-content/60">At-Risk</div>
@if($div['ar_at_risk'] > 0)
<span class="badge badge-error badge-sm">{{ $div['ar_at_risk'] }} customers</span>
@else
<span class="text-base-content/50">0</span>
@endif
</td>
<td></td>
</tr>
</tfoot>
@endif
</table>
</div>
<div>
<div class="text-base-content/60">On Hold</div>
@if($div['ar_on_hold'] > 0)
<span class="badge badge-warning badge-sm">{{ $div['ar_on_hold'] }}</span>
@else
<span class="text-base-content/50">0</span>
@endif
</div>
</div>
<div class="mt-3">
<a href="{{ route('seller.business.management.ar.aging', $div['division']) }}"
class="link link-hover text-xs flex items-center gap-1">
<span class="icon-[heroicons--arrow-right] size-3"></span>
View AR Aging
</a>
</div>
</div>
</div>
</div>
</div>
</div>
@empty
<div class="card bg-base-100 shadow-sm">
<div class="card-body text-center text-base-content/50 py-12">
<span class="icon-[heroicons--building-office-2] size-12 mx-auto mb-4 opacity-40"></span>
<p>No divisions found. This dashboard is for parent companies only.</p>
</div>
</div>
@endforelse
</div>
{{-- Division Comparison Chart --}}
{{-- Totals Footer --}}
@if($divisions->count() > 0)
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="font-bold">
Totals ({{ $divisions->count() }} {{ Str::plural('division', $divisions->count()) }})
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div class="text-base-content/60">AP Outstanding</div>
<div class="font-mono font-bold">${{ number_format($totals['ap_outstanding'], 2) }}</div>
</div>
<div>
<div class="text-base-content/60">AP Overdue</div>
<div class="font-mono font-bold text-error">${{ number_format($totals['ap_overdue'], 2) }}</div>
</div>
<div>
<div class="text-base-content/60">AR Outstanding</div>
<div class="font-mono font-bold">${{ number_format($totals['ar_total'], 2) }}</div>
</div>
<div>
<div class="text-base-content/60">AR Overdue</div>
<div class="font-mono font-bold text-warning">${{ number_format($totals['ar_overdue'], 2) }}</div>
</div>
</div>
</div>
</div>
</div>
@endif
{{-- Visual Comparison Chart --}}
@if($divisions->count() > 0)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">AP Outstanding by Division</h2>
<h2 class="card-title text-lg">AP vs AR by Division</h2>
<div class="mt-4 space-y-4">
@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>
<div class="flex justify-between mb-1">
<span class="font-medium">{{ $div['division']->division_name ?? $div['division']->name }}</span>
<span class="font-mono">${{ number_format($div['ap_outstanding'], 2) }}</span>
</div>
<div class="h-4 bg-base-200 rounded-full overflow-hidden">
<div class="h-full flex">
@if($overduePct > 0)
<div class="bg-error" style="width: {{ ($overduePct / 100) * $pct }}%"
title="Overdue: ${{ number_format($div['ap_overdue'], 2) }}"></div>
@endif
<div class="bg-primary" style="width: {{ $pct - (($overduePct / 100) * $pct) }}%"
title="Current: ${{ number_format($div['ap_outstanding'] - $div['ap_overdue'], 2) }}"></div>
<div class="grid grid-cols-2 gap-2">
<div>
<div class="text-xs text-base-content/60 mb-1">AP: ${{ number_format($div['ap_outstanding'], 0) }}</div>
<div class="h-4 bg-base-200 rounded-full overflow-hidden">
<div class="h-full bg-error/70" style="width: {{ $apPct }}%"></div>
</div>
</div>
<div>
<div class="text-xs text-base-content/60 mb-1">AR: ${{ number_format($div['ar_total'], 0) }}</div>
<div class="h-4 bg-base-200 rounded-full overflow-hidden">
<div class="h-full bg-success/70" style="width: {{ $arPct }}%"></div>
</div>
</div>
</div>
</div>
@@ -156,10 +271,10 @@
</div>
<div class="flex gap-4 mt-4 text-sm text-base-content/70">
<span class="flex items-center gap-1">
<span class="w-3 h-3 bg-primary rounded"></span> Current
<span class="w-3 h-3 bg-error/70 rounded"></span> AP (Payables)
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 bg-error rounded"></span> Overdue
<span class="w-3 h-3 bg-success/70 rounded"></span> AR (Receivables)
</span>
</div>
</div>

View File

@@ -9,35 +9,153 @@
</div>
</div>
{{-- Quick Stats --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-title">AP Outstanding</div>
<div class="stat-value text-lg">${{ number_format($aging['total'], 2) }}</div>
<div class="stat-desc">Total payables</div>
</div>
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-title">Overdue Bills</div>
<div class="stat-value text-lg {{ $aging['overdue_bills']->count() > 0 ? 'text-error' : '' }}">
{{ $aging['overdue_bills']->count() }}
{{-- Accounts Receivable Section --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">
<span class="icon-[heroicons--document-currency-dollar] size-6 text-success"></span>
Accounts Receivable
</h2>
{{-- AR Stats - Clickable drill-down links --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<a href="{{ route('seller.business.management.ar.accounts', $business) }}" class="stat bg-success/10 rounded-box p-4 hover:bg-success/20 transition-colors cursor-pointer no-underline">
<div class="stat-title text-sm">Total AR</div>
<div class="stat-value text-lg text-success">${{ number_format($arSummary['total_ar'] ?? 0, 2) }}</div>
<div class="stat-desc flex items-center gap-1">
Open receivables
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
<a href="{{ route('seller.business.management.ar.aging', $business) }}" class="stat bg-warning/10 rounded-box p-4 hover:bg-warning/20 transition-colors cursor-pointer no-underline">
<div class="stat-title text-sm">Past Due</div>
<div class="stat-value text-lg {{ ($arSummary['total_past_due'] ?? 0) > 0 ? 'text-warning' : '' }}">${{ number_format($arSummary['total_past_due'] ?? 0, 2) }}</div>
<div class="stat-desc flex items-center gap-1">
View aging
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
<a href="{{ route('seller.business.management.ar.accounts', ['business' => $business, 'at_risk' => 1]) }}" class="stat bg-error/10 rounded-box p-4 hover:bg-error/20 transition-colors cursor-pointer no-underline">
<div class="stat-title text-sm">At-Risk Accounts</div>
<div class="stat-value text-lg {{ ($arSummary['at_risk_count'] ?? 0) > 0 ? 'text-error' : '' }}">{{ $arSummary['at_risk_count'] ?? 0 }}</div>
<div class="stat-desc flex items-center gap-1">
View at-risk
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
<a href="{{ route('seller.business.management.ar.accounts', ['business' => $business, 'on_hold' => 1]) }}" class="stat bg-base-200 rounded-box p-4 hover:bg-base-300 transition-colors cursor-pointer no-underline">
<div class="stat-title text-sm">On Credit Hold</div>
<div class="stat-value text-lg">{{ $arSummary['on_hold_count'] ?? 0 }}</div>
<div class="stat-desc flex items-center gap-1">
View holds
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
</div>
<div class="stat-desc">Needs attention</div>
{{-- Top AR Accounts --}}
@if(isset($topArAccounts) && $topArAccounts->count() > 0)
<div class="overflow-x-auto">
<h3 class="font-semibold text-sm mb-2">Top AR Accounts</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Account</th>
@if($isParent)
<th>Division</th>
@endif
<th class="text-right">Balance</th>
<th class="text-right">Past Due</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($topArAccounts as $account)
<tr>
<td class="font-medium">{{ $account['customer']->name ?? 'N/A' }}</td>
@if($isParent)
<td class="text-sm text-base-content/70">{{ $account['business']->name ?? 'N/A' }}</td>
@endif
<td class="text-right">${{ number_format($account['balance'], 2) }}</td>
<td class="text-right {{ $account['past_due'] > 0 ? 'text-warning' : '' }}">${{ number_format($account['past_due'], 2) }}</td>
<td>
@if($account['on_credit_hold'])
<span class="badge badge-error badge-sm">Hold</span>
@elseif($account['past_due'] > 0)
<span class="badge badge-warning badge-sm">Past Due</span>
@else
<span class="badge badge-success badge-sm">Good</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-title">Due (Next 7 Days)</div>
<div class="stat-value text-lg">${{ number_format($forecast['total'], 2) }}</div>
<div class="stat-desc">{{ $forecast['bill_count'] }} {{ Str::plural('bill', $forecast['bill_count']) }}</div>
</div>
@if($isParent)
<div class="stat bg-base-100 shadow-sm rounded-box">
<div class="stat-figure text-primary">
<span class="icon-[heroicons--building-office-2] size-8"></span>
</div>
{{-- Accounts Payable Section --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">
<span class="icon-[heroicons--document-minus] size-6 text-error"></span>
Accounts Payable
</h2>
{{-- AP Stats - Clickable drill-down links --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="{{ route('seller.business.management.finance.ap-aging', $business) }}" class="stat bg-error/10 rounded-box p-4 hover:bg-error/20 transition-colors cursor-pointer no-underline">
<div class="stat-title text-sm">AP Outstanding</div>
<div class="stat-value text-lg text-error">${{ number_format($aging['total'] ?? 0, 2) }}</div>
<div class="stat-desc flex items-center gap-1">
View AP aging
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
<a href="{{ route('seller.business.management.finance.ap-aging', ['business' => $business, 'filter' => 'overdue']) }}" class="stat bg-warning/10 rounded-box p-4 hover:bg-warning/20 transition-colors cursor-pointer no-underline">
<div class="stat-title text-sm">Overdue Bills</div>
<div class="stat-value text-lg {{ isset($aging['overdue_bills']) && $aging['overdue_bills']->count() > 0 ? 'text-warning' : '' }}">
{{ isset($aging['overdue_bills']) ? $aging['overdue_bills']->count() : 0 }}
</div>
<div class="stat-desc flex items-center gap-1">
View overdue
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
<a href="{{ route('seller.business.management.finance.cash-forecast', $business) }}" class="stat bg-base-200 rounded-box p-4 hover:bg-base-300 transition-colors cursor-pointer no-underline">
<div class="stat-title text-sm">Due (Next 7 Days)</div>
<div class="stat-value text-lg">${{ number_format($forecast['total'] ?? 0, 2) }}</div>
<div class="stat-desc flex items-center gap-1">
{{ $forecast['bill_count'] ?? 0 }} {{ Str::plural('bill', $forecast['bill_count'] ?? 0) }}
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
@if($isParent)
<a href="{{ route('seller.business.management.finance.divisions', $business) }}" class="stat bg-primary/10 rounded-box p-4 hover:bg-primary/20 transition-colors cursor-pointer no-underline">
<div class="stat-figure text-primary">
<span class="icon-[heroicons--building-office-2] size-6"></span>
</div>
<div class="stat-title text-sm">Divisions</div>
<div class="stat-value text-lg">{{ $business->childBusinesses()->count() }}</div>
<div class="stat-desc flex items-center gap-1">
View all
<span class="icon-[heroicons--arrow-right] size-3"></span>
</div>
</a>
@else
<div class="stat bg-base-200 rounded-box p-4">
<div class="stat-figure text-base-content/40">
<span class="icon-[heroicons--building-office] size-6"></span>
</div>
<div class="stat-title text-sm">Account Type</div>
<div class="stat-value text-lg text-base-content/60">Division</div>
<div class="stat-desc">Single location</div>
</div>
<div class="stat-title">Parent Company</div>
<div class="stat-value text-lg">Yes</div>
<div class="stat-desc">Has divisions</div>
@endif
</div>
@endif
</div>
</div>
{{-- Quick Links --}}
@@ -45,21 +163,36 @@
<div class="card-body">
<h2 class="card-title text-lg">Finance Reports</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
{{-- AR Reports --}}
<a href="{{ route('seller.business.management.ar.aging', $business) }}" class="btn btn-outline justify-start gap-2">
<span class="icon-[heroicons--clock] size-5 text-success"></span>
AR Aging
</a>
<a href="{{ route('seller.business.management.ar.accounts', $business) }}" class="btn btn-outline justify-start gap-2">
<span class="icon-[heroicons--users] size-5 text-success"></span>
AR Accounts
</a>
{{-- AP Reports --}}
<a href="{{ route('seller.business.management.finance.ap-aging', $business) }}" class="btn btn-outline justify-start gap-2">
<span class="icon-[heroicons--clock] size-5"></span>
<span class="icon-[heroicons--clock] size-5 text-error"></span>
AP Aging
</a>
<a href="{{ route('seller.business.management.finance.vendor-spend', $business) }}" class="btn btn-outline justify-start gap-2">
<span class="icon-[heroicons--chart-bar] size-5 text-error"></span>
Vendor Spend
</a>
{{-- Cash & Forecasting --}}
<a href="{{ route('seller.business.management.finance.cash-forecast', $business) }}" class="btn btn-outline justify-start gap-2">
<span class="icon-[heroicons--currency-dollar] size-5"></span>
Cash Forecast
</a>
<a href="{{ route('seller.business.management.finance.vendor-spend', $business) }}" class="btn btn-outline justify-start gap-2">
<span class="icon-[heroicons--chart-bar] size-5"></span>
Vendor Spend
</a>
{{-- Parent-only links --}}
@if($isParent)
<a href="{{ route('seller.business.management.finance.divisions', $business) }}" class="btn btn-outline justify-start gap-2">
<span class="icon-[heroicons--building-office-2] size-5"></span>
<span class="icon-[heroicons--building-office-2] size-5 text-primary"></span>
Divisions
</a>
@endif

View File

@@ -0,0 +1,168 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Forecasting - Management')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Financial Forecasting</h1>
<p class="text-base-content/60 mt-1">12-month revenue and expense projections based on historical trends</p>
</div>
</div>
{{-- Division Filter --}}
@include('seller.management.partials.division-filter')
{{-- Trends Summary --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="text-sm text-base-content/60">Revenue Trend</div>
<div class="text-2xl font-bold flex items-center gap-2">
@if($forecast['trends']['revenue'] >= 0)
<span class="text-success">+{{ number_format($forecast['trends']['revenue'], 1) }}%</span>
<span class="icon-[heroicons--arrow-trending-up] size-6 text-success"></span>
@else
<span class="text-error">{{ number_format($forecast['trends']['revenue'], 1) }}%</span>
<span class="icon-[heroicons--arrow-trending-down] size-6 text-error"></span>
@endif
</div>
<div class="text-xs text-base-content/60 mt-1">Based on last 12 months</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="text-sm text-base-content/60">Expense Trend</div>
<div class="text-2xl font-bold flex items-center gap-2">
@if($forecast['trends']['expenses'] <= 0)
<span class="text-success">{{ number_format($forecast['trends']['expenses'], 1) }}%</span>
<span class="icon-[heroicons--arrow-trending-down] size-6 text-success"></span>
@else
<span class="text-warning">+{{ number_format($forecast['trends']['expenses'], 1) }}%</span>
<span class="icon-[heroicons--arrow-trending-up] size-6 text-warning"></span>
@endif
</div>
<div class="text-xs text-base-content/60 mt-1">Based on last 12 months</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="text-sm text-base-content/60">Projected Net Income (12mo)</div>
<div class="text-2xl font-bold {{ $forecast['summary']['total_projected_net'] >= 0 ? 'text-success' : 'text-error' }}">
${{ number_format($forecast['summary']['total_projected_net'], 0) }}
</div>
<div class="text-xs text-base-content/60 mt-1">Next 12 months total</div>
</div>
</div>
</div>
{{-- Forecast Table --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">12-Month Forecast</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Month</th>
<th class="text-right">Projected Revenue</th>
<th class="text-right">Projected Expenses</th>
<th class="text-right">Projected Net</th>
<th class="text-right">Margin</th>
</tr>
</thead>
<tbody>
@foreach($forecast['forecast'] as $month)
@php
$margin = $month['projected_revenue'] > 0
? ($month['projected_net'] / $month['projected_revenue']) * 100
: 0;
@endphp
<tr>
<td class="font-medium">{{ $month['month'] }}</td>
<td class="text-right text-success">${{ number_format($month['projected_revenue'], 0) }}</td>
<td class="text-right text-error">${{ number_format($month['projected_expenses'], 0) }}</td>
<td class="text-right font-medium {{ $month['projected_net'] >= 0 ? 'text-success' : 'text-error' }}">
${{ number_format($month['projected_net'], 0) }}
</td>
<td class="text-right">
<span class="badge {{ $margin >= 20 ? 'badge-success' : ($margin >= 10 ? 'badge-warning' : 'badge-error') }} badge-sm">
{{ number_format($margin, 1) }}%
</span>
</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-bold bg-base-200">
<td>Total</td>
<td class="text-right text-success">${{ number_format($forecast['summary']['total_projected_revenue'], 0) }}</td>
<td class="text-right text-error">${{ number_format($forecast['summary']['total_projected_expenses'], 0) }}</td>
<td class="text-right {{ $forecast['summary']['total_projected_net'] >= 0 ? 'text-success' : 'text-error' }}">
${{ number_format($forecast['summary']['total_projected_net'], 0) }}
</td>
<td class="text-right">
@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) }}%
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{{-- Historical Data --}}
<div class="card bg-base-100 shadow-sm mt-6">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Historical Data (Last 12 Months)</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Month</th>
<th class="text-right">Revenue</th>
<th class="text-right">Expenses</th>
<th class="text-right">Net</th>
</tr>
</thead>
<tbody>
@foreach($forecast['historical']['revenue'] as $index => $revenueMonth)
@php
$expenses = $forecast['historical']['expenses'][$index]['amount'] ?? 0;
$net = $revenueMonth['amount'] - $expenses;
@endphp
<tr>
<td>{{ $revenueMonth['month'] }}</td>
<td class="text-right">${{ number_format($revenueMonth['amount'], 0) }}</td>
<td class="text-right">${{ number_format($expenses, 0) }}</td>
<td class="text-right {{ $net >= 0 ? 'text-success' : 'text-error' }}">
${{ number_format($net, 0) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
{{-- Methodology Note --}}
<div class="alert mt-6">
<span class="icon-[heroicons--information-circle] size-5"></span>
<div>
<div class="font-medium">Forecast Methodology</div>
<div class="text-sm">
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.
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,371 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Inventory Valuation - Management')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Inventory Valuation</h1>
<p class="text-base-content/60 mt-1">
@if($selectedDivision ?? false)
{{ $selectedDivision->division_name ?? $selectedDivision->name }} - Inventory overview
@else
Consolidated inventory overview
@endif
</p>
</div>
<div class="mt-4 md:mt-0 text-sm text-base-content/60">
As of {{ now()->format('M j, Y g:i A') }}
</div>
</div>
{{-- Division Filter --}}
@include('seller.management.partials.division-filter')
{{-- Risk Alert Banner --}}
@if(($atRisk['quarantined']['count'] ?? 0) > 0 || ($atRisk['expired']['count'] ?? 0) > 0)
<div class="alert alert-warning mb-6">
<span class="icon-[heroicons--exclamation-triangle] size-5"></span>
<div>
<div class="font-semibold">Inventory At Risk</div>
<div class="text-sm">
@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
</div>
</div>
</div>
@endif
{{-- TOP ROW: Summary KPIs --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{{-- Total Inventory Value --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Total Inventory Value</div>
<div class="text-2xl font-bold text-primary">${{ number_format($summary['total_value'], 0) }}</div>
<div class="text-xs text-base-content/50">{{ number_format($summary['total_items']) }} items</div>
</div>
</div>
{{-- Total Quantity --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Total Quantity</div>
<div class="text-2xl font-bold">{{ number_format($summary['total_quantity'], 0) }}</div>
<div class="text-xs text-base-content/50">units on hand</div>
</div>
</div>
{{-- Avg Value per Item --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Avg Value/Item</div>
<div class="text-2xl font-bold">${{ number_format($summary['avg_value_per_item'], 2) }}</div>
<div class="text-xs text-base-content/50">per inventory item</div>
</div>
</div>
{{-- Expiring Soon --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4">
<div class="text-sm text-base-content/60">Expiring Soon</div>
<div class="text-2xl font-bold {{ ($atRisk['expiring_soon']['count'] ?? 0) > 0 ? 'text-warning' : '' }}">
{{ $atRisk['expiring_soon']['count'] ?? 0 }}
</div>
<div class="text-xs text-base-content/50">${{ number_format($atRisk['expiring_soon']['value'] ?? 0, 0) }} value</div>
</div>
</div>
</div>
{{-- SECOND ROW: Charts/Breakdowns --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{{-- Valuation by Item Type --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Valuation by Type</h2>
@if($byType->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Type</th>
<th class="text-right">Items</th>
<th class="text-right">Quantity</th>
<th class="text-right">Value</th>
<th class="text-right">%</th>
</tr>
</thead>
<tbody>
@foreach($byType as $type)
<tr>
<td>{{ $type['item_type_label'] }}</td>
<td class="text-right">{{ number_format($type['item_count']) }}</td>
<td class="text-right">{{ number_format($type['total_quantity'], 0) }}</td>
<td class="text-right font-medium">${{ number_format($type['total_value'], 0) }}</td>
<td class="text-right text-base-content/60">
{{ $summary['total_value'] > 0 ? number_format(($type['total_value'] / $summary['total_value']) * 100, 1) : 0 }}%
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="py-8 text-center text-base-content/60">No inventory data available</div>
@endif
</div>
</div>
{{-- Valuation by Category --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Valuation by Category</h2>
@if($byCategory->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Category</th>
<th class="text-right">Items</th>
<th class="text-right">Quantity</th>
<th class="text-right">Value</th>
</tr>
</thead>
<tbody>
@foreach($byCategory->take(8) as $cat)
<tr>
<td class="capitalize">{{ $cat['category'] }}</td>
<td class="text-right">{{ number_format($cat['item_count']) }}</td>
<td class="text-right">{{ number_format($cat['total_quantity'], 0) }}</td>
<td class="text-right font-medium">${{ number_format($cat['total_value'], 0) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if($byCategory->count() > 8)
<div class="text-center mt-2 text-sm text-base-content/60">
+ {{ $byCategory->count() - 8 }} more categories
</div>
@endif
@else
<div class="py-8 text-center text-base-content/60">No categorized inventory</div>
@endif
</div>
</div>
</div>
{{-- THIRD ROW: Division & Location --}}
@if($isParent && $byDivision->count() > 0)
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{{-- Valuation by Division --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Valuation by Division</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Division</th>
<th class="text-right">Items</th>
<th class="text-right">Value</th>
<th class="text-right">%</th>
</tr>
</thead>
<tbody>
@foreach($byDivision as $div)
<tr>
<td>{{ $div['business_name'] }}</td>
<td class="text-right">{{ number_format($div['item_count']) }}</td>
<td class="text-right font-medium">${{ number_format($div['total_value'], 0) }}</td>
<td class="text-right text-base-content/60">
{{ $summary['total_value'] > 0 ? number_format(($div['total_value'] / $summary['total_value']) * 100, 1) : 0 }}%
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
{{-- Valuation by Location --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Valuation by Location</h2>
@if($byLocation->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Location</th>
<th class="text-right">Items</th>
<th class="text-right">Value</th>
</tr>
</thead>
<tbody>
@foreach($byLocation->take(6) as $loc)
<tr>
<td>{{ $loc['location_name'] }}</td>
<td class="text-right">{{ number_format($loc['item_count']) }}</td>
<td class="text-right font-medium">${{ number_format($loc['total_value'], 0) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="py-8 text-center text-base-content/60">No location data</div>
@endif
</div>
</div>
</div>
@elseif($byLocation->count() > 0)
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h2 class="card-title text-lg">Valuation by Location</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Location</th>
<th class="text-right">Items</th>
<th class="text-right">Quantity</th>
<th class="text-right">Value</th>
</tr>
</thead>
<tbody>
@foreach($byLocation as $loc)
<tr>
<td>{{ $loc['location_name'] }}</td>
<td class="text-right">{{ number_format($loc['item_count']) }}</td>
<td class="text-right">{{ number_format($loc['total_quantity'], 0) }}</td>
<td class="text-right font-medium">${{ number_format($loc['total_value'], 0) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
{{-- FOURTH ROW: Aging & Top Items --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{{-- Inventory Aging --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Inventory Aging</h2>
<p class="text-sm text-base-content/60 mb-4">Based on received date</p>
<div class="space-y-3">
@foreach($aging as $bucket => $data)
@if($data['count'] > 0 || $bucket !== 'no_date')
<div>
<div class="flex justify-between text-sm mb-1">
<span>{{ $data['label'] }}</span>
<span class="font-medium">${{ number_format($data['value'], 0) }}</span>
</div>
<div class="flex items-center gap-2">
<progress
class="progress {{ $bucket === '180+' ? 'progress-error' : ($bucket === '91-180' ? 'progress-warning' : 'progress-primary') }} flex-1"
value="{{ $summary['total_value'] > 0 ? ($data['value'] / $summary['total_value']) * 100 : 0 }}"
max="100"
></progress>
<span class="text-xs text-base-content/60 w-12 text-right">{{ $data['count'] }}</span>
</div>
</div>
@endif
@endforeach
</div>
</div>
</div>
{{-- Inventory At Risk --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Inventory At Risk</h2>
<div class="grid grid-cols-3 gap-4 mt-4">
<div class="text-center p-4 bg-base-200 rounded-lg">
<div class="text-2xl font-bold {{ ($atRisk['quarantined']['count'] ?? 0) > 0 ? 'text-error' : '' }}">
{{ $atRisk['quarantined']['count'] ?? 0 }}
</div>
<div class="text-sm text-base-content/60">Quarantined</div>
<div class="text-xs text-base-content/50">${{ number_format($atRisk['quarantined']['value'] ?? 0, 0) }}</div>
</div>
<div class="text-center p-4 bg-base-200 rounded-lg">
<div class="text-2xl font-bold {{ ($atRisk['expiring_soon']['count'] ?? 0) > 0 ? 'text-warning' : '' }}">
{{ $atRisk['expiring_soon']['count'] ?? 0 }}
</div>
<div class="text-sm text-base-content/60">Expiring Soon</div>
<div class="text-xs text-base-content/50">${{ number_format($atRisk['expiring_soon']['value'] ?? 0, 0) }}</div>
</div>
<div class="text-center p-4 bg-base-200 rounded-lg">
<div class="text-2xl font-bold {{ ($atRisk['expired']['count'] ?? 0) > 0 ? 'text-error' : '' }}">
{{ $atRisk['expired']['count'] ?? 0 }}
</div>
<div class="text-sm text-base-content/60">Expired</div>
<div class="text-xs text-base-content/50">${{ number_format($atRisk['expired']['value'] ?? 0, 0) }}</div>
</div>
</div>
</div>
</div>
</div>
{{-- FIFTH ROW: Top Items by Value --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Top 10 Items by Value</h2>
@if($topItems->count() > 0)
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Item</th>
<th>SKU</th>
<th>Type</th>
@if($isParent)
<th>Division</th>
@endif
<th class="text-right">Qty</th>
<th class="text-right">Unit Cost</th>
<th class="text-right">Total Value</th>
</tr>
</thead>
<tbody>
@foreach($topItems as $item)
<tr>
<td>
<div class="font-medium">{{ $item['name'] }}</div>
@if($item['product_name'])
<div class="text-xs text-base-content/60">{{ $item['product_name'] }}</div>
@endif
</td>
<td class="text-sm font-mono">{{ $item['sku'] ?? '-' }}</td>
<td>
<span class="badge badge-ghost badge-sm">{{ $item['item_type_label'] }}</span>
</td>
@if($isParent)
<td class="text-sm">{{ $item['business_name'] }}</td>
@endif
<td class="text-right">{{ number_format($item['quantity_on_hand'], 0) }} {{ $item['unit_of_measure'] }}</td>
<td class="text-right">${{ number_format($item['unit_cost'], 2) }}</td>
<td class="text-right font-bold">${{ number_format($item['total_value'], 0) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="py-8 text-center text-base-content/60">No inventory items with costs defined</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,211 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Operations Overview - Management')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Operations Overview</h1>
<p class="text-base-content/60 mt-1">Real-time operational metrics across all business units</p>
</div>
<div class="mt-4 md:mt-0">
<span class="text-sm text-base-content/60">
<span class="icon-[heroicons--clock] size-4 inline"></span>
Last updated: {{ now()->format('M j, Y g:i A') }}
</span>
</div>
</div>
{{-- Division Filter --}}
@include('seller.management.partials.division-filter')
{{-- Order Status --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-base-content/60">Pending Orders</div>
<div class="text-3xl font-bold text-warning">{{ $operations['orders']->pending_orders ?? 0 }}</div>
</div>
<span class="icon-[heroicons--clock] size-10 text-warning/30"></span>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-base-content/60">Processing</div>
<div class="text-3xl font-bold text-info">{{ $operations['orders']->processing_orders ?? 0 }}</div>
</div>
<span class="icon-[heroicons--cog-6-tooth] size-10 text-info/30"></span>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-base-content/60">Completed (MTD)</div>
<div class="text-3xl font-bold text-success">{{ $operations['orders']->completed_this_month ?? 0 }}</div>
</div>
<span class="icon-[heroicons--check-circle] size-10 text-success/30"></span>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-base-content/60">This Week</div>
<div class="text-3xl font-bold">{{ $operations['orders']->orders_this_week ?? 0 }}</div>
</div>
<span class="icon-[heroicons--calendar] size-10 text-base-content/20"></span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{{-- Inventory Status --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Inventory Status</h2>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title">Total Products</div>
<div class="stat-value text-2xl">{{ $operations['products']->total_products ?? 0 }}</div>
<div class="stat-desc">{{ $operations['products']->active_products ?? 0 }} active</div>
</div>
<div class="stat bg-warning/10 rounded-lg p-4">
<div class="stat-title">Low Stock</div>
<div class="stat-value text-2xl text-warning">{{ $operations['products']->low_stock_products ?? 0 }}</div>
<div class="stat-desc">Need reorder</div>
</div>
<div class="stat bg-error/10 rounded-lg p-4 col-span-2">
<div class="stat-title">Out of Stock</div>
<div class="stat-value text-2xl text-error">{{ $operations['products']->out_of_stock_products ?? 0 }}</div>
<div class="stat-desc">Immediate attention required</div>
</div>
</div>
</div>
</div>
{{-- Customer Metrics --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Customer Metrics</h2>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title">Total Customers</div>
<div class="stat-value text-2xl">{{ $operations['customers']->total_customers ?? 0 }}</div>
</div>
<div class="stat bg-success/10 rounded-lg p-4">
<div class="stat-title">New This Month</div>
<div class="stat-value text-2xl text-success">{{ $operations['customers']->new_this_month ?? 0 }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{{-- Accounts Payable Status --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Accounts Payable</h2>
<div class="space-y-4 mt-4">
<div class="flex items-center justify-between py-2 border-b border-base-200">
<span class="text-base-content/60">Pending Bills</span>
<span class="badge badge-warning">{{ $operations['bills']->pending_bills ?? 0 }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-base-200">
<span class="text-base-content/60">Approved (Ready to Pay)</span>
<span class="badge badge-info">{{ $operations['bills']->approved_bills ?? 0 }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-base-200">
<span class="text-base-content/60">Overdue</span>
<span class="badge badge-error">{{ $operations['bills']->overdue_bills ?? 0 }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="font-medium">Total Pending Amount</span>
<span class="font-bold text-error">${{ number_format($operations['bills']->pending_amount ?? 0, 2) }}</span>
</div>
</div>
</div>
</div>
{{-- Expense Approvals --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Expense Approvals</h2>
<div class="space-y-4 mt-4">
<div class="flex items-center justify-between py-2 border-b border-base-200">
<span class="text-base-content/60">Pending Approval</span>
<span class="badge badge-warning">{{ $operations['expenses']->pending_expenses ?? 0 }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="font-medium">Pending Amount</span>
<span class="font-bold text-warning">${{ number_format($operations['expenses']->pending_amount ?? 0, 2) }}</span>
</div>
</div>
@if(($operations['expenses']->pending_expenses ?? 0) > 0)
<div class="mt-4">
<a href="{{ route('seller.business.management.expenses.index', $business) }}" class="btn btn-sm btn-outline">
Review Expenses
</a>
</div>
@endif
</div>
</div>
</div>
{{-- Recent Orders --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Recent Orders</h2>
@if(count($operations['recent_orders']) > 0)
<div class="overflow-x-auto mt-4">
<table class="table">
<thead>
<tr>
<th>Order</th>
<th>Business</th>
<th>Status</th>
<th class="text-right">Total</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@foreach($operations['recent_orders'] as $order)
<tr>
<td class="font-medium">#{{ $order->id }}</td>
<td>{{ $order->business_name }}</td>
<td>
<span class="badge badge-sm
{{ $order->status === 'completed' ? 'badge-success' :
($order->status === 'pending' ? 'badge-warning' :
($order->status === 'processing' ? 'badge-info' : 'badge-ghost')) }}">
{{ ucfirst($order->status) }}
</span>
</td>
<td class="text-right">${{ number_format($order->total, 2) }}</td>
<td class="text-base-content/60">{{ \Carbon\Carbon::parse($order->created_at)->diffForHumans() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--shopping-cart] size-12 block mx-auto mb-2 opacity-40"></span>
No recent orders
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,218 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Usage & Billing - Management')
@section('content')
<div class="container mx-auto px-4 py-6">
{{-- Page Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Usage & Billing</h1>
<p class="text-base-content/60 mt-1">Monitor platform usage and billing across your organization</p>
</div>
<div class="mt-4 md:mt-0">
<span class="text-sm text-base-content/60">
Billing Period: {{ $usage['billing_period']['start'] }} - {{ $usage['billing_period']['end'] }}
</span>
</div>
</div>
{{-- Division Filter --}}
@include('seller.management.partials.division-filter')
{{-- Plan Status --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
<div>
<h2 class="card-title text-lg">Current Plan</h2>
<div class="flex items-center gap-2 mt-2">
@if($usage['is_enterprise'])
<span class="badge badge-lg badge-primary">Enterprise Plan</span>
<span class="text-base-content/60">Unlimited usage</span>
@else
<span class="badge badge-lg badge-ghost">Standard Plan</span>
@endif
</div>
</div>
<div class="mt-4 md:mt-0">
<div class="text-sm text-base-content/60 mb-2">Active Suites</div>
<div class="flex flex-wrap gap-2">
@foreach($usage['enabled_suites'] as $suite)
<span class="badge badge-outline">{{ $suite['name'] }}</span>
@endforeach
</div>
</div>
</div>
</div>
</div>
{{-- Usage Meters --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{{-- Brands --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">Brands</span>
<span class="text-sm text-base-content/60">
{{ $usage['brands']['current'] }}
@if($usage['brands']['limit'])
/ {{ $usage['brands']['limit'] }}
@else
<span class="badge badge-xs badge-success">Unlimited</span>
@endif
</span>
</div>
@if($usage['brands']['limit'])
<progress class="progress {{ $usage['brands']['percentage'] > 80 ? 'progress-warning' : 'progress-primary' }} w-full"
value="{{ $usage['brands']['percentage'] }}" max="100"></progress>
@else
<progress class="progress progress-success w-full" value="100" max="100"></progress>
@endif
</div>
</div>
{{-- SKUs --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">Products (SKUs)</span>
<span class="text-sm text-base-content/60">
{{ $usage['skus']['current'] }}
@if($usage['skus']['limit'])
/ {{ $usage['skus']['limit'] }}
@else
<span class="badge badge-xs badge-success">Unlimited</span>
@endif
</span>
</div>
@if($usage['skus']['limit'])
<progress class="progress {{ $usage['skus']['percentage'] > 80 ? 'progress-warning' : 'progress-primary' }} w-full"
value="{{ $usage['skus']['percentage'] }}" max="100"></progress>
@else
<progress class="progress progress-success w-full" value="100" max="100"></progress>
@endif
</div>
</div>
{{-- Messages --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">Messages (MTD)</span>
<span class="text-sm text-base-content/60">
{{ number_format($usage['messages']['current']) }}
@if($usage['messages']['limit'])
/ {{ number_format($usage['messages']['limit']) }}
@else
<span class="badge badge-xs badge-success">Unlimited</span>
@endif
</span>
</div>
@if($usage['messages']['limit'])
<progress class="progress {{ $usage['messages']['percentage'] > 80 ? 'progress-warning' : 'progress-primary' }} w-full"
value="{{ $usage['messages']['percentage'] }}" max="100"></progress>
@else
<progress class="progress progress-success w-full" value="100" max="100"></progress>
@endif
</div>
</div>
{{-- Menu Sends --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">Menu Sends (MTD)</span>
<span class="text-sm text-base-content/60">
{{ number_format($usage['menu_sends']['current']) }}
@if($usage['menu_sends']['limit'])
/ {{ number_format($usage['menu_sends']['limit']) }}
@else
<span class="badge badge-xs badge-success">Unlimited</span>
@endif
</span>
</div>
@if($usage['menu_sends']['limit'])
<progress class="progress {{ $usage['menu_sends']['percentage'] > 80 ? 'progress-warning' : 'progress-primary' }} w-full"
value="{{ $usage['menu_sends']['percentage'] }}" max="100"></progress>
@else
<progress class="progress progress-success w-full" value="100" max="100"></progress>
@endif
</div>
</div>
{{-- Contacts --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">CRM Contacts</span>
<span class="text-sm text-base-content/60">
{{ number_format($usage['contacts']['current']) }}
@if($usage['contacts']['limit'])
/ {{ number_format($usage['contacts']['limit']) }}
@else
<span class="badge badge-xs badge-success">Unlimited</span>
@endif
</span>
</div>
@if($usage['contacts']['limit'])
<progress class="progress {{ $usage['contacts']['percentage'] > 80 ? 'progress-warning' : 'progress-primary' }} w-full"
value="{{ $usage['contacts']['percentage'] }}" max="100"></progress>
@else
<progress class="progress progress-success w-full" value="100" max="100"></progress>
@endif
</div>
</div>
</div>
{{-- Usage by Division --}}
@if(count($usage['usage_by_division']) > 0)
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Usage by Division</h2>
<div class="overflow-x-auto mt-4">
<table class="table">
<thead>
<tr>
<th>Division</th>
<th class="text-right">Brands</th>
<th class="text-right">SKUs</th>
</tr>
</thead>
<tbody>
@foreach($usage['usage_by_division'] as $division)
<tr>
<td class="font-medium">{{ $division->name }}</td>
<td class="text-right">{{ $division->brand_count }}</td>
<td class="text-right">{{ $division->sku_count }}</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-bold bg-base-200">
<td>Total</td>
<td class="text-right">{{ $usage['brands']['current'] }}</td>
<td class="text-right">{{ $usage['skus']['current'] }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
@endif
{{-- Plan Limits Info --}}
@if(!$usage['is_enterprise'])
<div class="alert mt-6">
<span class="icon-[heroicons--information-circle] size-5"></span>
<div>
<div class="font-medium">Usage Limits</div>
<div class="text-sm">
Your current plan includes {{ $usage['brands']['limit'] ?? 1 }} brand(s) with usage limits per brand.
Contact support to upgrade to Enterprise for unlimited usage.
</div>
</div>
</div>
@endif
</div>
@endsection

View File

@@ -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');
});
});