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:
@@ -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)
|
||||
|
||||
119
app/Http/Controllers/Seller/Management/AnalyticsController.php
Normal file
119
app/Http/Controllers/Seller/Management/AnalyticsController.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
201
app/Http/Controllers/Seller/Management/ForecastingController.php
Normal file
201
app/Http/Controllers/Seller/Management/ForecastingController.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/Seller/Management/OperationsController.php
Normal file
136
app/Http/Controllers/Seller/Management/OperationsController.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
139
app/Models/Accounting/BankAccount.php
Normal file
139
app/Models/Accounting/BankAccount.php
Normal 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);
|
||||
}
|
||||
}
|
||||
284
app/Models/Accounting/BankMatchRule.php
Normal file
284
app/Models/Accounting/BankMatchRule.php
Normal 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;
|
||||
}
|
||||
}
|
||||
132
app/Models/Accounting/BankTransfer.php
Normal file
132
app/Models/Accounting/BankTransfer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
171
app/Models/Accounting/PlaidAccount.php
Normal file
171
app/Models/Accounting/PlaidAccount.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
141
app/Models/Accounting/PlaidItem.php
Normal file
141
app/Models/Accounting/PlaidItem.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
328
app/Models/Accounting/PlaidTransaction.php
Normal file
328
app/Models/Accounting/PlaidTransaction.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
504
app/Services/Accounting/ArService.php
Normal file
504
app/Services/Accounting/ArService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
233
app/Services/Accounting/BankAccountService.php
Normal file
233
app/Services/Accounting/BankAccountService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
382
app/Services/Accounting/BankReconciliationService.php
Normal file
382
app/Services/Accounting/BankReconciliationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
300
app/Services/Accounting/InventoryValuationService.php
Normal file
300
app/Services/Accounting/InventoryValuationService.php
Normal 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
554
app/Services/Accounting/JournalEntryService.php
Normal file
554
app/Services/Accounting/JournalEntryService.php
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
369
app/Services/Accounting/PlaidIntegrationService.php
Normal file
369
app/Services/Accounting/PlaidIntegrationService.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
247
app/Services/BrandAccessService.php
Normal file
247
app/Services/BrandAccessService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
163
resources/views/seller/management/analytics/index.blade.php
Normal file
163
resources/views/seller/management/analytics/index.blade.php
Normal 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
|
||||
@@ -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">
|
||||
|
||||
343
resources/views/seller/management/ar/account-detail.blade.php
Normal file
343
resources/views/seller/management/ar/account-detail.blade.php
Normal 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
|
||||
163
resources/views/seller/management/ar/accounts.blade.php
Normal file
163
resources/views/seller/management/ar/accounts.blade.php
Normal 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
|
||||
174
resources/views/seller/management/bank-accounts/create.blade.php
Normal file
174
resources/views/seller/management/bank-accounts/create.blade.php
Normal 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
|
||||
171
resources/views/seller/management/bank-accounts/edit.blade.php
Normal file
171
resources/views/seller/management/bank-accounts/edit.blade.php
Normal 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
|
||||
148
resources/views/seller/management/bank-accounts/index.blade.php
Normal file
148
resources/views/seller/management/bank-accounts/index.blade.php
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
188
resources/views/seller/management/bank-accounts/show.blade.php
Normal file
188
resources/views/seller/management/bank-accounts/show.blade.php
Normal 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)
|
||||
• ***{{ $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
|
||||
@@ -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
|
||||
133
resources/views/seller/management/bank-transfers/index.blade.php
Normal file
133
resources/views/seller/management/bank-transfers/index.blade.php
Normal 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
|
||||
161
resources/views/seller/management/bank-transfers/show.blade.php
Normal file
161
resources/views/seller/management/bank-transfers/show.blade.php
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
168
resources/views/seller/management/forecasting/index.blade.php
Normal file
168
resources/views/seller/management/forecasting/index.blade.php
Normal 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
|
||||
@@ -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
|
||||
211
resources/views/seller/management/operations/index.blade.php
Normal file
211
resources/views/seller/management/operations/index.blade.php
Normal 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
|
||||
218
resources/views/seller/management/usage-billing/index.blade.php
Normal file
218
resources/views/seller/management/usage-billing/index.blade.php
Normal 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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user