Compare commits

...

149 Commits

Author SHA1 Message Date
kelly
27c8395d5a ci: trigger rebuild after Woodpecker repair 2025-12-07 15:04:11 -07:00
kelly
dbee401f61 Merge pull request 'feature/suite-shares' (#144) from feature/suite-shares into develop 2025-12-07 21:39:13 +00:00
kelly
b17bc590bb Merge pull request 'feat: add visibility guards and Shared badge for child division menu items' (#143) from feature/shared-menu-badges-clean into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/143
2025-12-07 21:35:58 +00:00
kelly
6ce5ca14e2 feat: improve Suite Shares UI and expand Sales Suite menu
Suite Shares improvements:
- Fix sharing direction: share FROM this business TO target
- Add suite selector to choose which suite to share items from
- Dynamically show menu items based on selected suite
- Add shared_suite_key column to track source suite

Sales Suite menu expansion:
- Add Commerce section: Orders, Invoices, Backorders, All Customers
- Add Growth section: Campaigns, Channels, Templates, Automations
- Add Sales CRM section: Overview, Pipeline, Accounts, Tasks, Activity, Calendar
- Add Inbox section: Overview, Contacts, Conversations
- Add Stock to Inventory section
- Remove AR Overview, AR Accounts, AI Copilot, My Expenses (not core Sales)
2025-12-07 14:24:53 -07:00
kelly
454b85ffb1 fix: require explicit Suite Shares for division menu items
Remove automatic division menu items (requisitions, vendors, AR/AP snapshots)
for child businesses. These features must now be explicitly configured via
Suite Shares from the parent company.

This aligns with the core product vision - Sales Suite is a marketplace
platform like LeafLink. Accounting features are opt-in, not automatic.
2025-12-07 13:52:38 -07:00
kelly
e13d7cd7ad feat: add Shared badge to sidebar menu items
Display 'Shared' badge next to menu items that have shared_from_parent flag,
indicating items shared from parent business via Suite Shares.
2025-12-07 13:38:43 -07:00
kelly
f3436d35ec feat: add Suite Shares system and restore shared menu badges
- Remove "Navigation Settings" block (everyone uses suite navigation now)
- Remove "Suite info" placeholder from Suite Assignment section
- Add "Suite Shares" section in admin to share menu items with other businesses
- Create business_suite_shares migration and BusinessSuiteShare model
- Add suiteShares and receivedSuiteShares relationships to Business model
- Update SuiteMenuResolver to include shared menu items with shared_from_parent flag
- Restore "Shared" badge rendering in sidebar for shared menu items

Suite Shares allow a parent business to share specific menu items (like
Requisitions, Vendors, AR/AP Snapshots) with child businesses, and those
items appear in the child's sidebar with a "Shared" badge.
2025-12-07 13:29:19 -07:00
kelly
a46b44055e Merge pull request 'fix: prevent empty doughnut chart from rendering as black circle' (#141) from fix/empty-doughnut-chart into develop 2025-12-07 20:00:22 +00:00
kelly
a3dda1520e fix: prevent empty doughnut chart from rendering as black circle
When all task counts are zero, the Chart.js doughnut renders as a solid
black/dark circle. Added a check to show a gray placeholder segment
with "No tasks yet" label instead.
2025-12-07 12:04:07 -07:00
kelly
4068bfc0b2 feat: add visibility guards and Shared badge for child division menu items
- Add shared_from_parent flag to division alias menu items:
  - requisitions (Purchasing section)
  - division_vendors (Accounting section)
  - division_ar_snapshot (Accounting section)
  - division_ap_snapshot (Accounting section)

- Add permission guard for requisitions menu item via userCanSubmitRequisitions()
  - Checks for can_submit_requisition, can_view_requisitions, can_manage_requisitions
  - Business owners and admins always have access

- Update resolveMenuItems() to pass through shared_from_parent flag

- Update seller-sidebar-suites.blade.php to render "Shared" badge
  - Small uppercase badge appears next to shared menu items
  - Uses DaisyUI-style pill: bg-base-200, text-base-content/60

Guard logic ensures:
- Standalone SaaS businesses (no parent_id) never see division alias items
- Child businesses see aliases only when user has appropriate permissions
- Shared items are visually marked with badge
2025-12-07 12:02:50 -07:00
kelly
497523ee0c Merge pull request 'feat: Management Suite with Finance, Accounting, AP/AR, Budgets, and CFO Dashboard' (#140) from feature/management-suite-accounting into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/140
2025-12-07 18:23:06 +00:00
kelly
94d68f80e4 fix: resolve multiple bugs in products, work orders, AR invoices, and sidebar
- Add null check for brand in products/edit.blade.php when creating new product
- Fix WorkOrder model to use scheduled_date column (matching migration) instead of due_date
- Fix AR invoice query to use total_amount column instead of total
- Rename 'Settings' sidebar section to 'Finances' with wallet icon
2025-12-07 11:08:18 -07:00
kelly
c091c3c168 feat: complete Management Suite implementation
- Add dashboard performance optimizations with caching
- Add InterBusiness settlement controller, service, and models
- Add Bank Accounts, Transfers, and Reconciliation
- Add Chart of Accounts management
- Add Accounting Periods with period lock enforcement
- Add Action Center for approvals
- Add Advanced Analytics dashboards
- Add Finance Roles and Permissions management
- Add Requisitions Approval workflow
- Add Forecasting and Cash Flow projections
- Add Inventory Valuation
- Add Operations dashboard
- Add Usage & Billing analytics
- Fix dashboard routing to use overview() method
- All naming conventions follow inter_business (not intercompany)
2025-12-07 10:56:26 -07:00
kelly
7c54ece253 style: fix pint formatting in QuoteController 2025-12-07 10:51:38 -07:00
kelly
f7294fcf83 perf: increase php-fpm worker pool for cold-start handling 2025-12-07 10:39:36 -07:00
kelly
6d64d9527a feat: implement Management Suite core features
Bank Account Management:
- BankAccountsController with full CRUD operations
- BankAccountService for account management logic
- Bank account views (index, create, edit)
- GL account linking for cash accounts

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

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

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

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

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

Models and Migrations:
- BankAccount, BankTransfer, BankMatchRule models
- PlaidItem, PlaidAccount, PlaidTransaction models
- Credit hold fields on ArCustomer
- journal_entry_id on ap_bills and ap_payments
2025-12-07 00:34:53 -07:00
kelly
08df003b20 fix: add hasChildBusinesses() method to Business model 2025-12-06 23:38:16 -07:00
kelly
59cd09eb5b refactor: rename inter_company to inter_business for naming convention
Rename table and model from inter_company_transactions to
inter_business_transactions to comply with platform naming
conventions (no 'company' terminology).
2025-12-06 23:27:11 -07:00
kelly
3a6ab1c207 chore: re-trigger CI 2025-12-06 23:27:11 -07:00
kelly
404a731bd9 fix: correct selectedDivision key name in CashFlowForecastController 2025-12-06 23:27:11 -07:00
kelly
2b30deed11 feat: switch to suite-based navigation and add management suite middleware
- Use seller-sidebar-suites component instead of legacy seller-sidebar
- Add suite:management middleware to all Management Suite routes
- Ignore local database backup files
2025-12-06 23:27:11 -07:00
kelly
109d9cd39d feat: add canopy/leopard/curagreen users and block migrate:fresh
- Add canopy@example.com, leopardaz@example.com, curagreen@example.com users
- Each seller business now has its own dedicated owner user
- SafeFreshCommand blocks migrate:fresh except for test databases
- Prevents accidental data loss in local, dev, staging, and production
2025-12-06 23:26:14 -07:00
kelly
aadd7a500a fix: update Business model and seeder for is_approved to status migration
- scopeApproved() now uses status='approved' instead of is_approved=true
- ProductionSyncSeeder uses status column for business approval state
2025-12-06 23:26:14 -07:00
kelly
111ef20684 fix: correct migration ordering and remove missing bank_accounts FK
- Rename fixed assets migrations from 100xxx to 110xxx so they run after
  ap_vendors and gl_accounts tables (which they reference)
- Remove bank_account_id foreign key constraint from ar_payments since
  bank_accounts table doesn't exist (use unconstrained bigint like ap_payments)
2025-12-06 23:26:14 -07:00
kelly
85fdb71f92 fix: rename fixed assets migrations to run after businesses table
The fixed assets migrations were dated 2024-12-06 but the businesses table
is dated 2025-08-08, causing foreign key failures since the referenced
table didn't exist yet. Renamed to 2025-12-06 to ensure proper ordering.
2025-12-06 23:26:14 -07:00
kelly
08e2eb3ac6 style: fix pint formatting in journal entries migration 2025-12-06 23:26:14 -07:00
kelly
87e8384aca fix: make all accounting migrations idempotent
Add Schema::hasTable() guards to all create table migrations and
Schema::hasColumn() guards to alter table migrations to prevent
duplicate table/column errors when migrations are run multiple times.
2025-12-06 23:26:14 -07:00
kelly
e56ad20568 docs: add accounting QA checklist for Management Suite 2025-12-06 23:26:14 -07:00
kelly
fafb05e29b test: add skip guards to accounting tests for schema/route readiness 2025-12-06 23:26:14 -07:00
kelly
a322d7609b test: add accounting feature tests and demo seeder
Adds 13 feature tests covering:
- AP flow (bills, payments, approvals)
- AR flow (invoices, payments, aging)
- Bank accounts and transfers
- Budgets and variance tracking
- Business scoping/isolation
- Expense reimbursement workflow
- Fixed assets and depreciation
- Management mutations
- Migration safety
- Notifications
- Recurring transactions
- Suite menu visibility

Also adds AccountingDemoSeeder for local development.
2025-12-06 23:26:14 -07:00
kelly
2aefba3619 style: fix pint formatting 2025-12-06 23:26:14 -07:00
kelly
b47fc35857 feat: expand Management Suite with AR, budgets, expenses, fixed assets, recurring schedules, and CFO dashboard
Adds comprehensive financial management capabilities:
- Accounts Receivable (AR) with customers, invoices, payments
- Budget management with lines and variance tracking
- Expense tracking with approval workflow
- Fixed asset management with depreciation
- Recurring transaction schedules (AP, AR, journal entries)
- CFO dashboard with financial KPIs
- Cash flow forecasting
- Directory views for customers and vendors
- Division filtering support across all modules
2025-12-06 23:26:14 -07:00
kelly
e5e1dea055 feat: add Management Suite routes for AP, Finance, and Accounting 2025-12-06 23:25:37 -07:00
kelly
e5e485d636 feat: add Management Suite accounting module
- Add GL accounts, AP vendors, bills, payments, and journal entries
- Add financial reporting (P&L, Balance Sheet, Cash Flow)
- Add finance dashboards (AP Aging, Cash Forecast, Division Rollup)
- Fix PostgreSQL compatibility in JournalEntry::generateEntryNumber()
- All migrations are additive (new tables only, no destructive changes)
2025-12-06 23:25:37 -07:00
kelly
3d383e0490 Merge pull request 'fix: disable seeders in dev/staging K8s init container' (#139) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/139
2025-12-07 02:19:33 +00:00
kelly
df188e21ce style: fix pint formatting 2025-12-06 19:12:28 -07:00
kelly
55016f7009 feat: add brands:sync-media-paths command and configure MinIO for dev
- Add artisan command to sync brand logo_path/banner_path from MinIO storage
- Configure dev K8s overlay with MinIO environment variables
- Command supports --dry-run and --business options
2025-12-06 19:02:52 -07:00
kelly
9cf89c7b1a fix: disable seeders in dev/staging K8s init container
DO NOT re-enable. This is staging - we do not reseed ever again.
Migrations only from now on.
2025-12-06 18:10:07 -07:00
kelly
0d810dff27 Merge pull request 'fix: remove post-deploy seeding steps from CI' (#138) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/138
2025-12-07 00:03:25 +00:00
kelly
624a36d2c5 fix: remove post-deploy seeding steps from CI
The db:restore-cannabrands and SuitesSeeder commands were running
on every deploy, which is unnecessary and was causing OOM kills
(exit code 137). Migrations handle schema changes; seeding should
only run manually when needed.
2025-12-06 16:54:15 -07:00
kelly
92e3e171e1 Merge pull request 'fix: ensure brands array serializes correctly when empty' (#137) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/137
2025-12-06 23:41:56 +00:00
kelly
58ca83c8c2 fix: ensure brands array serializes correctly when empty
Added ->values() to prevent empty Collection from serializing as {} instead of []
This fixes Alpine.js error when all brands have null hashids
2025-12-06 16:26:58 -07:00
kelly
7f175709a5 Merge pull request 'fix: consolidated fixes - suites, brand portal, login, hashid backfills' (#136) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/136
2025-12-06 23:06:25 +00:00
kelly
26a903bdd9 Merge branch 'fix/login-bugs' into fix/brand-user-assignments
# Conflicts:
#	resources/views/admin/quick-switch.blade.php
#	routes/seller.php
2025-12-06 16:01:22 -07:00
kelly
e871426817 Merge branch 'fix/hashid-backfills' into fix/brand-user-assignments
# Conflicts:
#	.woodpecker/.ci.yml
2025-12-06 16:00:49 -07:00
kelly
c99511d696 fix: suite sidebar, brand portal access, and seeder corrections
- Fix SuiteMenuResolver to use correct route names for Management Suite
- Add Brand Manager mode support to SuiteMenuResolver
- Update sidebar layout to conditionally use suite-based navigation
- Fix EnsureBrandPortalAccess middleware to allow Brand Manager users
- Refactor BrandPortalController with validateAccessAndGetBrandIds helper
- Fix DepartmentSeeder to create organizational departments (not product categories)
- Fix MinioMediaSeeder to not set department_id on products
- Remove Settings from all suite sidebar menus (accessible via user dropdown only)
2025-12-06 15:38:44 -07:00
kelly
963f00cd39 fix: add confirmation prompt to dev:setup --fresh to prevent accidental data loss
Development data is being preserved for production release. The --fresh
flag now requires explicit confirmation before dropping all tables.
2025-12-06 09:50:46 -07:00
kelly
0db70220c7 fix: add migration to backfill brand hashids after data import
Previous migration ran before data was imported from backup,
so brands created in production were missing hashids. This
migration ensures all brands have hashids for URL routing.
2025-12-06 09:49:01 -07:00
kelly
4bcd0cca8a Merge pull request 'feat: implement brand settings page with full CRUD functionality' (#133) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/133
2025-12-06 03:39:20 +00:00
kelly
6c7d7016c9 feat: implement brand settings page with full CRUD functionality
- Add BrandSettingsController for managing brands in seller settings
- Create brands-edit.blade.php for editing individual brand details
- Update brands.blade.php with full brand list, actions, and drag-and-drop reorder
- Add routes for brand CRUD operations under /s/{business}/settings/brands
- Support brand logo upload/removal, status toggles (active, sales enabled, public, featured)
- Implement team access management for assigning internal users to brands
- Add migration to make products.department_id nullable (was incorrectly required)
- Update department tests to reflect nullable department_id behavior
- Various view fixes for CRM, inventory, and messaging pages
2025-12-05 20:31:53 -07:00
kelly
6d92f37ea7 Merge pull request 'fix: brand user assignments and CRM thread $business variable' (#130) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/130
2025-12-06 02:11:36 +00:00
kelly
318d6b4fe8 fix: pass $business to CRM thread views 2025-12-05 19:04:17 -07:00
kelly
9ea69447ec fix: assign all Cannabrands brands to crystal user
Update brand_user.sql to assign all 18 Cannabrands brands to crystal@cannabrands.com (user_id=7) so they appear in the brand switcher dropdown.

Previously only 2 brands (Doobz, Thunder Bud) were assigned, limiting the dropdown display for non-owner users.
2025-12-05 19:02:57 -07:00
kelly
a24fbaac9a Merge pull request 'fix: stabilize CI pod selection for post-deploy commands' (#129) from fix/ci-pod-stability into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/129
2025-12-06 01:36:09 +00:00
kelly
412a3beeed Merge pull request 'fix: update CRM views to use business-scoped route patterns' (#128) from fix/crm-route-patterns into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/128
2025-12-06 01:36:00 +00:00
kelly
4e7f344941 fix: reorder crm_pipelines migration to run before crm_deals 2025-12-05 18:31:19 -07:00
kelly
d0e9369795 fix: stabilize CI pod selection for post-deploy commands
- Add 15 second delay after rollout completes
- Target only Running pods with --field-selector
- Log which pod is being used for exec
2025-12-05 18:24:59 -07:00
kelly
8f56f32e62 fix: update CRM views to use business-scoped route patterns
- Update 18 CRM view files from seller.crm.* to seller.business.crm.* routes
- Add $business parameter to all route calls for proper business scoping
- Add pipeline_id column migration for crm_deals table

The Premium CRM is part of the Sales Suite and uses business-scoped routes
(seller.business.crm.*) per the architecture docs. The old seller.crm.*
pattern was causing route-not-found errors.
2025-12-05 18:13:29 -07:00
kelly
b8d307200b Merge pull request 'fix: stock page actions, brands cleanup, and task controller' (#127) from fix/stock-actions-and-brands-cleanup into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/127
2025-12-06 01:12:36 +00:00
kelly
4e979c3158 fix: stock page actions, brands cleanup, and task controller
- Add standard view/edit/delete actions to stock page (inventory-index)
- All products now have hamburger menu with View, Edit, Delete
- Fix brands.sql: all brands now assigned to Cannabrands (business_id=2)
- Add UPDATE statement to ensure brands get fixed on existing deploys
- Remove 56 products with old/invalid SKU prefixes from products.sql
- Fix TaskController to pass $counts instead of $stats to view
2025-12-05 18:04:46 -07:00
kelly
085ca6c415 ci: run SuitesSeeder before DevSuitesSeeder on deploy
SuitesSeeder creates the Suite records in the database.
DevSuitesSeeder assigns suites to businesses.
Must run in this order or suite assignment fails.
2025-12-05 17:50:04 -07:00
kelly
1d363d7157 fix: backfill brand hashids and add defensive filter
- Migration to generate hashids for brands without them
- Skip brands without hashid in brand-switcher component
2025-12-05 17:43:47 -07:00
kelly
71effd6f4c Merge pull request 'fix: Buyer CRM namespace fixes and route corrections' (#126) from fix/buyer-crm-namespaces into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/126
2025-12-06 00:21:18 +00:00
kelly
2198008b4c fix: buyer CRM namespace fixes and premium CRM enhancements
- Fix Buyer CRM controller namespaces and model references
- Add CRM pipelines and calendar migrations
- Enhance PermissionService with additional checks
- Update DevSeeder with improved data
- Fix brand switcher sidebar and views
2025-12-05 17:14:45 -07:00
kelly
2320511cd3 fix: CRM layout routes and MessagingController delegation
- Fix all CRM sidebar routes to use seller.business.crm.* pattern
- Add $business parameter to all route() calls in CRM layout
- Fix MessagingController to delegate to ThreadController instead of
  non-existent InboxController when CRM is enabled

This preserves the premium CRM features (AI replies, SLA tracking,
collision detection, etc.) by correctly delegating to ThreadController
when business has CRM access.
2025-12-05 17:13:21 -07:00
kelly
6124e8fa07 fix: skip products without hashid in product listings
Defensive filter to prevent UrlGenerationException while waiting
for hashid backfill migration to run on production.
2025-12-05 17:08:20 -07:00
kelly
23195d1887 fix: backfill hashids for products migrated from MySQL
Products from the MySQL migration were missing hashids, causing
UrlGenerationException when generating edit URLs. This migration
generates unique NNLLN-format hashids for all products without one.
2025-12-05 17:06:34 -07:00
kelly
d9e99b3091 ci: seed suites for Cannabrands on dev deploy 2025-12-05 17:03:02 -07:00
kelly
e774093e94 fix: migrate CRM calendar views to premium CRM layout
- Updated calendar/index.blade.php to use layouts.app-with-sidebar
- Updated calendar/connections.blade.php to use layouts.app-with-sidebar
- Fixed route names from seller.crm.* to seller.business.crm.*
- Added $business to controller compact() for route generation
- Added code comments explaining premium CRM architecture
2025-12-05 16:59:29 -07:00
kelly
697ba5f0f4 fix: CRM account routes use slug instead of id
- Fixed account sub-pages (tasks, activity, contacts, opportunities, orders) to use $account->slug instead of $account->id in route parameters
- Routes expect {account:slug} binding, was causing 404 errors when navigating between account pages
- Added critical note to CLAUDE.md about Suites architecture vs legacy Modules system
2025-12-05 16:54:34 -07:00
kelly
ef043bda0c ci: run db:restore-cannabrands on dev deploy to restore seed data 2025-12-05 16:48:57 -07:00
kelly
0f419075cd fix: properly extract multi-line INSERT statements from SQL dumps
Fixes brands.sql and products.sql which had multi-line INSERT statements
(brand descriptions with embedded newlines) that were broken when using
simple grep extraction. Now uses awk to properly capture complete INSERT
statements from start to ON CONFLICT DO NOTHING.
2025-12-05 16:43:25 -07:00
kelly
9b3bb1d93b fix: restore Cannabrands data dumps from before accidental data loss
Commit 17b0f65 accidentally wiped data from SQL dumps when re-exporting
from an empty local database. This restores the full data including:

- order_items.sql: 780 records (was 0)
- products.sql: 980 records (was 276)
- invoices.sql: 289 records (was 282)
- orders.sql: 290 records (was 282)
- brands.sql: 18 records (was 11)
- All other dump files restored to pre-loss state
2025-12-05 16:37:06 -07:00
kelly
8b4f6a48ad Merge pull request 'fix: CRM controller fixes and missing migrations' (#125) from fix/buyer-crm-namespaces into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/125
2025-12-05 23:31:05 +00:00
kelly
f5d537cb67 fix: CRM controller column mismatches and permission service type error
- Fix TaskController to use correct column names (seller_business_id,
  completed_at, due_at, details) matching the CrmTask model
- Fix CrmCalendarController to use correct column names (start_at/end_at,
  calendar_connection_id, all_day, booker_name/booker_email)
- Add normalizePermissions() helper to PermissionService to handle JSON
  string permissions from pivot tables
- Add crm_calendar_tables migration for calendar connections, synced
  events, meeting links, and meeting bookings
- Filter out brands without hashid in blade templates to prevent route
  generation errors
2025-12-05 16:24:15 -07:00
kelly
fad91c5d7d fix: additional CRM namespace fixes and add crm_pipelines migration
- Fix Modules\Crm\Entities references in Buyer models and Services:
  - BuyerInvoiceRecord.php: CrmInvoice
  - BuyerQuoteApproval.php: CrmQuote
  - CrmAutomationService.php: CrmTeamRole
  - CrmAiService.php: CrmProductInterest
  - CrmCalendarController.php: CrmMeetingBooking

- Add missing crm_pipelines table migration for CRM Premium deals feature

This fixes the "relation crm_pipelines does not exist" error when accessing
/s/{business}/crm/deals
2025-12-05 16:14:08 -07:00
kelly
7e2b3d4ce6 fix: update Buyer CRM controllers to use App\Models\Crm namespace
The Buyer CRM controllers were using incorrect namespace imports from
Modules\Crm\Entities\* which doesn't exist. Updated all controllers to
use the correct App\Models\Crm\* namespace:

- DashboardController: CrmInvoice, CrmQuote, CrmThread
- InboxController: CrmThread
- InvoiceController: CrmInvoice, CrmThread
- QuoteController: CrmQuote
- BrandHubController: CrmThread
- MessageController: CrmChannelMessage, CrmThread
- OrderController: CrmThread (inline)

Also changed CrmMessage to CrmChannelMessage as CrmMessage doesn't exist.
2025-12-05 16:09:48 -07:00
kelly
918d2a3a95 Merge pull request 'fix: brand switcher null hashid and migration idempotency' (#124) from fix/brand-switcher-null-hashid into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/124
2025-12-05 23:00:29 +00:00
kelly
bff2199cb6 fix: add idempotency checks to migrations
Add Schema::hasTable/hasColumn checks to prevent duplicate table/column
errors during parallel test execution or repeated migrations.
2025-12-05 15:49:31 -07:00
kelly
8b32be2c19 fix: skip brands without hashid in brand switcher
Prevents UrlGenerationException when brands have null hashids
before the backfill migration runs.
2025-12-05 15:40:43 -07:00
kelly
9ee02b6115 Merge pull request 'feat: add cannabrands team users to DevSeeder' (#123) from feature/cannabrands-seeder-users into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/123
2025-12-05 22:24:26 +00:00
kelly
7c1ff57eb1 feat: add Cannabrands team users to DevSeeder
Add 5 @cannabrands.com user accounts and link them to cannabrands business:
- jared@cannabrands.com
- crystal@cannabrands.com
- kelly@cannabrands.com
- jon@cannabrands.com
- vinny@cannabrands.com

Also:
- Removes seller@example.com link from Cannabrands
- Removes Desert Bloom location creation
- Ensures dba_name is null for Cannabrands
2025-12-05 15:09:46 -07:00
kelly
67c663faf4 Merge pull request 'feat: add Cannabrands team users to DevSeeder' (#122) from feature/cannabrands-seeder-users into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/122
2025-12-05 21:46:30 +00:00
kelly
691aeda2c2 feat: add Cannabrands team users to DevSeeder
Add 5 @cannabrands.com user accounts to the seeder for linking
contacts to platform users:
- jared@cannabrands.com
- crystal@cannabrands.com
- kelly@cannabrands.com
- jon@cannabrands.com
- vinny@cannabrands.com

Also ensures dba_name is null for Cannabrands business.
2025-12-05 14:33:40 -07:00
kelly
0e4e7784d3 Merge pull request 'feat: show business contacts without platform access in Users tab' (#121) from feat/show-contacts-in-users-tab into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/121
2025-12-05 20:25:38 +00:00
kelly
315a206542 feat: show business contacts without platform access in Users tab
Display contacts that have emails but no user accounts at the top of
the Users & Access tab, making it easier to identify who needs to be
converted to a platform user.
2025-12-05 13:07:01 -07:00
kelly
d1ff2e8221 Merge pull request 'fix: backfill null hashids for brands' (#120) from fix/backfill-brand-hashids into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/120
2025-12-05 19:46:14 +00:00
kelly
a2184e2de2 fix: backfill null hashids for brands
Adds migration to generate hashids for any brands missing them.
This fixes the "Missing parameter: brand" error on seller dashboard.
2025-12-05 12:35:17 -07:00
kelly
cf4a77c72a Merge pull request 'feat: show git commit hash in admin sidebar' (#119) from feat/admin-version-badge into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/119
2025-12-05 19:19:35 +00:00
kelly
85d0ca2369 feat: show git commit hash in admin sidebar
Display the deployment version (git sha) under "Cannabrands Hub" in
the admin panel sidebar for easier deployment verification.
2025-12-05 12:13:30 -07:00
kelly
61fd09f6a8 Merge pull request 'fix: BusinessConfigSeeder creates missing parent businesses' (#118) from fix/business-config-seeder-create-parents into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/118
2025-12-05 17:17:08 +00:00
kelly
ed20135cbe fix: BusinessConfigSeeder creates missing parent businesses
The seeder now:
- First pass: Creates any parent businesses referenced by parent_slug
- Re-caches business IDs after creations
- Creates businesses with meaningful config if they don't exist

This fixes the issue where Canopy (parent) wasn't being created,
causing Cannabrands, Curagreen, and Leopard AZ to have no parent_id.
2025-12-05 10:03:27 -07:00
kelly
e6f33d4fa9 Merge pull request 'fix: use Filament Actions Action for table actions' (#117) from fix/orchestrator-action-class into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/117
2025-12-05 16:51:15 +00:00
kelly
66da7b5a7a fix: use Filament\Actions\Action for table actions
Filament v4 uses Filament\Actions\Action for both page and table
actions. The Filament\Tables\Actions\Action class doesn't exist.
2025-12-05 09:44:33 -07:00
kelly
5dfef28a20 Merge pull request 'fix: add parent company relationships to BusinessConfigSeeder' (#116) from fix/business-config-parent-relationships into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/116
2025-12-05 15:25:24 +00:00
kelly
2e1eda8c5d fix: use correct Action class for Orchestrator table actions
Change table actions from Filament\Actions\Action (page actions) to
Filament\Tables\Actions\Action (table row actions) to fix the broken
layout on Head of Sales and Head of Marketing Orchestrator pages.
2025-12-05 08:20:12 -07:00
kelly
58e35dc78e fix: add parent company relationships to BusinessConfigSeeder
- Cannabrands, Leopard AZ, Curagreen → parent: Canopy AZ
- Add parent_slug support to seeder config and run() method
2025-12-05 08:05:37 -07:00
kelly
43b49aafd7 Merge pull request 'fix: admin BusinessResource and sticky business config settings' (#115) from fix/seller-page-bugs-and-performance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/115
2025-12-05 14:48:25 +00:00
kelly
b265b407b1 fix: admin BusinessResource and update business config seeder
- Fix getOwnerRecord() -> getRecord() in BusinessResource repeaters
- Add business types support to BusinessConfigSeeder
- Remove deprecated has_manufacturing/has_processing_suite flags
- Update 4 businesses with correct types and suites:
  - Canopy: financial_management type, management suite
  - Leopard: processor/manufacturer/distributor types, processing/manufacturing/delivery suites
  - Curagreen: processor type, processing suite
  - Cannabrands: wholesaler type, no suites
2025-12-05 07:28:26 -07:00
kelly
4b71bbea6a fix: use getRecord() instead of getOwnerRecord() in BusinessResource
getOwnerRecord() only exists on Relation Manager pages. For Repeaters
in regular Edit pages, use getRecord() to access the parent model.
2025-12-05 07:04:26 -07:00
kelly
398cd41361 Merge pull request 'fix: idempotent dev deployment with Cannabrands data seeding' (#114) from fix/seller-page-bugs-and-performance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/114
2025-12-05 13:52:38 +00:00
kelly
17b0f65680 fix: improve data export/restore and update SQL dumps
- Fix multi-line INSERT handling in ExportCannabrandsData command
- Properly detect INSERT statement boundaries using ON CONFLICT DO NOTHING
- Update RestoreCannabrandsData to handle multi-line statements
- Refresh all SQL dump files with latest data
- Add dynamic labels for dispensary vs brand locations in BusinessResource
- Fix User model formatting
2025-12-05 06:11:01 -07:00
kelly
a4514f4985 fix: make dev deployment idempotent - stop resetting database on every deploy
- Change migrate:fresh --seed to migrate + db:seed
- Make ProductCategorySeeder idempotent (checks for existing records)
- Database now persists between deploys
- Seeders are safe to re-run (won't duplicate data)
2025-12-05 04:41:05 -07:00
kelly
3ba9ae86b4 feat: add CannabrandsDataSeeder to DatabaseSeeder for dev deployments
Automatically restores Cannabrands data from SQL dumps during db:seed.
This runs on dev/staging deploys via migrate:fresh --seed.
2025-12-05 04:38:35 -07:00
kelly
261f00043e feat: add PostgreSQL data dump/restore commands for dev deployment
- Add db:export-cannabrands command to export current data to SQL dumps
- Add db:restore-cannabrands command to restore data from dumps
- Add BrandsImportSeeder for MySQL brand imports
- Update import seeders to use name-based brand lookups (no hardcoded IDs)
- Include SQL dumps for 17 tables (businesses, users, products, orders, etc.)
- Add .gitignore exception for database/dumps/*.sql

This allows dev deployments to restore Cannabrands data without needing
a MySQL connection. After configuring settings locally, run:
  ./vendor/bin/sail artisan db:export-cannabrands

To restore on fresh deploy:
  ./vendor/bin/sail artisan db:restore-cannabrands
2025-12-05 04:26:47 -07:00
kelly
656ebd023b chore: trigger CI/CD rebuild 2025-12-05 03:06:49 -07:00
Jon
55ab18ee53 Merge pull request 'fix: seller page bugs and performance improvements' (#113) from fix/seller-page-bugs-and-performance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/113
2025-12-05 03:21:47 +00:00
Jon Leopard
391bd6546b fix: use DatabaseTransactions instead of RefreshDatabase in tests
RefreshDatabase causes PostgreSQL 'out of shared memory' errors
when running parallel tests. DatabaseTransactions is compatible
with parallel testing as it uses transactions per-connection.
2025-12-04 20:14:20 -07:00
kelly
ef5af08609 fix: admin business routing and add seller businesses to seeder
- Fix duplicate $recordRouteKeyName in BusinessResource
- Fix CreateBusiness redirect to use ID instead of slug
- Fix ListBusinesses URL generation to use ID instead of slug
- Disable CannabrandsSeeder sample product data
- Add real seller businesses to DevSeeder:
  - Cannabrands (cannabrands-owner@example.com)
  - Canopy AZ LLC (canopy@example.com)
  - Leopard AZ LLC (leopardaz@example.com)
  - Curagreen LLC (curagreen@example.com)
2025-12-04 20:02:41 -07:00
kelly
8f171c0784 chore: trigger CI 2025-12-04 19:35:58 -07:00
kelly
d8d2bc5fb1 fix: update seeders for new product type constraint 2025-12-04 19:34:59 -07:00
kelly
11c67f491c feat: MySQL data import and parallel test fixes
- Import Cannabrands data from MySQL to PostgreSQL (strains, categories,
  companies, locations, contacts, products, images, invoices)
- Make migrations idempotent for parallel test execution
- Add ParallelTesting setup for separate test databases per process
- Update product type constraint for imported data
- Keep MysqlImport seeders for reference (data already in PG)
2025-12-04 19:26:38 -07:00
kelly
f3b8281cf7 fix: seller page bugs and performance improvements
- Fix InvoiceController undefined $business variable in closure
- Fix CategoryController recursive eager loading for product categories
- Fix Product::getImageUrl() null hashid fallback for legacy products
- Fix Product N+1 queries in healthStatus() and issues() methods
- Add server-side pagination to products index (50 per page)
- Fix ProductFactory type values to match database constraint
- Add products() relationship to ProductCategory model
2025-12-04 19:00:24 -07:00
kelly
8ec47836d7 Merge pull request 'feat: add BusinessConfigSeeder to deploy pipeline for sticky settings' (#112) from fix/business-settings-persistence into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/112
2025-12-04 07:54:57 +00:00
kelly
e4205cbc77 feat: add BusinessConfigSeeder to deploy pipeline for sticky settings
- Add BusinessConfigSeeder call to DatabaseSeeder (runs on every deploy)
- Update BusinessConfigSeeder with current business configurations
- Document Gitea API usage in CLAUDE.md

Business settings (suites, enterprise status, limits) will now persist
across deployments. Export new settings with:
php artisan export:business-config-seeder
2025-12-04 00:48:26 -07:00
kelly
8f6701fb9c Merge pull request 'fix(ci): disable buildx provenance to fix Gitea registry 500 errors' (#111) from fix/ci-buildx-provenance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/111
2025-12-04 07:34:21 +00:00
kelly
648d9d56ab fix(ci): disable buildx provenance to fix Gitea registry 500 errors
Docker buildx creates SLSA provenance attestations by default which
can cause 500 errors when pushing to Gitea's container registry.

Setting provenance: false disables these attestations for all build
steps (dev, production, and release).
2025-12-04 00:29:55 -07:00
kelly
577dd6c369 Merge pull request 'fix: resolve BusinessResource admin URL generation errors' (#110) from fix/business-resource-admin-urls into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/110
2025-12-04 05:17:02 +00:00
kelly
6015195885 fix: resolve BusinessResource admin URL generation errors
Business model uses 'slug' as getRouteKeyName() for public routes,
but Filament admin panel needs 'id' for reliable record binding.

Changes:
- Add $recordRouteKeyName = 'id' to BusinessResource
- Add getRedirectUrl() override to CreateBusiness for post-creation redirect
- Add getResourceUrl() override to ListBusinesses for table row links
- Add auto-slug generation in Business::creating event
2025-12-03 21:19:07 -07:00
kelly
7522cadce5 Merge pull request 'fix: use business_user pivot for User queries' (#108) from fix/user-business-pivot-queries into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/108
2025-12-04 03:32:24 +00:00
kelly
af899f39ca Remove MysqlImport seeders - starting over 2025-12-03 19:54:50 -07:00
kelly
90b752cb8f fix: resolve test failures in CRM models and migrations
- Fix CrmInvoiceItem $fillable to match database schema (add name, sku, position)
- Fix CrmInvoice items() relationship to use position instead of sort_order
- Remove SoftDeletes from CrmInvoicePayment (table lacks deleted_at column)
- Add Schema::hasColumn guards to 6 hashid migrations for idempotent runs
- Fix InvoiceServiceTest to create proper test data with user/business owner
- Update CrmFeatureTest to match suite:sales middleware behavior (abort 403)
- Fix remaining controller user->business queries to use pivot table
- Delete duplicate migration files for permission_audit_logs and view_as_sessions
2025-12-03 19:51:00 -07:00
kelly
3f049b505b Merge pull request 'perf(ci): skip tests on merge (already passed on PR)' (#109) from fix/skip-tests-on-merge into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/109
2025-12-04 01:16:36 +00:00
Jon Leopard
daf9ec9134 perf(ci): skip tests on merge (already passed on PR)
Tests now only run on pull_request events. When PRs are merged to
develop/master, the pipeline skips directly to build+deploy since
branch protection ensures tests already passed.
2025-12-03 18:04:58 -07:00
kelly
ee757761e3 docs: add PR requirement and User-Business pivot table rules 2025-12-03 17:43:42 -07:00
Jon Leopard
010e1f9259 perf(ci): improve Docker build caching with cache_from/cache_to 2025-12-03 17:40:09 -07:00
kelly
154ecfb507 fix: use business_user pivot for User queries
Users don't have a business_id column - they're related to businesses
through the business_user pivot table.

Changed from broken pattern:
- User::where('business_id', $id)

To correct pattern:
- User::whereHas('businesses', fn($q) => $q->where('businesses.id', $id))

Also fixed DealController::create() which still had $request->user()->business

Files fixed:
- ThreadController.php (team members dropdown)
- DealController.php (team members dropdown + create method)
- CrmChannelService.php (buyer notifications)
- CrmInternalNote.php (mention resolution)
- SendCrmDailyDigest.php (recipient lookup)
- GenerateBriefingsCommand.php (user lookup x3)
2025-12-03 17:29:08 -07:00
Jon Leopard
97a41afed1 fix(ci): prevent php-lint grep from failing on success 2025-12-03 17:11:31 -07:00
Jon Leopard
3088d05825 fix(ci): use kirschbaumdevelopment/laravel-test-runner for all steps
- Remove custom CI image (unnecessary - kirschbaumdevelopment already has all extensions)
- Update all steps to use kirschbaumdevelopment/laravel-test-runner:8.3
- Update workflow to 2-env (dev + production, no staging)
- Add production deploy step for master branch
2025-12-03 17:07:24 -07:00
kelly
93648ed001 Merge pull request 'fix: Route model binding fixes for 17 controllers' (#106) from fixes/first-dev-rollout into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/106
2025-12-03 22:41:30 +00:00
kelly
88b201222f fix: use route model binding for Business in 16 controllers
Fixes "Attempt to read property 'id' on null" errors caused by
$request->user()->business pattern (User model has no business attribute).

Controllers fixed:
- Seller CRM: ThreadController, AutomationController, CrmCalendarController,
  CrmDashboardController, CrmSettingsController, DealController, InvoiceController,
  MeetingLinkController, QuoteController
- Seller Operations: DeliveryController, DeliveryWindowController, WashReportController
- Seller Marketing: CampaignController, ChannelController, TemplateController
- Buyer CRM: SettingsController

Changed from broken patterns:
- $request->user()->business
- Auth::user()->business
- currentBusiness()

To proper route model binding:
- Business $business (from {business} URL segment)

Note: FulfillmentWorkOrderController intentionally uses legacy pattern
($request->user()->businesses()->first()) for routes without {business} segment.
2025-12-03 15:19:23 -07:00
kelly
de402c03d5 Merge pull request 'feat: add dev environment seeders and fixes' (#105) from feature/dev-environment-seeders into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/105
2025-12-03 20:02:28 +00:00
kelly
b92ba4b86d feat: add dev environment seeders and fixes
- Add DevCleanupSeeder to remove non-Thunder Bud products (keeps only TB- prefix)
- Add DevMediaSyncSeeder to update brand/product media paths from MinIO
- Fix CustomerController to pass $benefits array to feature-disabled view
- Update BrandSeeder to include Twisties brand
- Make vite.config.js read VITE_PORT from env (fixes port conflict)

Run on dev.cannabrands.app:
  php artisan db:seed --class=DevCleanupSeeder
  php artisan db:seed --class=DevMediaSyncSeeder
2025-12-03 12:47:33 -07:00
Jon
f8f219f00b Merge pull request 'feat: consolidate suites to 7 active and update user management UI' (#104) from feature/suite-consolidation-and-user-permissions into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/104
2025-12-03 18:06:07 +00:00
kelly
f16dac012d feat: consolidate suites to 7 active and update user management UI
- Consolidate from 14+ suites to 7 active suites (sales, processing,
  manufacturing, delivery, management, brand_manager, dispensary)
- Mark 9 legacy suites as deprecated (is_active=false)
- Update DepartmentSuitePermission with granular permissions per suite
- Update DevSuitesSeeder with correct business→suite assignments
- Rebuild Settings > Users page with new role system (owner/admin/manager/member)
- Add department assignment UI with department roles (operator/lead/supervisor/manager)
- Add suite-based permission overrides with tabbed UI
- Move SUITES_AND_PRICING_MODEL.md to docs root
- Add USER_MANAGEMENT.md documentation
2025-12-03 09:59:22 -07:00
Jon
f566b83cc6 Merge pull request 'fix: use /up health endpoint for K8s probes' (#103) from fix/health-probe-path into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/103
2025-12-03 08:07:26 +00:00
Jon Leopard
418da7a39e fix: use /up health endpoint for K8s probes
The root path (/) redirects to /register or /login when unauthenticated,
causing readiness/liveness probes to fail with 302 responses.

Laravel's /up health endpoint always returns 200 OK.
2025-12-03 01:00:52 -07:00
Jon
3c6fe92811 Merge pull request 'fix: force HTTPS scheme for asset URLs in non-local environments' (#102) from fix/force-https-scheme into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/102
2025-12-03 07:28:14 +00:00
Jon
7d3243b67e Merge branch 'develop' into fix/force-https-scheme 2025-12-03 07:20:30 +00:00
Jon Leopard
8f6597f428 fix: force HTTPS scheme for asset URLs in non-local environments
Filament v4 uses dynamic imports for components like tabs.js and select.js.
These use Laravel's asset() helper which doesn't automatically respect
X-Forwarded-Proto from TrustProxies middleware.

When SSL terminates at the K8s ingress, PHP sees HTTP requests, so asset()
generates HTTP URLs. The browser then blocks these as 'Mixed Content' when
the page is served over HTTPS.

URL::forceScheme('https') ensures all generated URLs use HTTPS in
development, staging, and production environments.
2025-12-03 00:17:04 -07:00
Jon
64d38b8b2f Merge pull request 'fix: add asset_url config key for ASSET_URL env var to work' (#101) from fix/filament-mixed-content-asset-url into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/101
2025-12-03 06:55:35 +00:00
Jon Leopard
7aa366eda9 fix: add asset_url config key for ASSET_URL env var to work
Laravel 11's minimal config/app.php doesn't include the asset_url
key by default. Without this, the ASSET_URL environment variable
is never read, causing asset() to not use the configured URL.
2025-12-02 23:43:52 -07:00
Jon
d7adaf0cba Merge pull request 'fix: add ASSET_URL to resolve Filament v4 mixed content errors' (#100) from fix/asset-url-mixed-content into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/100
2025-12-03 06:24:17 +00:00
kelly
4adc611e83 fix: Allow quick-switch access while impersonating
When impersonating a user, the quick-switch controller was checking if
the impersonated user could impersonate, which always failed. Now it
checks if the impersonator (admin) can impersonate.

Also improved the switch() method to handle switching between
impersonated users in the same tab by leaving the current impersonation
before starting a new one.

Fixes 403 error when accessing /admin/quick-switch while impersonating.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:53:30 -07:00
kelly
3c88bbfb4d feat: Open quick-switch users in new tab
- Add target="_blank" to quick-switch links to open in new tab
- Update tips to reflect multi-tab testing capability
- Enables testing multiple impersonated users simultaneously

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:44:37 -07:00
kelly
3496421264 fix: Allow impersonated users to access business routes
When an admin impersonates a user via quick-switch, they need to access
the business routes for that user. The route model binding for 'business'
was blocking access because it checked if auth()->user()->businesses
contains the business, which fails during impersonation.

Now checks if user isImpersonated() and bypasses the business ownership
check, allowing admins to view any business when impersonating.

Fixes 403 error when accessing /s/{business}/dashboard while impersonating.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:41:30 -07:00
kelly
91f1ae217a feat: Use impersonation for quick-switch to enable multi-tab testing
Changed quick-switch to use the lab404/impersonate library instead of
session replacement. This allows admins to:

- Stay logged in as admin while impersonating users
- Impersonate multiple different users in separate browser tabs
- Easily switch between admin view and user views
- Test multi-user scenarios without logging in/out

Benefits:
 Admin session is maintained while impersonating
 Multiple tabs can show different impersonated users simultaneously
 Proper impersonation tracking and security
 Easy to leave impersonation with backToAdmin()

Changes:
- QuickSwitchController::switch() now uses ImpersonateManager::take()
- QuickSwitchController::backToAdmin() now uses ImpersonateManager::leave()
- Removed manual session manipulation (admin_user_id, quick_switch_mode)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:22:44 -07:00
kelly
5b7a2dd7bf fix: Enable admin quick-switch by registering custom auth middleware
Fixed issue where admin users couldn't access quick-switch functionality
after logging in through Filament.

Changes:
- Registered custom Authenticate middleware in bootstrap/app.php so our
  custom authentication logic is used instead of Laravel's default
- Added bidirectional guard auto-login:
  - Admins on web guard auto-login to admin guard (for Filament access)
  - Admins on admin guard auto-login to web guard (for quick-switch)
- Improved redirectTo() to use str_starts_with() for more reliable path matching

This fixes:
 /admin/quick-switch redirects to /admin/login when unauthenticated
 Admins logged into Filament can access quick-switch without re-login
 Cross-guard authentication works seamlessly for admin users

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:00:55 -07:00
kelly
c991d3f141 fix: Redirect /admin to /admin/login instead of /login
When unauthenticated users access /admin, they should be redirected to
the Filament admin login page at /admin/login, not the unified buyer/
seller login at /login.

Changed FilamentAdminAuthenticate middleware to explicitly redirect to
the Filament admin login route instead of throwing AuthenticationException,
which was being caught by Laravel and redirecting to the default /login route.

Also removed unused AuthenticationException import.

Tested:
-  /admin redirects to /admin/login when unauthenticated
-  /login shows unified login for buyers/sellers
-  /admin/login shows Filament admin login

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 14:52:53 -07:00
492 changed files with 62780 additions and 3899 deletions

2
.gitignore vendored
View File

@@ -37,6 +37,7 @@ yarn-error.log
*.gz
*.sql.gz
*.sql
!database/dumps/*.sql
# Version files (generated at build time or locally)
version.txt
@@ -81,3 +82,4 @@ SESSION_*
# AI workflow personal context files
CLAUDE.local.md
claude.*.md
cannabrands_dev_backup.dump

View File

@@ -1,10 +1,15 @@
# Woodpecker CI/CD Pipeline for Cannabrands Hub
# Documentation: https://woodpecker-ci.org/docs/intro
#
# 3-Environment Workflow:
# - develop branch → dev.cannabrands.app (unstable, daily integration)
# - master branch → staging.cannabrands.app (stable, pre-production)
# - tags (2025.X) → cannabrands.app (production releases)
# 2-Environment Workflow (Optimized for small team):
# - develop branch → dev.cannabrands.app (integration/testing)
# - master branch → cannabrands.app (production)
# - tags (2025.X) → cannabrands.app (versioned production releases)
#
# Pipeline Strategy:
# - PRs: Run tests (lint, style, phpunit)
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
# - Tags: Build versioned release
when:
- branch: [develop, master]
@@ -26,18 +31,10 @@ steps:
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# Install dependencies
# Install dependencies (uses pre-built Laravel image with all extensions)
composer-install:
image: php:8.3-cli
image: kirschbaumdevelopment/laravel-test-runner:8.3
commands:
- echo "Installing system dependencies..."
- apt-get update -qq
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
- echo "Installing PHP extensions..."
- docker-php-ext-configure gd --with-freetype --with-jpeg
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
- echo "Installing Composer..."
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- echo "Creating minimal .env for package discovery..."
- |
cat > .env << 'EOF'
@@ -59,13 +56,12 @@ steps:
- |
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
echo "✅ Restored vendor from cache"
echo "Verifying cached dependencies are up to date..."
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
else
echo "📦 Installing fresh dependencies (cache miss)"
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
fi
- echo "Composer dependencies ready!"
- echo "Composer dependencies ready!"
# Rebuild Composer cache
rebuild-composer-cache:
@@ -80,29 +76,35 @@ steps:
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# PHP Syntax Check (runs after composer install so traits/classes are available)
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
php-lint:
image: php:8.3-cli
image: kirschbaumdevelopment/laravel-test-runner:8.3
commands:
- echo "Checking PHP syntax..."
- find app -name "*.php" -exec php -l {} \;
- find routes -name "*.php" -exec php -l {} \;
- find database -name "*.php" -exec php -l {} \;
- echo "PHP syntax check complete!"
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- echo "PHP syntax check complete!"
when:
event: pull_request
# Run Laravel Pint (Code Style)
# Run Laravel Pint (PRs only - skipped on merge since tests already passed)
code-style:
image: php:8.3-cli
image: kirschbaumdevelopment/laravel-test-runner:8.3
commands:
- echo "Checking code style with Laravel Pint..."
- ./vendor/bin/pint --test
- echo "Code style check complete!"
- echo "Code style check complete!"
when:
event: pull_request
# Run PHPUnit Tests
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
# Note: Uses array cache/session for speed and isolation (Laravel convention)
# Redis + Reverb services used for real-time broadcasting tests
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
when:
event: pull_request
environment:
APP_ENV: testing
BROADCAST_CONNECTION: reverb
@@ -183,9 +185,12 @@ steps:
VITE_REVERB_HOST: "dev.cannabrands.app"
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: "https"
cache_images:
cache_from:
- code.cannabrands.app/cannabrands/hub:buildcache-dev
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-dev
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
when:
branch: develop
event: push
@@ -228,8 +233,8 @@ steps:
event: push
status: success
# Build and push Docker image for STAGING environment (master branch)
build-image-staging:
# Build and push Docker image for PRODUCTION (master branch)
build-image-production:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
@@ -239,21 +244,53 @@ steps:
password:
from_secret: gitea_token
tags:
- staging # Latest staging build → staging.cannabrands.app
- latest # Latest production build
- prod-${CI_COMMIT_SHA:0:7} # Unique prod tag with SHA
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (master)
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "staging"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-staging
APP_VERSION: "production"
cache_from:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
when:
branch: master
event: push
status: success
# Build and push Docker image for PRODUCTION (tagged releases)
# Deploy to production (master branch)
deploy-production:
image: bitnami/kubectl:latest
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_prod
commands:
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
- echo "Commit SHA ${CI_COMMIT_SHA:0:7}"
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
-n cannabrands-prod
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
- |
echo ""
echo "✅ PRODUCTION deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
when:
branch: master
event: push
status: success
# Build and push Docker image for tagged releases (optional versioned releases)
build-image-release:
image: woodpeckerci/plugin-docker-buildx
settings:
@@ -272,6 +309,8 @@ steps:
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
when:
event: tag
status: success
@@ -306,25 +345,10 @@ steps:
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🧪 STAGING BUILD COMPLETE"
echo "🚀 PRODUCTION DEPLOYED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: master"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo "Tags:"
echo " - staging"
echo " - sha-${CI_COMMIT_SHA:0:7}"
echo " - ${CI_COMMIT_BRANCH}"
echo ""
echo "📦 Deploy to STAGING (staging.cannabrands.app):"
echo " docker pull code.cannabrands.app/cannabrands/hub:staging"
echo " docker-compose -f docker-compose.staging.yml up -d"
echo ""
echo "👥 Next steps:"
echo " 1. Super-admin tests on staging.cannabrands.app"
echo " 2. Validate all features work"
echo " 3. When ready, create production tag:"
echo " git tag -a 2025.10.1 -m 'Release 2025.10.1'"
echo " git push origin 2025.10.1"
echo "Site: https://cannabrands.app"
echo "Image: prod-${CI_COMMIT_SHA:0:7}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
echo ""
@@ -349,10 +373,14 @@ steps:
echo " - Login: admin@example.com / password"
echo " - Check: https://dev.cannabrands.app/telescope"
echo ""
echo "👥 Next steps:"
echo " 1. Verify feature works on dev.cannabrands.app"
echo " 2. When stable, merge to master for staging:"
echo " git checkout master && git merge develop && git push"
echo "Ready for production? Open PR: develop → master"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "pull_request" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ PR CHECKS PASSED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Ready to merge to master for production deployment."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi

View File

@@ -54,7 +54,36 @@ ALL routes need auth + user type middleware except public pages
❌ No IF/ELSE logic in migrations (not supported)
✅ Use Laravel Schema builder or conditional PHP code
### 7. Styling - DaisyUI/Tailwind Only
### 7. Git Workflow - ALWAYS Use PRs
**NEVER** push directly to `develop` or `master`
**NEVER** bypass pull requests
**NEVER** use GitHub CLI (`gh`) - we use Gitea
**ALWAYS** create a feature branch and PR for review
**ALWAYS** use Gitea API for PR creation (see below)
**Why:** PRs are required for code review, CI checks, and audit trail
**Creating PRs via Gitea API:**
```bash
# Requires GITEA_TOKEN environment variable
curl -X POST "https://code.cannabrands.app/api/v1/repos/Cannabrands/hub/pulls" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
```
**Gitea Services:**
- **Gitea:** `https://code.cannabrands.app`
- **Woodpecker CI:** `https://ci.cannabrands.app`
### 8. User-Business Relationship (Pivot Table)
Users connect to businesses via `business_user` pivot table (many-to-many).
**Wrong:** `User::where('business_id', $id)` — users table has NO business_id column
**Right:** `User::whereHas('businesses', fn($q) => $q->where('businesses.id', $id))`
**Pivot table columns:** `business_id`, `user_id`, `role`, `role_template`, `is_primary`, `permissions`
**Why:** Allows users to belong to multiple businesses with different roles per business
### 9. Styling - DaisyUI/Tailwind Only
**NEVER use inline `style=""` attributes** in Blade templates
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
**Why:** Consistency, maintainability, theme switching, and better performance
@@ -67,7 +96,29 @@ ALL routes need auth + user type middleware except public pages
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
### 8. Media Storage - MinIO Architecture (CRITICAL!)
### 10. Suites Architecture - NOT Modules (CRITICAL!)
**NEVER use** `has_crm`, `has_marketing`, or other legacy module flags
**NEVER create** routes like `seller.crm.*` (without `.business.`)
**NEVER extend** `seller.crm.layouts.crm` layout (outdated CRM module layout)
**ALWAYS use** Suites system (Sales Suite, Processing Suite, etc.)
**ALWAYS use** route pattern `seller.business.crm.*` (includes `{business}` segment)
**ALWAYS extend** `layouts.seller` for seller views
**Why:** We migrated from individual modules to a Suites architecture. CRM features are now part of the **Sales Suite**.
**See:** `docs/SUITES_AND_PRICING_MODEL.md` for full architecture
**The 7 Suites:**
1. **Sales Suite** - Products, Orders, Buyers, CRM, Marketing, Analytics, Orchestrator
2. **Processing Suite** - Extraction, Wash Reports, Yields (internal)
3. **Manufacturing Suite** - Work Orders, BOM, Packaging (internal)
4. **Delivery Suite** - Pick/Pack, Drivers, Manifests (internal)
5. **Management Suite** - Finance, AP/AR, Budgets (Canopy only)
6. **Brand Manager Suite** - Read-only brand portal (external partners)
7. **Dispensary Suite** - Buyer marketplace (dispensaries)
**Legacy module flags still exist** in database but are deprecated. Suite permissions control access now.
### 11. Media Storage - MinIO Architecture (CRITICAL!)
**NEVER use** `Storage::disk('public')` for brand/product media
**ALWAYS use** `Storage` (respects .env FILESYSTEM_DISK=minio)
**Why:** All media lives on MinIO (S3-compatible storage), not local disk. Using wrong disk breaks production images.

View File

@@ -102,7 +102,7 @@ class GenerateBriefingsCommand extends Command
}
// Get users who should receive briefings (sellers/admins)
$users = User::where('business_id', $businessId)
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $businessId))
->whereIn('user_type', ['seller', 'both'])
->where('is_active', true)
->get();
@@ -150,7 +150,7 @@ class GenerateBriefingsCommand extends Command
$totalUsers = 0;
foreach ($businesses as $business) {
$userCount = User::where('business_id', $business->id)
$userCount = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->whereIn('user_type', ['seller', 'both'])
->where('is_active', true)
->count();
@@ -164,7 +164,7 @@ class GenerateBriefingsCommand extends Command
$bar->start();
foreach ($businesses as $business) {
$users = User::where('business_id', $business->id)
$users = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->whereIn('user_type', ['seller', 'both'])
->where('is_active', true)
->get();

View File

@@ -7,7 +7,7 @@ use Illuminate\Console\Command;
class DevSetup extends Command
{
protected $signature = 'dev:setup
{--fresh : Drop all tables and re-run migrations}
{--fresh : Drop all tables and re-run migrations (DESTRUCTIVE - requires confirmation)}
{--skip-seed : Skip seeding dev fixtures}';
protected $description = 'Set up local development environment with migrations and dev fixtures';
@@ -25,8 +25,18 @@ class DevSetup extends Command
// Run migrations
if ($this->option('fresh')) {
$this->warn('Dropping all tables and re-running migrations...');
$this->call('migrate:fresh');
$this->newLine();
$this->error('WARNING: --fresh will DELETE ALL DATA in the database!');
$this->warn('This includes development data being preserved for production release.');
$this->newLine();
if (! $this->confirm('Are you SURE you want to drop all tables and lose all data?', false)) {
$this->info('Aborted. Running normal migrations instead...');
$this->call('migrate');
} else {
$this->warn('Dropping all tables and re-running migrations...');
$this->call('migrate:fresh');
}
} else {
$this->info('Running migrations...');
$this->call('migrate');

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;
/**
* Export Cannabrands data to PostgreSQL SQL dumps.
*
* This command exports current database data to SQL files in database/dumps/
* for later restoration without requiring a MySQL connection.
*
* Usage:
* - Configure your local database with the desired settings
* - Run: php artisan db:export-cannabrands
* - Commit the updated dump files (if they should be in git)
*/
class ExportCannabrandsData extends Command
{
protected $signature = 'db:export-cannabrands
{--tables= : Comma-separated list of specific tables to export}';
protected $description = 'Export Cannabrands data to PostgreSQL SQL dumps';
// Tables to export (same as restore command)
protected array $tables = [
'strains',
'product_categories',
'businesses',
'users',
'brands',
'locations',
'contacts',
'products',
'orders',
'order_items',
'invoices',
'business_user',
'brand_user',
'model_has_roles',
'ai_settings',
'orchestrator_sales_configs',
'orchestrator_marketing_configs',
];
protected string $dumpsPath;
public function __construct()
{
parent::__construct();
$this->dumpsPath = database_path('dumps');
}
public function handle(): int
{
$this->info('Exporting Cannabrands data to SQL dumps...');
// Create dumps directory if it doesn't exist
if (! is_dir($this->dumpsPath)) {
mkdir($this->dumpsPath, 0755, true);
$this->info("Created dumps directory: {$this->dumpsPath}");
}
// Determine which tables to export
$tablesToExport = $this->tables;
if ($this->option('tables')) {
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
$tablesToExport = array_intersect($this->tables, $requestedTables);
if (empty($tablesToExport)) {
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
return Command::FAILURE;
}
}
// Get database connection info
$database = config('database.connections.pgsql.database');
$username = config('database.connections.pgsql.username');
$host = config('database.connections.pgsql.host');
$port = config('database.connections.pgsql.port');
$exported = 0;
$errors = 0;
foreach ($tablesToExport as $table) {
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
$this->line("Exporting {$table}...");
// Build pg_dump command
// Using --column-inserts for portable SQL
// Using --on-conflict-do-nothing for idempotent inserts
$pgDumpArgs = sprintf(
'--data-only --column-inserts --on-conflict-do-nothing --table=%s %s',
escapeshellarg($table),
escapeshellarg($database)
);
// pg_dump with connection info
// Works both inside Sail container (pgsql hostname) and natively
$command = sprintf(
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s',
escapeshellarg(config('database.connections.pgsql.password')),
escapeshellarg($host),
escapeshellarg($port),
escapeshellarg($username),
$pgDumpArgs
);
$result = Process::run($command);
if ($result->successful()) {
// Extract only INSERT statements (remove pg_dump headers and SET commands)
// Handle multi-line INSERTs by looking for the ending pattern
$output = $result->output();
$lines = explode("\n", $output);
$inserts = [];
$currentInsert = '';
$inInsert = false;
foreach ($lines as $line) {
if (str_starts_with(trim($line), 'INSERT INTO')) {
// Start of new INSERT
$inInsert = true;
$currentInsert = $line;
// Check if this INSERT ends on same line
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
$inserts[] = $currentInsert;
$currentInsert = '';
$inInsert = false;
}
} elseif ($inInsert) {
// Continuation of current INSERT (multi-line due to embedded newlines in data)
// We need to escape the actual newline in the SQL string value
// Since we're inside a string value, replace with \n escape sequence
$currentInsert .= "\n".$line;
// Check if this line ends the INSERT
if (str_ends_with(trim($line), 'ON CONFLICT DO NOTHING;')) {
$inserts[] = $currentInsert;
$currentInsert = '';
$inInsert = false;
}
}
}
// Don't forget the last one if it didn't end properly
if (! empty($currentInsert)) {
$inserts[] = $currentInsert;
}
$cleanOutput = implode("\n", $inserts);
file_put_contents($dumpFile, $cleanOutput);
$this->info(' -> Exported '.count($inserts)." rows to {$table}.sql");
$exported++;
} else {
$this->error("Failed to export {$table}: ".$result->errorOutput());
$errors++;
}
}
$this->newLine();
$this->info("Exported {$exported} tables. Errors: {$errors}");
if ($exported > 0) {
$this->newLine();
$this->info('To restore this data on another machine:');
$this->line(' php artisan db:restore-cannabrands');
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Restore Cannabrands data from PostgreSQL SQL dumps.
*
* This command loads data from pre-exported SQL files in database/dumps/
* without requiring a MySQL connection. Data was originally imported from
* the MySQL hub_cannabrands database.
*
* Order of restoration matters due to foreign key constraints:
* 1. strains (no dependencies)
* 2. product_categories (self-referential via parent_id)
* 3. businesses (no dependencies)
* 4. users (no dependencies)
* 5. brands (depends on businesses)
* 6. locations (depends on businesses)
* 7. contacts (depends on businesses, locations)
* 8. products (depends on brands, strains, product_categories)
* 9. orders (depends on businesses)
* 10. order_items (depends on orders, products)
* 11. invoices (depends on orders, businesses)
* 12. business_user (depends on businesses, users)
* 13. brand_user (depends on brands, users)
* 14. model_has_roles (depends on users, roles)
* 15. ai_settings (depends on businesses)
* 16. orchestrator_sales_configs (depends on businesses)
* 17. orchestrator_marketing_configs (depends on businesses)
*/
class RestoreCannabrandsData extends Command
{
protected $signature = 'db:restore-cannabrands
{--fresh : Truncate tables before restoring}
{--tables= : Comma-separated list of specific tables to restore}';
protected $description = 'Restore Cannabrands data from PostgreSQL SQL dumps';
// Tables in dependency order
protected array $tables = [
'strains',
'product_categories',
'businesses',
'users',
'brands',
'locations',
'contacts',
'products',
'orders',
'order_items',
'invoices',
'business_user',
'brand_user',
'model_has_roles',
'ai_settings',
'orchestrator_sales_configs',
'orchestrator_marketing_configs',
];
protected string $dumpsPath;
public function __construct()
{
parent::__construct();
$this->dumpsPath = database_path('dumps');
}
public function handle(): int
{
$this->info('Restoring Cannabrands data from SQL dumps...');
// Check if dumps directory exists
if (! is_dir($this->dumpsPath)) {
$this->error("Dumps directory not found: {$this->dumpsPath}");
$this->error('Run the MySQL import seeders first to create the dumps.');
return Command::FAILURE;
}
// Determine which tables to restore
$tablesToRestore = $this->tables;
if ($this->option('tables')) {
$requestedTables = array_map('trim', explode(',', $this->option('tables')));
$tablesToRestore = array_intersect($this->tables, $requestedTables);
if (empty($tablesToRestore)) {
$this->error('No valid tables specified. Available tables: '.implode(', ', $this->tables));
return Command::FAILURE;
}
}
// Fresh option - truncate tables in reverse order
if ($this->option('fresh')) {
$this->warn('Truncating tables before restore...');
DB::statement('SET session_replication_role = replica;'); // Disable FK checks
foreach (array_reverse($tablesToRestore) as $table) {
$this->line("Truncating {$table}...");
DB::table($table)->truncate();
}
DB::statement('SET session_replication_role = DEFAULT;'); // Re-enable FK checks
}
// Restore each table
$restored = 0;
$errors = 0;
foreach ($tablesToRestore as $table) {
$dumpFile = "{$this->dumpsPath}/{$table}.sql";
if (! file_exists($dumpFile)) {
$this->warn("Dump file not found for {$table}: {$dumpFile}");
continue;
}
$this->line("Restoring {$table}...");
try {
$sql = file_get_contents($dumpFile);
if (empty(trim($sql))) {
$this->info(' -> 0 rows (empty file)');
$restored++;
continue;
}
// Disable FK checks for this session to allow loading in any order
DB::statement('SET session_replication_role = replica;');
// Execute all statements at once
DB::unprepared($sql);
// Re-enable FK checks
DB::statement('SET session_replication_role = DEFAULT;');
// Count rows
$count = DB::table($table)->count();
$this->info(" -> {$count} rows in {$table}");
$restored++;
} catch (\Exception $e) {
// Re-enable FK checks even on error
try {
DB::statement('SET session_replication_role = DEFAULT;');
} catch (\Exception $ignored) {
}
$this->error("Failed to restore {$table}: ".$e->getMessage());
$errors++;
}
}
// Reset sequences to max ID + 1 for each table
$this->info('Resetting sequence counters...');
foreach ($tablesToRestore as $table) {
$this->resetSequence($table);
}
$this->newLine();
$this->info("Restored {$restored} tables. Errors: {$errors}");
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* Reset the sequence for a table to max ID + 1.
*/
protected function resetSequence(string $table): void
{
try {
$maxId = DB::table($table)->max('id');
if ($maxId) {
$sequence = "{$table}_id_seq";
DB::statement("SELECT setval('{$sequence}', ?)", [$maxId]);
}
} catch (\Exception $e) {
// Sequence might not exist for this table
}
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Business;
use App\Services\Accounting\FixedAssetService;
use Carbon\Carbon;
use Illuminate\Console\Command;
/**
* Run monthly depreciation for fixed assets.
*
* This command calculates and posts depreciation entries for all
* eligible fixed assets. Can be run for a specific business or all
* businesses with Management Suite enabled.
*
* Safe to run multiple times in the same month - assets that have
* already been depreciated for the period will be skipped.
*/
class RunFixedAssetDepreciation extends Command
{
protected $signature = 'fixed-assets:run-depreciation
{business_id? : Specific business ID to run for}
{--period= : Period date (Y-m-d format, defaults to end of current month)}
{--dry-run : Show what would be depreciated without making changes}';
protected $description = 'Run monthly depreciation for fixed assets';
public function __construct(
protected FixedAssetService $assetService
) {
parent::__construct();
}
public function handle(): int
{
$businessId = $this->argument('business_id');
$periodOption = $this->option('period');
$dryRun = $this->option('dry-run');
// Parse period date
$periodDate = $periodOption
? Carbon::parse($periodOption)->endOfMonth()
: Carbon::now()->endOfMonth();
$this->info("Running depreciation for period: {$periodDate->format('Y-m')}");
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
// Get businesses to process
$businesses = $this->getBusinesses($businessId);
if ($businesses->isEmpty()) {
$this->warn('No businesses found to process.');
return Command::SUCCESS;
}
$totalRuns = 0;
$totalAmount = 0;
foreach ($businesses as $business) {
$this->line('');
$this->info("Processing: {$business->name}");
if ($dryRun) {
$results = $this->previewDepreciation($business, $periodDate);
} else {
$results = $this->assetService->runBatchDepreciation($business, $periodDate);
}
$count = $results->count();
$amount = $results->sum('depreciation_amount');
if ($count > 0) {
$this->line(" - Depreciated {$count} assets");
$this->line(" - Total amount: \${$amount}");
$totalRuns += $count;
$totalAmount += $amount;
} else {
$this->line(' - No assets to depreciate');
}
}
$this->line('');
$this->info('=== Summary ===');
$this->info("Total assets depreciated: {$totalRuns}");
$this->info("Total depreciation amount: \${$totalAmount}");
if ($dryRun) {
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
}
return Command::SUCCESS;
}
/**
* Get businesses to process.
*/
protected function getBusinesses(?string $businessId): \Illuminate\Support\Collection
{
if ($businessId) {
$business = Business::find($businessId);
if (! $business) {
$this->error("Business with ID {$businessId} not found.");
return collect();
}
if (! $business->hasManagementSuite()) {
$this->warn("Business {$business->name} does not have Management Suite enabled.");
}
return collect([$business]);
}
// Get all businesses with Management Suite
return Business::whereHas('suites', function ($query) {
$query->where('key', 'management');
})->get();
}
/**
* Preview depreciation without making changes.
*/
protected function previewDepreciation(Business $business, Carbon $periodDate): \Illuminate\Support\Collection
{
$period = $periodDate->format('Y-m');
$assets = \App\Models\Accounting\FixedAsset::where('business_id', $business->id)
->where('status', \App\Models\Accounting\FixedAsset::STATUS_ACTIVE)
->where('category', '!=', \App\Models\Accounting\FixedAsset::CATEGORY_LAND)
->get();
$results = collect();
foreach ($assets as $asset) {
// Skip if already depreciated for this period
$existing = \App\Models\Accounting\FixedAssetDepreciationRun::where('fixed_asset_id', $asset->id)
->where('period', $period)
->where('is_reversed', false)
->exists();
if ($existing) {
continue;
}
// Skip if fully depreciated
if ($asset->book_value <= $asset->salvage_value) {
continue;
}
$depreciationAmount = $asset->monthly_depreciation;
$maxDepreciation = $asset->book_value - $asset->salvage_value;
$depreciationAmount = min($depreciationAmount, $maxDepreciation);
if ($depreciationAmount > 0) {
$results->push((object) [
'fixed_asset_id' => $asset->id,
'asset_name' => $asset->name,
'depreciation_amount' => $depreciationAmount,
]);
$this->line(" - {$asset->name}: \${$depreciationAmount}");
}
}
return $results;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Accounting\RecurringSchedulerService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class RunRecurringSchedules extends Command
{
protected $signature = 'recurring:run
{--date= : The date to run schedules for (YYYY-MM-DD, default: today)}
{--business= : Specific business ID to run schedules for}
{--dry-run : Preview what would be generated without actually creating transactions}';
protected $description = 'Run due recurring schedules to generate AR invoices, AP bills, and journal entries';
public function __construct(
protected RecurringSchedulerService $schedulerService
) {
parent::__construct();
}
public function handle(): int
{
$dateString = $this->option('date');
$businessId = $this->option('business') ? (int) $this->option('business') : null;
$dryRun = $this->option('dry-run');
$date = $dateString ? Carbon::parse($dateString) : now();
$this->info("Running recurring schedules for {$date->toDateString()}...");
if ($businessId) {
$this->info("Filtering to business ID: {$businessId}");
}
// Get due schedules
$dueSchedules = $this->schedulerService->getDueSchedules($date, $businessId);
if ($dueSchedules->isEmpty()) {
$this->info('No schedules are due for execution.');
return self::SUCCESS;
}
$this->info("Found {$dueSchedules->count()} schedule(s) due for execution.");
if ($dryRun) {
$this->warn('DRY RUN MODE - No transactions will be created.');
$this->table(
['ID', 'Name', 'Type', 'Business', 'Next Run Date', 'Auto Post'],
$dueSchedules->map(fn ($s) => [
$s->id,
$s->name,
$s->type_label,
$s->business->name ?? 'N/A',
$s->next_run_date->toDateString(),
$s->auto_post ? 'Yes' : 'No',
])
);
return self::SUCCESS;
}
// Run all due schedules
$results = $this->schedulerService->runAllDue($date, $businessId);
// Output results
$this->newLine();
$this->info('Execution Summary:');
$this->line(" Processed: {$results['processed']}");
$this->line(" Successful: {$results['success']}");
$this->line(" Failed: {$results['failed']}");
if (! empty($results['generated'])) {
$this->newLine();
$this->info('Generated Transactions:');
$this->table(
['Schedule', 'Type', 'Result ID'],
collect($results['generated'])->map(fn ($g) => [
$g['schedule_name'],
$g['type'],
$g['result_id'],
])
);
}
if (! empty($results['errors'])) {
$this->newLine();
$this->error('Errors:');
foreach ($results['errors'] as $error) {
$this->line(" [{$error['schedule_id']}] {$error['schedule_name']}: {$error['error']}");
}
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use Illuminate\Database\Console\Migrations\FreshCommand;
/**
* Override migrate:fresh to prevent accidental data loss.
*
* This command blocks migrate:fresh in all environments except when
* explicitly targeting a test database (DB_DATABASE=testing or *_test_*).
*/
class SafeFreshCommand extends FreshCommand
{
public function handle()
{
// Check both config and direct env (env var may not be in config yet)
$database = env('DB_DATABASE', config('database.connections.pgsql.database'));
// Allow migrate:fresh ONLY for test databases
$isTestDatabase = $database === 'testing'
|| str_contains($database, '_test_')
|| str_contains($database, 'testing_');
if (! $isTestDatabase) {
$this->components->error('migrate:fresh is BLOCKED to prevent data loss!');
$this->components->warn("Database: {$database}");
$this->newLine();
$this->components->bulletList([
'This command drops ALL tables and destroys ALL data.',
'It is blocked in local, dev, staging, and production.',
'For testing: DB_DATABASE=testing ./vendor/bin/sail artisan migrate:fresh',
'To seed existing data: php artisan db:seed --class=ProductionSyncSeeder',
]);
return 1;
}
$this->components->info("Running migrate:fresh on TEST database: {$database}");
return parent::handle();
}
}

View File

@@ -229,13 +229,13 @@ class SendCrmDailyDigest extends Command
if ($business->crm_notification_emails) {
$emails = array_map('trim', explode(',', $business->crm_notification_emails));
return User::where('business_id', $business->id)
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->whereIn('email', $emails)
->get();
}
// Otherwise, send to the business owner or first admin
return User::where('business_id', $business->id)
return User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->where(function ($q) {
$q->where('is_business_owner', true)
->orWhere('user_type', 'admin');

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class SyncBrandMediaPaths extends Command
{
protected $signature = 'brands:sync-media-paths
{--dry-run : Preview changes without applying}
{--business= : Limit to specific business slug}';
protected $description = 'Sync brand logo_path and banner_path from MinIO storage';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$businessFilter = $this->option('business');
if ($dryRun) {
$this->warn('DRY RUN - No changes will be made');
}
$this->info('Scanning MinIO for brand media...');
$businessDirs = Storage::directories('businesses');
$updated = 0;
$skipped = 0;
foreach ($businessDirs as $businessDir) {
$businessSlug = basename($businessDir);
if ($businessFilter && $businessSlug !== $businessFilter) {
continue;
}
$brandsDir = $businessDir.'/brands';
if (! Storage::exists($brandsDir)) {
continue;
}
$brandDirs = Storage::directories($brandsDir);
foreach ($brandDirs as $brandDir) {
$brandSlug = basename($brandDir);
$brandingDir = $brandDir.'/branding';
if (! Storage::exists($brandingDir)) {
continue;
}
$brand = Brand::where('slug', $brandSlug)->first();
if (! $brand) {
$this->line(" <fg=yellow>?</> {$brandSlug} - not found in database");
$skipped++;
continue;
}
$files = Storage::files($brandingDir);
$logoPath = null;
$bannerPath = null;
foreach ($files as $file) {
$filename = strtolower(basename($file));
if (str_starts_with($filename, 'logo.')) {
$logoPath = $file;
} elseif (str_starts_with($filename, 'banner.')) {
$bannerPath = $file;
}
}
$changes = [];
if ($logoPath && $brand->logo_path !== $logoPath) {
$changes[] = "logo: {$logoPath}";
}
if ($bannerPath && $brand->banner_path !== $bannerPath) {
$changes[] = "banner: {$bannerPath}";
}
if (empty($changes)) {
$this->line(" <fg=green>✓</> {$brandSlug} - already synced");
continue;
}
if (! $dryRun) {
if ($logoPath) {
$brand->logo_path = $logoPath;
}
if ($bannerPath) {
$brand->banner_path = $bannerPath;
}
$brand->save();
}
$this->line(" <fg=blue>↻</> {$brandSlug} - ".implode(', ', $changes));
$updated++;
}
}
$this->newLine();
$this->info("Updated: {$updated} | Skipped: {$skipped}");
if ($dryRun && $updated > 0) {
$this->warn('Run without --dry-run to apply changes');
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use App\Models\Accounting\AccountingPeriod;
class PeriodLockedException extends \Exception
{
public function __construct(
string $message,
public readonly ?AccountingPeriod $period = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
public function getPeriod(): ?AccountingPeriod
{
return $this->period;
}
}

View File

@@ -11,7 +11,7 @@ class CreateBatch extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['business_id'] = auth()->user()->business_id;
$data['business_id'] = auth()->user()->primaryBusiness()?->id;
return $data;
}

View File

@@ -13,6 +13,7 @@ use Filament\Forms;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
@@ -45,6 +46,13 @@ class BusinessResource extends Resource
{
protected static ?string $model = Business::class;
/**
* Force Filament to use 'id' for record route binding in admin panel.
* This is necessary because Business model uses 'slug' as getRouteKeyName()
* for public routes, but admin panel needs 'id' for reliable record binding.
*/
protected static ?string $recordRouteKeyName = 'id';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static \UnitEnum|string|null $navigationGroup = 'Accounts';
@@ -147,80 +155,191 @@ class BusinessResource extends Resource
]),
]),
Tab::make('Addresses')
Tab::make('Locations')
->label(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Locations' : 'Address')
->schema([
Section::make('Physical Address')
Repeater::make('locations')
->relationship('locations')
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['slug'] = $data['slug'] ?? \Illuminate\Support\Str::slug($data['name'] ?? 'location');
return $data;
})
->schema([
Grid::make(2)
Grid::make(3)
->schema([
TextInput::make('physical_address')
TextInput::make('name')
->label(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Location Name' : 'Address Name')
->maxLength(255),
Select::make('location_type')
->label('Type')
->options([
'physical' => 'Physical',
'billing' => 'Billing',
'delivery' => 'Delivery',
])
->default('physical'),
TextInput::make('license_number')
->label('License #')
->maxLength(255),
]),
Grid::make(4)
->schema([
TextInput::make('address')
->label('Street Address')
->maxLength(255)
->columnSpan(2),
TextInput::make('physical_city')
TextInput::make('unit')
->label('Unit/Suite')
->maxLength(255),
TextInput::make('city')
->label('City')
->maxLength(255),
TextInput::make('physical_state')
]),
Grid::make(4)
->schema([
TextInput::make('state')
->label('State')
->maxLength(255),
TextInput::make('physical_zipcode')
->label('ZIP Code')
->maxLength(2),
TextInput::make('zipcode')
->label('ZIP')
->maxLength(10),
TextInput::make('phone')
->label('Phone')
->tel()
->maxLength(20),
TextInput::make('email')
->label('Email')
->email()
->maxLength(255),
]),
]),
Section::make('Billing Address')
->schema([
Grid::make(2)
->schema([
TextInput::make('billing_address')
->label('Billing Street Address')
->maxLength(255)
->columnSpan(2),
TextInput::make('billing_city')
->label('Billing City')
->maxLength(255),
TextInput::make('billing_state')
->label('Billing State')
->maxLength(255),
TextInput::make('billing_zipcode')
->label('Billing ZIP Code')
->maxLength(255),
Toggle::make('is_primary')
->label(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Primary Location' : 'Primary Address'),
Toggle::make('is_billing')
->label('Billing Address'),
]),
]),
])
->itemLabel(fn (array $state, $livewire): ?string => $state['name'] ?? (self::isDispensaryBusiness($livewire->getRecord()) ? 'New Location' : 'New Address'))
->collapsible()
->collapsed()
->addActionLabel(fn ($livewire) => self::isDispensaryBusiness($livewire->getRecord()) ? 'Add Location' : 'Add Address')
->defaultItems(0),
]),
Tab::make('Users & Access')
->schema([
// Quick add from business contacts section
Forms\Components\Placeholder::make('contacts_without_users')
->label('Contacts Without Platform Access')
->content(function ($livewire) {
$business = $livewire->getRecord();
if (! $business) {
return '';
}
$existingUserEmails = \App\Models\User::pluck('email')->map(fn ($e) => strtolower($e))->toArray();
$contacts = $business->contacts()
->whereNotNull('email')
->where('email', '!=', '')
->get()
->filter(fn ($c) => ! in_array(strtolower($c->email), $existingUserEmails));
if ($contacts->isEmpty()) {
return new \Illuminate\Support\HtmlString(
'<span class="text-gray-500 text-sm">All business contacts with emails already have platform access.</span>'
);
}
$html = '<div class="text-sm text-gray-600 mb-2">These business contacts have emails but no platform login. Click "Add Platform User" below and use "Link Existing User" or manually add them:</div>';
$html .= '<div class="flex flex-wrap gap-2">';
foreach ($contacts as $contact) {
$name = trim($contact->first_name.' '.$contact->last_name) ?: 'Unknown';
$type = $contact->contact_type ? ucfirst($contact->contact_type) : '';
$html .= '<span class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-amber-50 text-amber-800 border border-amber-200 text-xs">';
$html .= '<strong>'.e($name).'</strong>';
if ($type) {
$html .= ' <span class="text-amber-600">('.$type.')</span>';
}
$html .= ' - '.e($contact->email);
$html .= '</span>';
}
$html .= '</div>';
return new \Illuminate\Support\HtmlString($html);
})
->visible(function ($livewire) {
$business = $livewire->getRecord();
if (! $business) {
return false;
}
$existingUserEmails = \App\Models\User::pluck('email')->map(fn ($e) => strtolower($e))->toArray();
return $business->contacts()
->whereNotNull('email')
->where('email', '!=', '')
->get()
->filter(fn ($c) => ! in_array(strtolower($c->email), $existingUserEmails))
->isNotEmpty();
})
->columnSpanFull(),
Repeater::make('users')
->relationship('users')
->helperText('Users with login credentials and access to manage this business')
->schema([
Grid::make(3)
->schema([
TextInput::make('first_name')
->label('First Name')
->required()
->maxLength(255),
TextInput::make('last_name')
->label('Last Name')
->required()
->maxLength(255),
TextInput::make('email')
->label('Email')
->email()
->required()
->maxLength(255),
TextInput::make('phone')
->label('Phone')
->tel()
->maxLength(255),
Select::make('contact_type')
->label('Role/Type')
->required()
->options(Contact::CONTACT_TYPES)
->default('staff')
->searchable(),
Hidden::make('id'),
Select::make('user_id')
->label('Link Existing User')
->options(function ($get, $livewire) {
$business = $livewire->getRecord();
$currentUserIds = $business ? $business->users()->pluck('users.id')->toArray() : [];
$currentId = $get('id');
return \App\Models\User::query()
->with('businesses')
->where(function ($query) use ($currentUserIds, $currentId) {
$query->whereNotIn('id', $currentUserIds);
if ($currentId) {
$query->orWhere('id', $currentId);
}
})
->where('user_type', '!=', 'admin')
->orderBy('first_name')
->get()
->mapWithKeys(function ($user) {
$businesses = $user->businesses->pluck('name')->join(', ');
$label = $user->full_name.' ('.$user->email.')';
if ($businesses) {
$label .= ' - '.$businesses;
}
return [$user->id => $label];
});
})
->searchable()
->preload()
->live()
->dehydrated(false)
->afterStateUpdated(function ($state, callable $set) {
if ($state) {
$user = \App\Models\User::find($state);
if ($user) {
$set('id', $user->id);
$set('first_name', $user->first_name);
$set('last_name', $user->last_name);
$set('email', $user->email);
$set('phone', $user->phone);
}
}
})
->helperText('Search and select an existing user, or leave empty to create new')
->columnSpan(2),
Toggle::make('is_primary')
->label(new \Illuminate\Support\HtmlString(
'<span style="text-decoration: underline dotted; cursor: help;" title="Only one primary user allowed - clicking will immediately switch the primary user">Primary</span>'
@@ -259,6 +378,31 @@ class BusinessResource extends Resource
return false;
})
->inline(false),
TextInput::make('first_name')
->label('First Name')
->required()
->maxLength(255),
TextInput::make('last_name')
->label('Last Name')
->required()
->maxLength(255),
TextInput::make('email')
->label('Email')
->email()
->required()
->maxLength(255)
->disabled(fn ($get) => ! empty($get('id')))
->helperText(fn ($get) => ! empty($get('id')) ? 'Email cannot be changed for existing users' : 'New user will be created with this email'),
TextInput::make('phone')
->label('Phone')
->tel()
->maxLength(255),
Select::make('contact_type')
->label('Role/Type')
->required()
->options(Contact::CONTACT_TYPES)
->default('staff')
->searchable(),
]),
])
->saveRelationshipsUsing(function ($component, $state, $record) {
@@ -267,22 +411,54 @@ class BusinessResource extends Resource
}
$syncData = [];
foreach ($state as $item) {
$email = $item['email'] ?? null;
if (! $email) {
continue;
}
// Check if user exists by ID or email
$user = null;
if (isset($item['id'])) {
$user = \App\Models\User::find($item['id']);
if ($user) {
$user->update([
'first_name' => $item['first_name'] ?? null,
'last_name' => $item['last_name'] ?? null,
'email' => $item['email'] ?? null,
'phone' => $item['phone'] ?? null,
]);
}
$syncData[$item['id']] = [
'contact_type' => $item['contact_type'] ?? 'staff',
'is_primary' => $item['is_primary'] ?? false,
];
}
// If no user found by ID, try to find by email
if (! $user) {
$user = \App\Models\User::where('email', $email)->first();
}
if ($user) {
// Update existing user
$user->update([
'first_name' => $item['first_name'] ?? $user->first_name,
'last_name' => $item['last_name'] ?? $user->last_name,
'phone' => $item['phone'] ?? $user->phone,
]);
} else {
// Create new user
$user = \App\Models\User::create([
'first_name' => $item['first_name'] ?? '',
'last_name' => $item['last_name'] ?? '',
'email' => $email,
'phone' => $item['phone'] ?? null,
'password' => bcrypt(\Illuminate\Support\Str::random(16)),
'user_type' => $record->business_type === 'retailer' ? 'buyer' : 'seller',
]);
}
$syncData[$user->id] = [
'contact_type' => $item['contact_type'] ?? 'staff',
'is_primary' => $item['is_primary'] ?? false,
];
}
// Auto-set first user as primary if no primary is set
$hasPrimary = collect($syncData)->contains(fn ($data) => $data['is_primary']);
if (! $hasPrimary && ! empty($syncData)) {
$firstUserId = array_key_first($syncData);
$syncData[$firstUserId]['is_primary'] = true;
}
$record->users()->sync($syncData);
})
->itemLabel(fn (array $state): ?string => trim(($state['first_name'] ?? '').' '.($state['last_name'] ?? '')) ?:
@@ -546,53 +722,83 @@ class BusinessResource extends Resource
->bulkToggleable()
->helperText('Select the suites this business should have access to. Each suite enables specific features and menu items.'),
Forms\Components\Placeholder::make('suite_info')
->label('')
->content(function () {
// Show available suites (excluding deprecated and internal)
$suites = \App\Models\Suite::available()->orderBy('sort_order')->get();
$html = '<div class="grid grid-cols-2 gap-4 text-sm mt-4">';
foreach ($suites as $suite) {
$colorClass = match ($suite->color) {
'emerald' => 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-950', // Sales
'pink' => 'border-pink-300 bg-pink-50 dark:border-pink-700 dark:bg-pink-950', // Marketing
'cyan' => 'border-cyan-300 bg-cyan-50 dark:border-cyan-700 dark:bg-cyan-950', // Inventory
'blue' => 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-950', // Processing
'orange' => 'border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-950', // Manufacturing
'indigo' => 'border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-950', // Procurement
'violet' => 'border-violet-300 bg-violet-50 dark:border-violet-700 dark:bg-violet-950', // Distribution
'green' => 'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-950', // Finance
'amber' => 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950', // Compliance
'sky' => 'border-sky-300 bg-sky-50 dark:border-sky-700 dark:bg-sky-950', // Inbox
'slate' => 'border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-950', // Tools
'gray' => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950', // Management
'lime' => 'border-lime-300 bg-lime-50 dark:border-lime-700 dark:bg-lime-950', // Dispensary
'gold' => 'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-950', // Enterprise
'teal' => 'border-teal-300 bg-teal-50 dark:border-teal-700 dark:bg-teal-950', // Brand Manager
'red' => 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-950',
'rose' => 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950',
'fuchsia' => 'border-fuchsia-300 bg-fuchsia-50 dark:border-fuchsia-700 dark:bg-fuchsia-950',
default => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950',
};
$features = is_array($suite->included_features) ? implode(', ', $suite->included_features) : '';
$html .= '<div class="border rounded-lg p-3 '.$colorClass.'">';
$html .= '<div class="font-medium">'.e($suite->name).'</div>';
$html .= '<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">'.e($features).'</div>';
$html .= '</div>';
}
$html .= '</div>';
return new \Illuminate\Support\HtmlString($html);
}),
]),
Section::make('Navigation Settings')
->description('Control how this business experiences the seller sidebar navigation.')
// ===== SUITE SHARES SECTION =====
// Allows this business to share parts of their suite TO other businesses
Section::make('Suite Shares')
->description('Share parts of THIS business\'s suite with other businesses. The recipient will see these menu items with a "Shared" badge.')
->collapsed()
->schema([
Toggle::make('use_suite_navigation')
->label('Use Suite Navigation (beta)')
->helperText('When enabled, this business uses the new suite-based sidebar instead of the legacy menu.')
->default(false),
Forms\Components\Repeater::make('suiteShares')
->relationship('suiteShares')
->label('')
->schema([
Select::make('target_business_id')
->label('Share TO Business')
->options(function (callable $get) {
$currentBusinessId = $get('../../id');
return \App\Models\Business::query()
->when($currentBusinessId, fn ($q) => $q->where('id', '!=', $currentBusinessId))
->orderBy('name')
->pluck('name', 'id');
})
->searchable()
->required()
->helperText('Select the business that will RECEIVE these shared menu items'),
Select::make('shared_suite_key')
->label('Suite to Share From')
->options(function ($livewire) {
// Get suites assigned to THIS business (source)
$business = $livewire->record;
if (! $business) {
return [];
}
return $business->suites()
->orderBy('sort_order')
->pluck('name', 'key')
->toArray();
})
->required()
->reactive()
->helperText('Select which of THIS business\'s suites to share items from'),
CheckboxList::make('shared_menu_keys')
->label('Menu Items to Share')
->options(function (callable $get) {
$suiteKey = $get('shared_suite_key');
if (! $suiteKey) {
return [];
}
// Get menu keys for this suite from config
$menuKeys = config("suites.menus.{$suiteKey}", []);
$resolver = app(\App\Services\SuiteMenuResolver::class);
$options = [];
foreach ($menuKeys as $key) {
$def = $resolver->getMenuDefinition($key);
if ($def) {
$options[$key] = $def['label'].' ('.$def['section'].')';
}
}
return $options;
})
->columns(2)
->required()
->visible(fn (callable $get) => ! empty($get('shared_suite_key'))),
])
->columns(1)
->defaultItems(0)
->addActionLabel('Add Suite Share')
->reorderable(false)
->collapsible()
->itemLabel(fn (array $state): ?string => isset($state['target_business_id'])
? 'Share to: '.(\App\Models\Business::find($state['target_business_id'])?->name ?? 'New Share')
: 'New Share'
),
]),
Section::make('Sales Suite Usage Limits')
@@ -1653,23 +1859,27 @@ class BusinessResource extends Resource
default => 'gray',
})
->sortable(),
TextColumn::make('owner.full_name')
TextColumn::make('primary_user')
->label('Account Owner')
->getStateUsing(function (Business $record): ?string {
$owner = $record->owner;
if ($owner) {
$name = trim($owner->first_name.' '.$owner->last_name);
// Use the primary user from the pivot table
$primaryUser = $record->users->first();
if ($primaryUser) {
$name = trim($primaryUser->first_name.' '.$primaryUser->last_name);
return $name.' ('.$owner->email.')';
return $name.' ('.$primaryUser->email.')';
}
return 'N/A';
})
->searchable(query: function ($query, $search) {
return $query->whereHas('owner', function ($q) use ($search) {
$q->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
return $query->whereHas('users', function ($q) use ($search) {
$q->wherePivot('is_primary', true)
->where(function ($q2) use ($search) {
$q2->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
})
->sortable(),
@@ -1850,4 +2060,19 @@ class BusinessResource extends Resource
'edit' => EditBusiness::route('/{record}/edit'),
];
}
/**
* Check if business is a dispensary/retailer type.
* Used to determine whether to show "Locations" (multi-location dispensaries)
* or "Address" (single address for sellers/manufacturers).
*/
protected static function isDispensaryBusiness(?\App\Models\Business $business): bool
{
if (! $business) {
return false;
}
// Check if business has the "dispensary" type key assigned
return $business->types()->where('business_types.key', 'dispensary')->exists();
}
}

View File

@@ -8,4 +8,15 @@ use Filament\Resources\Pages\CreateRecord;
class CreateBusiness extends CreateRecord
{
protected static string $resource = BusinessResource::class;
/**
* Override redirect URL to use record ID instead of slug.
*
* This ensures proper routing after business creation since
* Business model uses 'slug' as getRouteKeyName() but admin uses 'id'.
*/
protected function getRedirectUrl(): string
{
return static::getResource()::getUrl('edit', ['record' => $this->record->getKey()]);
}
}

View File

@@ -13,6 +13,11 @@ class EditBusiness extends EditRecord
{
protected static string $resource = BusinessResource::class;
public function getTitle(): string
{
return 'Edit '.$this->record->name;
}
/**
* Livewire listeners for audit trail integration.
*/

View File

@@ -5,6 +5,7 @@ namespace App\Filament\Resources\BusinessResource\Pages;
use App\Filament\Resources\BusinessResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Model;
class ListBusinesses extends ListRecords
{
@@ -24,4 +25,22 @@ class ListBusinesses extends ListRecords
CreateAction::make(),
];
}
/**
* Override URL generation to use business ID instead of slug.
*
* The Business model uses 'slug' as route key for public routes,
* but admin panel needs the primary key for reliable routing.
*
* @param array<string, mixed> $parameters
*/
public function getResourceUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = true): string
{
// Convert Model to ID for the 'record' parameter
if (isset($parameters['record']) && $parameters['record'] instanceof Model) {
$parameters['record'] = $parameters['record']->getKey();
}
return parent::getResourceUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
}
}

View File

@@ -43,20 +43,25 @@ class LabResource extends Resource
// Scope to user's business products and batches unless they're a super admin
if (auth()->check() && ! auth()->user()->hasRole('Super Admin')) {
$businessId = auth()->user()->business_id;
$businessId = auth()->user()->primaryBusiness()?->id;
$query->where(function ($q) use ($businessId) {
// Include labs for products owned by this business
$q->whereHas('product', function ($productQuery) use ($businessId) {
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
$brandQuery->where('business_id', $businessId);
});
})
// OR labs for batches owned by this business
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
$batchQuery->where('business_id', $businessId);
});
});
if ($businessId) {
$query->where(function ($q) use ($businessId) {
// Include labs for products owned by this business
$q->whereHas('product', function ($productQuery) use ($businessId) {
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
$brandQuery->where('business_id', $businessId);
});
})
// OR labs for batches owned by this business
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
$batchQuery->where('business_id', $businessId);
});
});
} else {
// No business association - show nothing
$query->whereRaw('1 = 0');
}
}
return $query;

View File

@@ -55,6 +55,22 @@ class OrchestratorOutcomesChart extends ChartWidget
->pending()
->count();
// If all values are zero, show a placeholder to prevent empty doughnut rendering
$total = $completed + $dismissed + $snoozed + $pending;
if ($total === 0) {
return [
'datasets' => [
[
'label' => 'No Data',
'data' => [1],
'backgroundColor' => ['rgba(209, 213, 219, 0.5)'], // gray placeholder
'borderWidth' => 0,
],
],
'labels' => ['No tasks yet'],
];
}
return [
'datasets' => [
[

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Accounting;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ApVendorController extends Controller
{
/**
* List vendors for a business.
*
* GET /api/{business}/ap/vendors
*/
public function index(Request $request, Business $business): JsonResponse
{
$query = ApVendor::where('business_id', $business->id);
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
}
// Active filter
if ($request->has('active')) {
$query->where('is_active', $request->boolean('active'));
}
$vendors = $query->orderBy('name')->paginate($request->get('per_page', 50));
return response()->json([
'success' => true,
'data' => $vendors->items(),
'meta' => [
'current_page' => $vendors->currentPage(),
'last_page' => $vendors->lastPage(),
'per_page' => $vendors->perPage(),
'total' => $vendors->total(),
],
]);
}
/**
* Get a single vendor.
*
* GET /api/{business}/ap/vendors/{vendor}
*/
public function show(Business $business, ApVendor $vendor): JsonResponse
{
if ($vendor->business_id !== $business->id) {
return response()->json([
'success' => false,
'message' => 'Vendor does not belong to this business.',
], 403);
}
return response()->json([
'success' => true,
'data' => $vendor,
]);
}
/**
* Create a new vendor.
*
* POST /api/{business}/ap/vendors
*/
public function store(Request $request, Business $business): JsonResponse
{
try {
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'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',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
// Generate code if not provided
if (empty($validated['code'])) {
$validated['code'] = $this->generateVendorCode($business->id, $validated['name']);
}
$vendor = ApVendor::create([
'business_id' => $business->id,
...$validated,
'is_active' => true,
]);
return response()->json([
'success' => true,
'message' => "Vendor {$vendor->name} created.",
'data' => $vendor,
], 201);
} catch (\Exception $e) {
Log::error('Vendor creation failed', [
'business_id' => $business->id,
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Failed to create vendor: '.$e->getMessage(),
], 500);
}
}
/**
* Update a vendor.
*
* PUT /api/{business}/ap/vendors/{vendor}
*/
public function update(Request $request, Business $business, ApVendor $vendor): JsonResponse
{
if ($vendor->business_id !== $business->id) {
return response()->json([
'success' => false,
'message' => 'Vendor does not belong to this business.',
], 403);
}
try {
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'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',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
$vendor->update($validated);
return response()->json([
'success' => true,
'message' => "Vendor {$vendor->name} updated.",
'data' => $vendor->fresh(),
]);
} catch (\Exception $e) {
Log::error('Vendor update failed', [
'vendor_id' => $vendor->id,
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Failed to update vendor: '.$e->getMessage(),
], 500);
}
}
/**
* Generate vendor code from name.
*/
protected function generateVendorCode(int $businessId, string $name): string
{
$words = preg_split('/\s+/', strtoupper($name));
$prefix = '';
foreach ($words as $word) {
$prefix .= substr(preg_replace('/[^A-Z0-9]/', '', $word), 0, 3);
if (strlen($prefix) >= 6) {
break;
}
}
$prefix = substr($prefix, 0, 6);
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
}
}

View File

@@ -6,10 +6,10 @@ use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Buyer\BuyerBrandFollow;
use App\Models\Buyer\BuyerProductBookmark;
use App\Models\Crm\CrmThread;
use App\Models\Seller\BrandAnnouncement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmThread;
class BrandHubController extends Controller
{

View File

@@ -7,12 +7,12 @@ use App\Models\Buyer\BuyerAnalyticsCache;
use App\Models\Buyer\BuyerBrandFollow;
use App\Models\Buyer\BuyerQuoteApproval;
use App\Models\Buyer\BuyerTask;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmThread;
use App\Models\Order;
use App\Models\Seller\BrandAnnouncement;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmInvoice;
use Modules\Crm\Entities\CrmQuote;
use Modules\Crm\Entities\CrmThread;
class DashboardController extends Controller
{

View File

@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Buyer\BuyerMessageSettings;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmThread;
class InboxController extends Controller
{

View File

@@ -5,9 +5,10 @@ namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Buyer\BuyerInvoiceRecord;
use App\Models\Buyer\BuyerSavedFilter;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmInvoice;
class InvoiceController extends Controller
{
@@ -115,7 +116,7 @@ class InvoiceController extends Controller
}
// Get related thread if exists
$thread = \Modules\Crm\Entities\CrmThread::where('buyer_business_id', $business->id)
$thread = CrmThread::where('buyer_business_id', $business->id)
->where(function ($q) use ($invoice) {
$q->where('order_id', $invoice->order_id)
->orWhere('subject', 'ilike', "%{$invoice->invoice_number}%");

View File

@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Crm\CrmChannelMessage;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmMessage;
use Modules\Crm\Entities\CrmThread;
class MessageController extends Controller
{
@@ -64,7 +64,7 @@ class MessageController extends Controller
return back()->with('success', 'Message sent.');
}
public function destroy(CrmThread $thread, CrmMessage $message)
public function destroy(CrmThread $thread, CrmChannelMessage $message)
{
$business = Auth::user()->business;
$user = Auth::user();
@@ -88,7 +88,7 @@ class MessageController extends Controller
return back()->with('success', 'Message deleted.');
}
public function react(Request $request, CrmThread $thread, CrmMessage $message)
public function react(Request $request, CrmThread $thread, CrmChannelMessage $message)
{
$business = Auth::user()->business;
$user = Auth::user();

View File

@@ -108,7 +108,7 @@ class OrderController extends Controller
$deliveryEvents = BuyerDeliveryEvent::getTimelineForOrder($order->id);
// Get related thread if exists
$thread = \Modules\Crm\Entities\CrmThread::where('order_id', $order->id)
$thread = \App\Models\Crm\CrmThread::where('order_id', $order->id)
->where('buyer_business_id', $business->id)
->first();

View File

@@ -6,9 +6,9 @@ use App\Http\Controllers\Controller;
use App\Models\Buyer\BuyerQuoteApproval;
use App\Models\Buyer\BuyerSavedFilter;
use App\Models\Buyer\BuyerTeamMember;
use App\Models\Crm\CrmQuote;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmQuote;
class QuoteController extends Controller
{

View File

@@ -9,12 +9,13 @@ class CustomerController extends Controller
/**
* Customers entry point - smart gateway to CRM Accounts.
*
* If CRM is enabled: redirect to /s/{business}/crm/accounts
* If CRM is enabled (via Sales Suite or CRM feature): redirect to /s/{business}/crm/accounts
* If CRM is disabled: show feature-disabled view
*/
public function index(Business $business)
{
if ($business->has_crm) {
// CRM is included in Sales Suite or can be enabled as standalone feature
if ($business->hasCrmAccess()) {
return redirect()->route('seller.business.crm.accounts.index', $business);
}
@@ -22,18 +23,25 @@ class CustomerController extends Controller
'business' => $business,
'feature' => 'Customers',
'description' => 'The Customers feature requires CRM to be enabled for your business.',
'benefits' => [
'Manage all your customer accounts in one place',
'Track contact information and order history',
'Build stronger customer relationships',
'Access customer insights and analytics',
],
]);
}
/**
* Individual customer view - redirect to CRM Account detail.
*
* If CRM is enabled: redirect to the account detail page
* If CRM is enabled (via Sales Suite or CRM feature): redirect to the account detail page
* If CRM is disabled: show feature-disabled view
*/
public function show(Business $business, $customer)
{
if ($business->has_crm) {
// CRM is included in Sales Suite or can be enabled as standalone feature
if ($business->hasCrmAccess()) {
// Redirect to CRM Account detail - $customer is the account ID
return redirect()->route('seller.business.crm.accounts.show', [$business, $customer]);
}
@@ -42,6 +50,12 @@ class CustomerController extends Controller
'business' => $business,
'feature' => 'Customers',
'description' => 'The Customers feature requires CRM to be enabled for your business.',
'benefits' => [
'Manage all your customer accounts in one place',
'Track contact information and order history',
'Build stronger customer relationships',
'Access customer insights and analytics',
],
]);
}
}

View File

@@ -4,9 +4,15 @@ namespace App\Http\Controllers;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DashboardController extends Controller
{
/**
* Cache TTL for dashboard metrics (5 minutes)
*/
private const DASHBOARD_CACHE_TTL = 300;
/**
* Main dashboard redirect - automatically routes to business context
* Redirects to /s/{business}/dashboard based on user's primary business
@@ -37,103 +43,120 @@ class DashboardController extends Controller
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// Time periods
$currentStart = now()->subDays(30);
$currentEnd = now();
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Generate cache key based on business and brand selection
$brandKey = md5(implode(',', $brandIds));
$cacheKey = "dashboard.overview.{$business->id}.{$brandKey}";
// Cache expensive KPI calculations for 5 minutes
$kpiData = Cache::remember($cacheKey, self::DASHBOARD_CACHE_TTL, function () use ($business, $brandNames, $brandIds) {
// Time periods
$currentStart = now()->subDays(30);
$currentEnd = now();
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Get core metrics for current and previous periods
$currentStats = $this->getOrderStats($brandNames, $currentStart, $currentEnd);
$previousStats = $this->getOrderStats($brandNames, $previousStart, $previousEnd);
// Calculate KPIs
$revenueLast30 = ($currentStats->revenue ?? 0) / 100;
$ordersLast30 = $currentStats->order_count ?? 0;
$unitsSoldLast30 = $currentStats->total_units ?? 0;
$averageOrderValueLast30 = $ordersLast30 > 0 ? $revenueLast30 / $ordersLast30 : 0;
// Previous period metrics for growth calculation
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$previousOrders = $previousStats->order_count ?? 0;
$previousUnits = $previousStats->total_units ?? 0;
$previousAOV = $previousOrders > 0 ? $previousRevenue / $previousOrders : 0;
// Growth percentages
$revenueGrowth = $previousRevenue > 0 ? (($revenueLast30 - $previousRevenue) / $previousRevenue) * 100 : 0;
$ordersGrowth = $previousOrders > 0 ? (($ordersLast30 - $previousOrders) / $previousOrders) * 100 : 0;
$unitsGrowth = $previousUnits > 0 ? (($unitsSoldLast30 - $previousUnits) / $previousUnits) * 100 : 0;
$aovGrowth = $previousAOV > 0 ? (($averageOrderValueLast30 - $previousAOV) / $previousAOV) * 100 : 0;
// Count active brands
$activeBrandCount = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->count();
// Count active buyers (distinct buyer businesses that ordered in last 30 days)
$activeBuyerCount = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->distinct('orders.business_id')
->count('orders.business_id');
// Get inventory alerts count
$activeInventoryAlertsCount = \App\Models\InventoryAlert::where('business_id', $business->id)
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
->active()
->count();
// Get active promotions count
$activePromotionCount = \App\Models\Broadcast::where('business_id', $business->id)
->whereIn('status', ['scheduled', 'sending'])
->count();
// Top Products (last 30 days by revenue)
$topProducts = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->orderByDesc('total_revenue')
->limit(10)
->get()
->map(function ($item) {
$item->total_revenue_dollars = $item->total_revenue / 100;
return $item;
});
// Top Brands (last 30 days) - fetch previous period stats in single query to avoid N+1
$previousBrandStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->select('order_items.brand_name')
->selectRaw('SUM(order_items.line_total) as revenue')
->groupBy('order_items.brand_name')
->pluck('revenue', 'brand_name');
$topBrands = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->groupBy('order_items.brand_name')
->orderByDesc('total_revenue')
->get()
->map(function ($item) use ($previousBrandStats) {
$item->total_revenue_dollars = $item->total_revenue / 100;
$prevRevenue = ($previousBrandStats[$item->brand_name] ?? 0) / 100;
$item->growth_pct = $prevRevenue > 0 ? (($item->total_revenue_dollars - $prevRevenue) / $prevRevenue) * 100 : null;
return $item;
});
return compact(
'revenueLast30', 'ordersLast30', 'unitsSoldLast30', 'averageOrderValueLast30',
'revenueGrowth', 'ordersGrowth', 'unitsGrowth', 'aovGrowth',
'activeBrandCount', 'activeBuyerCount', 'activeInventoryAlertsCount', 'activePromotionCount',
'topProducts', 'topBrands', 'currentStart'
);
});
// Extract cached values
extract($kpiData);
$start7 = now()->subDays(7);
// Get core metrics for current and previous periods
$currentStats = $this->getOrderStats($brandNames, $currentStart, $currentEnd);
$previousStats = $this->getOrderStats($brandNames, $previousStart, $previousEnd);
// Calculate KPIs
$revenueLast30 = ($currentStats->revenue ?? 0) / 100;
$ordersLast30 = $currentStats->order_count ?? 0;
$unitsSoldLast30 = $currentStats->total_units ?? 0;
$averageOrderValueLast30 = $ordersLast30 > 0 ? $revenueLast30 / $ordersLast30 : 0;
// Previous period metrics for growth calculation
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$previousOrders = $previousStats->order_count ?? 0;
$previousUnits = $previousStats->total_units ?? 0;
$previousAOV = $previousOrders > 0 ? $previousRevenue / $previousOrders : 0;
// Growth percentages
$revenueGrowth = $previousRevenue > 0 ? (($revenueLast30 - $previousRevenue) / $previousRevenue) * 100 : 0;
$ordersGrowth = $previousOrders > 0 ? (($ordersLast30 - $previousOrders) / $previousOrders) * 100 : 0;
$unitsGrowth = $previousUnits > 0 ? (($unitsSoldLast30 - $previousUnits) / $previousUnits) * 100 : 0;
$aovGrowth = $previousAOV > 0 ? (($averageOrderValueLast30 - $previousAOV) / $previousAOV) * 100 : 0;
// Count active brands
$activeBrandCount = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->count();
// Count active buyers (distinct buyer businesses that ordered in last 30 days)
$activeBuyerCount = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->distinct('orders.business_id')
->count('orders.business_id');
// Get inventory alerts count
$activeInventoryAlertsCount = \App\Models\InventoryAlert::where('business_id', $business->id)
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
->active()
->count();
// Get active promotions count
$activePromotionCount = \App\Models\Broadcast::where('business_id', $business->id)
->whereIn('status', ['scheduled', 'sending'])
->count();
// Top Products (last 30 days by revenue)
$topProducts = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->orderByDesc('total_revenue')
->limit(10)
->get()
->map(function ($item) {
$item->total_revenue_dollars = $item->total_revenue / 100;
return $item;
});
// Top Brands (last 30 days)
$topBrands = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->groupBy('order_items.brand_name')
->orderByDesc('total_revenue')
->get()
->map(function ($item) use ($previousStart, $previousEnd) {
$item->total_revenue_dollars = $item->total_revenue / 100;
// Calculate growth vs previous period
$prevBrandStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->where('order_items.brand_name', $item->brand_name)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('SUM(order_items.line_total) as revenue')
->first();
$prevRevenue = ($prevBrandStats->revenue ?? 0) / 100;
$item->growth_pct = $prevRevenue > 0 ? (($item->total_revenue_dollars - $prevRevenue) / $prevRevenue) * 100 : null;
return $item;
});
// Needs Attention - Combined collection of items requiring immediate action
// Needs Attention - Combined collection of items requiring immediate action (not cached - real-time data)
$needsAttention = collect();
// 1. Low Stock SKUs (inventory items at or below reorder point)
@@ -183,22 +206,22 @@ class DashboardController extends Controller
}
// 3. Engaged Buyers with No Recent Orders
// Get buyers with high engagement but no orders in last 30 days
// Pre-fetch buyer IDs that HAVE ordered recently (single query instead of N+1)
$buyersWithRecentOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->distinct()
->pluck('orders.business_id')
->toArray();
// Get hot buyers that are NOT in the recent orders list
$engagedNoOrders = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->where('engagement_level', 'hot')
->whereNotIn('buyer_business_id', $buyersWithRecentOrders)
->with('buyerBusiness')
->get()
->filter(function ($score) use ($brandNames, $currentStart) {
// Check if they have NO orders in last 30 days
$hasRecentOrder = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.business_id', $score->buyer_business_id)
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->exists();
return ! $hasRecentOrder;
})
->take(3);
->orderByDesc('total_score')
->limit(3)
->get();
foreach ($engagedNoOrders as $score) {
$needsAttention->push([
@@ -937,23 +960,21 @@ class DashboardController extends Controller
],
];
// Invoice Statistics
$invoices = \App\Models\Invoice::with(['order.items.product.brand'])
->whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->get();
// Invoice Statistics - optimized with aggregate queries instead of loading all records
$invoiceBaseQuery = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
});
$stats = [
'total_invoices' => $invoices->count(),
'pending_invoices' => $invoices->where('payment_status', 'unpaid')->count(),
'paid_invoices' => $invoices->where('payment_status', 'paid')->count(),
'overdue_invoices' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
'total_outstanding' => $invoices->where('payment_status', 'unpaid')->sum('amount_due'),
'total_invoices' => (clone $invoiceBaseQuery)->count(),
'pending_invoices' => (clone $invoiceBaseQuery)->where('payment_status', 'unpaid')->count(),
'paid_invoices' => (clone $invoiceBaseQuery)->where('payment_status', 'paid')->count(),
'overdue_invoices' => (clone $invoiceBaseQuery)->where('payment_status', 'unpaid')->where('due_date', '<', now())->count(),
'total_outstanding' => (clone $invoiceBaseQuery)->where('payment_status', 'unpaid')->sum('amount_due'),
];
// Recent Invoices (last 5)
$recentInvoices = \App\Models\Invoice::with(['order.items.product.brand', 'business'])
// Recent Invoices (last 5) - only load what we need
$recentInvoices = \App\Models\Invoice::with(['order:id,business_id', 'business:id,name'])
->whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
@@ -980,24 +1001,21 @@ class DashboardController extends Controller
$fleetData = $this->getFleetMetrics($business);
}
// Get low-stock alerts for sales metrics
// Get low-stock alerts for sales metrics - optimized to single query
$lowStockAlerts = collect([]);
$lowStockCount = 0;
if ($showSalesMetrics) {
// Get active low-stock alerts for this business's brands
$lowStockAlerts = \App\Models\InventoryAlert::where('business_id', $business->id)
// Get active low-stock alerts with count in single query
$lowStockQuery = \App\Models\InventoryAlert::where('business_id', $business->id)
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
->active()
->with(['product', 'inventoryItem'])
->active();
$lowStockCount = (clone $lowStockQuery)->count();
$lowStockAlerts = $lowStockQuery
->with(['product:id,name', 'inventoryItem:id,quantity_on_hand'])
->latest('triggered_at')
->take(5)
->get();
// Count total low-stock items
$lowStockCount = \App\Models\InventoryAlert::where('business_id', $business->id)
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
->active()
->count();
}
// Get recent notifications for the dashboard widget
@@ -1068,24 +1086,33 @@ class DashboardController extends Controller
/**
* Generate revenue chart data for different time periods
* Supports 7 days, 30 days, and 12 months views
* Optimized to use single query for all periods
*/
private function getRevenueChartData(array $brandIds): array
{
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// 7 Days Data
$sevenDaysData = $this->getRevenueByPeriod($brandNames, 7, 'days');
// Single query for the full 12-month period (covers all time ranges)
$start = now()->subMonths(12)->startOfDay();
$allOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $start)
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
->groupBy('date')
->orderBy('date', 'asc')
->get();
// 30 Days Data
$thirtyDaysData = $this->getRevenueByPeriod($brandNames, 30, 'days');
// Filter for different time periods from the same result set
$sevenDaysStart = now()->subDays(7)->startOfDay();
$thirtyDaysStart = now()->subDays(30)->startOfDay();
// 12 Months Data
$twelveMonthsData = $this->getRevenueByPeriod($brandNames, 12, 'months');
$sevenDaysOrders = $allOrders->filter(fn ($o) => \Carbon\Carbon::parse($o->date) >= $sevenDaysStart);
$thirtyDaysOrders = $allOrders->filter(fn ($o) => \Carbon\Carbon::parse($o->date) >= $thirtyDaysStart);
return [
'7_days' => $sevenDaysData,
'30_days' => $thirtyDaysData,
'12_months' => $twelveMonthsData,
'7_days' => $this->generateDailyData($sevenDaysOrders, 7),
'30_days' => $this->generateDailyData($thirtyDaysOrders, 30),
'12_months' => $this->generateMonthlyData($allOrders, 12),
];
}
@@ -1179,6 +1206,7 @@ class DashboardController extends Controller
/**
* Get processing/manufacturing metrics for solventless departments
* Optimized with caching and reduced queries
*/
private function getProcessingMetrics(Business $business, $userDepartments): array
{
@@ -1190,59 +1218,52 @@ class DashboardController extends Controller
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Get wash reports (hash washes) - using Conversion model
$currentWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $currentStart)
->count();
// Cache processing metrics for 5 minutes
$cacheKey = "dashboard.processing.{$business->id}";
$cachedMetrics = Cache::remember($cacheKey, self::DASHBOARD_CACHE_TTL, function () use ($business, $currentStart, $previousStart, $previousEnd) {
// Fetch all completed washes in one query for both periods (60 days)
$allWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $previousStart)
->select('id', 'stage_1_metadata', 'stage_2_metadata', 'created_at')
->get();
$previousWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('created_at', [$previousStart, $previousEnd])
->count();
// Split into current and previous periods
$currentWashesData = $allWashes->filter(fn ($w) => $w->created_at >= $currentStart);
$previousWashesData = $allWashes->filter(fn ($w) => $w->created_at < $currentStart);
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
$currentWashes = $currentWashesData->count();
$previousWashes = $previousWashesData->count();
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
// Average Yield (calculate from metadata)
$currentWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $currentStart)
->get();
// Calculate yields from already-loaded data (avoid re-fetching)
$calculateYield = function ($collection) {
if ($collection->isEmpty()) {
return 0;
}
$currentYield = $currentWashesWithYield->avg(function ($conversion) {
$stage1 = $conversion->getStage1Data();
$stage2 = $conversion->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return $collection->avg(function ($conversion) {
$stage1 = $conversion->getStage1Data();
$stage2 = $conversion->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
}) ?? 0;
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
});
};
$previousWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('created_at', [$previousStart, $previousEnd])
->get();
$currentYield = $calculateYield($currentWashesData);
$previousYield = $calculateYield($previousWashesData);
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
$previousYield = $previousWashesWithYield->avg(function ($conversion) {
$stage1 = $conversion->getStage1Data();
$stage2 = $conversion->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return compact('currentWashes', 'previousWashes', 'washesChange', 'currentYield', 'previousYield', 'yieldChange');
});
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
}) ?? 0;
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
extract($cachedMetrics);
// Active Work Orders
$activeWorkOrders = \App\Models\WorkOrder::where('business_id', $business->id)
@@ -1347,18 +1368,29 @@ class DashboardController extends Controller
->limit(5) // Show top 5 on dashboard
->get();
// Add past performance data for each component
$componentsWithPerformance = $components->map(function ($component) use ($business) {
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
// Extract strain names from components
$strainNames = $components->map(fn ($c) => str_replace(' - Fresh Frozen', '', $c->name))->toArray();
// Get past washes for this strain
$pastWashes = \App\Models\Conversion::where('business_id', $business->id)
// Fetch ALL past washes for ALL strains in ONE query (eliminates N+1)
$allPastWashes = Cache::remember("dashboard.strain_washes.{$business->id}", self::DASHBOARD_CACHE_TTL, function () use ($business) {
return \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereJsonContains('metadata->stage_1->strain', $strainName)
->select('id', 'stage_1_metadata', 'stage_2_metadata', 'completed_at')
->orderBy('completed_at', 'desc')
->take(10)
->get();
->limit(100) // Get recent washes
->get()
->groupBy(function ($wash) {
$stage1 = $wash->getStage1Data();
return $stage1['strain'] ?? 'unknown';
});
});
// Add past performance data for each component (no additional queries)
$componentsWithPerformance = $components->map(function ($component) use ($allPastWashes) {
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
$pastWashes = ($allPastWashes[$strainName] ?? collect())->take(10);
if ($pastWashes->isEmpty()) {
$component->past_performance = [

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Accounting;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\ArInvoice;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Read-only accounting alias controllers for child businesses (divisions).
*
* Child businesses can view limited accounting data from their parent company.
* This provides visibility without granting write access to financial systems.
*
* Requirements:
* - Business must have parent_id (be a division)
* - User must have appropriate viewing permissions
*/
class DivisionAccountingController extends Controller
{
/**
* Display vendor list (read-only from parent company).
*
* GET /s/{business}/accounting/vendors
*/
public function vendorsIndex(Request $request, Business $business): View
{
$this->authorizeChildBusiness($business);
// Get parent's vendors
$parentId = $business->parent_id;
$query = ApVendor::where('business_id', $parentId)
->where('is_active', true);
// Search filter
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$vendors = $query->orderBy('name')->paginate(30)->withQueryString();
return view('seller.accounting.vendors.index', [
'business' => $business,
'vendors' => $vendors,
'filters' => $request->only(['search']),
]);
}
/**
* Display AR snapshot (read-only summary for division).
*
* GET /s/{business}/accounting/ar-snapshot
*/
public function arSnapshot(Request $request, Business $business): View
{
$this->authorizeChildBusiness($business);
$parentId = $business->parent_id;
// Get AR summary stats (scoped to this division's invoices if possible,
// otherwise show high-level parent metrics)
$stats = [
'total_outstanding' => ArInvoice::where('business_id', $business->id)
->where('status', '!=', 'paid')
->sum('balance_due'),
'overdue_count' => ArInvoice::where('business_id', $business->id)
->where('status', '!=', 'paid')
->where('due_date', '<', now())
->count(),
'overdue_amount' => ArInvoice::where('business_id', $business->id)
->where('status', '!=', 'paid')
->where('due_date', '<', now())
->sum('balance_due'),
'current_month_billed' => ArInvoice::where('business_id', $business->id)
->whereMonth('invoice_date', now()->month)
->whereYear('invoice_date', now()->year)
->sum('total_amount'),
];
// Recent invoices for this division
$recentInvoices = ArInvoice::where('business_id', $business->id)
->with('customer')
->orderByDesc('invoice_date')
->limit(10)
->get();
return view('seller.accounting.ar-snapshot', [
'business' => $business,
'stats' => $stats,
'recentInvoices' => $recentInvoices,
]);
}
/**
* Display AP snapshot (read-only summary for division).
*
* GET /s/{business}/accounting/ap-snapshot
*/
public function apSnapshot(Request $request, Business $business): View
{
$this->authorizeChildBusiness($business);
$parentId = $business->parent_id;
// Get AP summary stats scoped to this division's bills
$stats = [
'total_outstanding' => ApBill::where('business_id', $business->id)
->whereIn('status', ['approved', 'partial'])
->sum('balance_due'),
'overdue_count' => ApBill::where('business_id', $business->id)
->whereIn('status', ['approved', 'partial'])
->where('due_date', '<', now())
->count(),
'overdue_amount' => ApBill::where('business_id', $business->id)
->whereIn('status', ['approved', 'partial'])
->where('due_date', '<', now())
->sum('balance_due'),
'pending_approval' => ApBill::where('business_id', $business->id)
->whereIn('status', ['draft', 'pending'])
->count(),
];
// Recent bills for this division
$recentBills = ApBill::where('business_id', $business->id)
->with('vendor')
->orderByDesc('bill_date')
->limit(10)
->get();
return view('seller.accounting.ap-snapshot', [
'business' => $business,
'stats' => $stats,
'recentBills' => $recentBills,
]);
}
/**
* Ensure this is a child business with parent_id.
*/
protected function authorizeChildBusiness(Business $business): void
{
if ($business->parent_id === null) {
abort(404, 'This feature is only available for division businesses.');
}
}
}

View File

@@ -250,10 +250,14 @@ class BrandController extends Controller
->orderBy('name')
->get();
// Load products for this brand (newest first)
$products = $brand->products()
// Load products for this brand (newest first) with pagination
$perPage = $request->get('per_page', 50);
$productsPaginator = $brand->products()
->with('images')
->orderBy('created_at', 'desc')
->get()
->paginate($perPage);
$products = $productsPaginator->getCollection()
->map(function ($product) use ($business, $brand) {
// Set brand relationship so getImageUrl() can fall back to brand logo
$product->setRelation('brand', $brand);
@@ -273,6 +277,16 @@ class BrandController extends Controller
];
});
// Pagination info for the view
$productsPagination = [
'current_page' => $productsPaginator->currentPage(),
'last_page' => $productsPaginator->lastPage(),
'per_page' => $productsPaginator->perPage(),
'total' => $productsPaginator->total(),
'from' => $productsPaginator->firstItem(),
'to' => $productsPaginator->lastItem(),
];
return view('seller.brands.dashboard', array_merge($stats, [
'business' => $business,
'brand' => $brand,
@@ -286,6 +300,8 @@ class BrandController extends Controller
'recommendations' => $recommendations,
'menus' => $menus,
'products' => $products,
'productsPagination' => $productsPagination,
'productsPaginator' => $productsPaginator,
'collections' => collect(), // Placeholder for future collections feature
]));
}
@@ -293,31 +309,31 @@ class BrandController extends Controller
/**
* Preview the brand as it would appear to buyers
*/
public function preview(Business $business, Brand $brand)
public function preview(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// Load relationships including active products with images, strain, unit, and product line
// Only load parent products (exclude varieties from top level) and eager load their varieties
$brand->load([
'business',
'products' => function ($query) {
$query->where('is_active', true)
->whereNull('parent_product_id') // Only parent products
->with([
'images',
'strain',
'unit',
'productLine',
'varieties' => function ($q) {
$q->where('is_active', true)
->with(['images', 'strain', 'unit'])
->orderBy('name');
},
])
->orderBy('name');
},
]);
// Load brand with business relationship
$brand->load('business');
// Paginate products (50 per page) instead of loading all
$perPage = $request->get('per_page', 50);
$productsPaginator = $brand->products()
->where('is_active', true)
->whereNull('parent_product_id') // Only parent products
->with([
'images',
'strain',
'unit',
'productLine',
'varieties' => function ($q) {
$q->where('is_active', true)
->with(['images', 'strain', 'unit'])
->orderBy('name');
},
])
->orderBy('name')
->paginate($perPage);
// Get other brands from the same business
$otherBrands = Brand::where('business_id', $brand->business_id)
@@ -325,15 +341,15 @@ class BrandController extends Controller
->orderBy('name')
->get();
// Group products by product line
$productsByLine = $brand->products->groupBy(function ($product) {
// Group paginated products by product line
$productsByLine = $productsPaginator->getCollection()->groupBy(function ($product) {
return $product->productLine->name ?? 'Uncategorized';
});
// Allow viewing as buyer with ?as=buyer query parameter (for testing)
$isSeller = request()->query('as') !== 'buyer';
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'isSeller'));
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'productsPaginator', 'isSeller'));
}
/**

View File

@@ -14,8 +14,12 @@ use Illuminate\Support\Facades\Auth;
/**
* Brand Portal Controller
*
* Handles all Brand Portal functionality for external brand partners.
* Brand Portal users have read-only access to data scoped to their linked brands.
* Handles all Brand Portal functionality for external brand partners and brand managers.
* Both user types have read-only access to data scoped to their linked brands.
*
* Supported access modes:
* - Brand Portal users (in "Brand Partner" department with linked brands)
* - Brand Manager users (contact_type = 'brand_manager' with linked brands)
*
* Key constraints:
* - All data is scoped to the user's linked brands (via brand_user pivot)
@@ -25,6 +29,26 @@ use Illuminate\Support\Facades\Auth;
*/
class BrandPortalController extends Controller
{
/**
* Check if user has brand access (Portal or Manager) and get their brand IDs.
*/
protected function validateAccessAndGetBrandIds(Business $business): array
{
$user = Auth::user();
// Check for Brand Portal access
if ($user->isBrandPortalUser($business)) {
return $user->getBrandIdsForPortal($business);
}
// Check for Brand Manager access
if ($user->isBrandManagerUser($business)) {
return $user->getBrandIdsForManager($business);
}
abort(403, 'Access denied. Brand Portal or Brand Manager access required.');
}
/**
* Brand Portal Dashboard - Brand Overview.
*
@@ -36,14 +60,7 @@ class BrandPortalController extends Controller
*/
public function dashboard(Business $business)
{
$user = Auth::user();
// Ensure user is in Brand Portal mode
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Summary stats
@@ -90,13 +107,7 @@ class BrandPortalController extends Controller
*/
public function orders(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Filter by brand if specified
@@ -135,13 +146,7 @@ class BrandPortalController extends Controller
*/
public function accounts(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Get businesses that have ordered products from linked brands
@@ -177,13 +182,7 @@ class BrandPortalController extends Controller
*/
public function inventory(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Filter by brand if specified
@@ -233,13 +232,7 @@ class BrandPortalController extends Controller
*/
public function promotions(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Filter by brand if specified
@@ -280,13 +273,7 @@ class BrandPortalController extends Controller
*/
public function inbox(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// For inbox, we show conversations but in a limited Brand Portal context
@@ -305,13 +292,7 @@ class BrandPortalController extends Controller
*/
public function contacts(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// For contacts, show in Brand Portal context
@@ -326,13 +307,7 @@ class BrandPortalController extends Controller
*/
public function showOrder(Business $business, Order $order)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
// Verify order contains products from user's linked brands
$hasLinkedBrandProducts = $order->items()
@@ -364,13 +339,7 @@ class BrandPortalController extends Controller
*/
public function showProduct(Business $business, Product $product)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
// Verify product belongs to user's linked brands
if (! in_array($product->brand_id, $brandIds)) {

View File

@@ -37,8 +37,28 @@ class CategoryController extends Controller
public function index(Business $business)
{
// Product categories table is not properly set up - skipping for now
$productCategories = collect();
// Load product categories with nesting and counts (include parent if division)
// Use recursive eager loading for nested children
$productCategories = ProductCategory::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->whereNull('parent_id')
->with(['children' => function ($query) {
$query->orderBy('sort_order')->orderBy('name')
->with(['children' => function ($q) {
$q->orderBy('sort_order')->orderBy('name')
->with(['children' => function ($q2) {
$q2->orderBy('sort_order')->orderBy('name');
}]);
}]);
}])
->withCount('products')
->orderBy('sort_order')
->orderBy('name')
->get();
// Load component categories with nesting and counts (include parent if division)
$componentCategories = ComponentCategory::where(function ($query) use ($business) {

View File

@@ -3,7 +3,12 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Crm\CrmEvent;
use App\Models\Crm\CrmTask;
use App\Models\SalesOpportunity;
use App\Models\SendMenuLog;
use Illuminate\Http\Request;
class AccountController extends Controller
@@ -27,13 +32,84 @@ class AccountController extends Controller
*/
public function show(Request $request, Business $business, Business $account)
{
$account->load(['contacts', 'orders' => function ($q) use ($business) {
$q->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
})->latest()->limit(10);
}]);
$account->load(['contacts']);
return view('seller.crm.accounts.show', compact('business', 'account'));
// Get orders for this account from this seller
$orders = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->latest()
->limit(10)
->get();
// Get opportunities for this account from this seller
// SalesOpportunity uses business_id for the buyer
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand'])
->latest()
->get();
// Get tasks related to this account
// CrmTask uses business_id for the buyer
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->whereNull('completed_at')
->with('assignee')
->orderBy('due_at')
->limit(5)
->get();
// Get conversation events for this account
$conversationEvents = CrmEvent::where('seller_business_id', $business->id)
->where('buyer_business_id', $account->id)
->latest('occurred_at')
->limit(20)
->get();
// Get menu send history for this account
$sendHistory = SendMenuLog::where('business_id', $business->id)
->where('customer_id', $account->id)
->with(['menu', 'brand'])
->latest('sent_at')
->limit(10)
->get();
// Get activity log for this account
$activities = Activity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['causer'])
->latest()
->limit(20)
->get();
// Compute stats for this account (orders from this seller)
$ordersQuery = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
});
$pipelineValue = $opportunities->where('status', 'open')->sum('value');
$stats = [
'total_orders' => $ordersQuery->count(),
'total_revenue' => $ordersQuery->sum('total') ?? 0,
'open_opportunities' => $opportunities->where('status', 'open')->count(),
'pipeline_value' => $pipelineValue ?? 0,
];
return view('seller.crm.accounts.show', compact(
'business',
'account',
'stats',
'orders',
'opportunities',
'tasks',
'conversationEvents',
'sendHistory',
'activities'
));
}
/**
@@ -77,4 +153,27 @@ class AccountController extends Controller
{
return view('seller.crm.accounts.tasks', compact('business', 'account'));
}
/**
* Store a note for an account
*/
public function storeNote(Request $request, Business $business, Business $account)
{
$request->validate([
'note' => 'required|string|max:5000',
]);
CrmEvent::log(
sellerBusinessId: $business->id,
eventType: 'note_added',
summary: $request->input('note'),
buyerBusinessId: $account->id,
userId: auth()->id(),
channel: 'system'
);
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Note added successfully.');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmAutomation;
use App\Models\Crm\CrmAutomationAction;
use App\Models\Crm\CrmAutomationCondition;
@@ -13,10 +14,8 @@ class AutomationController extends Controller
/**
* List automations
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$automations = CrmAutomation::forBusiness($business->id)
->with('creator')
->withCount(['logs as successful_runs' => fn ($q) => $q->completed()])
@@ -41,10 +40,8 @@ class AutomationController extends Controller
/**
* Store automation
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
@@ -107,10 +104,8 @@ class AutomationController extends Controller
/**
* Show automation details
*/
public function show(Request $request, CrmAutomation $automation)
public function show(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -123,10 +118,8 @@ class AutomationController extends Controller
/**
* Edit automation
*/
public function edit(Request $request, CrmAutomation $automation)
public function edit(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -143,10 +136,8 @@ class AutomationController extends Controller
/**
* Update automation
*/
public function update(Request $request, CrmAutomation $automation)
public function update(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -218,10 +209,8 @@ class AutomationController extends Controller
/**
* Toggle automation active status
*/
public function toggle(Request $request, CrmAutomation $automation)
public function toggle(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -238,10 +227,8 @@ class AutomationController extends Controller
/**
* Duplicate automation
*/
public function duplicate(Request $request, CrmAutomation $automation)
public function duplicate(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -255,10 +242,8 @@ class AutomationController extends Controller
/**
* Delete automation
*/
public function destroy(Request $request, CrmAutomation $automation)
public function destroy(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Jobs\Crm\SyncCalendarJob;
use App\Models\Business;
use App\Models\Crm\CrmCalendarConnection;
use App\Models\Crm\CrmSyncedEvent;
use App\Services\Crm\CrmCalendarService;
@@ -18,9 +19,8 @@ class CrmCalendarController extends Controller
/**
* Calendar view
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
// Get calendar connections
@@ -32,15 +32,15 @@ class CrmCalendarController extends Controller
$startDate = $request->input('start', now()->startOfMonth());
$endDate = $request->input('end', now()->endOfMonth());
$events = CrmSyncedEvent::whereIn('connection_id', $connections->pluck('id'))
->whereBetween('start_time', [$startDate, $endDate])
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections->pluck('id'))
->whereBetween('start_at', [$startDate, $endDate])
->get()
->map(fn ($e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_time->toIso8601String(),
'end' => $e->end_time->toIso8601String(),
'allDay' => $e->is_all_day,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
'extendedProps' => [
'location' => $e->location,
@@ -50,54 +50,55 @@ class CrmCalendarController extends Controller
]);
// Get meeting bookings
$bookings = \Modules\Crm\Entities\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
$bookings = \App\Models\Crm\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
$q->where('business_id', $business->id)
->where('user_id', $user->id);
})
->whereBetween('start_time', [$startDate, $endDate])
->whereBetween('start_at', [$startDate, $endDate])
->with(['meetingLink', 'contact'])
->get()
->map(fn ($b) => [
'id' => 'booking_'.$b->id,
'title' => $b->meetingLink->name.' - '.$b->guest_name,
'start' => $b->start_time->toIso8601String(),
'end' => $b->end_time->toIso8601String(),
'title' => $b->meetingLink->name.' - '.$b->booker_name,
'start' => $b->start_at->toIso8601String(),
'end' => $b->end_at->toIso8601String(),
'color' => '#10b981',
'extendedProps' => [
'type' => 'booking',
'contact_id' => $b->contact_id,
'guest_email' => $b->guest_email,
'booker_email' => $b->booker_email,
],
]);
$allEvents = $events->merge($bookings);
return view('seller.crm.calendar.index', compact('connections', 'allEvents'));
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
return view('seller.crm.calendar.index', compact('business', 'connections', 'allEvents'));
}
/**
* Calendar connections settings
*/
public function connections(Request $request)
public function connections(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$connections = CrmCalendarConnection::where('business_id', $business->id)
->where('user_id', $user->id)
->get();
return view('seller.crm.calendar.connections', compact('connections'));
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
return view('seller.crm.calendar.connections', compact('business', 'connections'));
}
/**
* Start OAuth flow for Google Calendar
*/
public function connectGoogle(Request $request)
public function connectGoogle(Request $request, Business $business)
{
$state = encrypt([
'user_id' => $request->user()->id,
'business_id' => $request->user()->business_id,
'business_id' => $business->id,
'provider' => 'google',
]);
@@ -117,11 +118,11 @@ class CrmCalendarController extends Controller
/**
* Start OAuth flow for Outlook Calendar
*/
public function connectOutlook(Request $request)
public function connectOutlook(Request $request, Business $business)
{
$state = encrypt([
'user_id' => $request->user()->id,
'business_id' => $request->user()->business_id,
'business_id' => $business->id,
'provider' => 'outlook',
]);
@@ -195,10 +196,8 @@ class CrmCalendarController extends Controller
/**
* Disconnect a calendar
*/
public function disconnect(Request $request, CrmCalendarConnection $connection)
public function disconnect(Request $request, Business $business, CrmCalendarConnection $connection)
{
$business = $request->user()->business;
if ($connection->business_id !== $business->id) {
abort(404);
}
@@ -215,10 +214,8 @@ class CrmCalendarController extends Controller
/**
* Toggle sync for a connection
*/
public function toggleSync(Request $request, CrmCalendarConnection $connection)
public function toggleSync(Request $request, Business $business, CrmCalendarConnection $connection)
{
$business = $request->user()->business;
if ($connection->business_id !== $business->id) {
abort(404);
}
@@ -231,10 +228,8 @@ class CrmCalendarController extends Controller
/**
* Force sync a calendar
*/
public function sync(Request $request, CrmCalendarConnection $connection)
public function sync(Request $request, Business $business, CrmCalendarConnection $connection)
{
$business = $request->user()->business;
if ($connection->business_id !== $business->id) {
abort(404);
}
@@ -247,9 +242,8 @@ class CrmCalendarController extends Controller
/**
* API: Get events for date range (for calendar JS)
*/
public function events(Request $request)
public function events(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$validated = $request->validate([
@@ -261,15 +255,15 @@ class CrmCalendarController extends Controller
->where('user_id', $user->id)
->pluck('id');
$events = CrmSyncedEvent::whereIn('connection_id', $connections)
->whereBetween('start_time', [$validated['start'], $validated['end']])
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
->whereBetween('start_at', [$validated['start'], $validated['end']])
->get()
->map(fn ($e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_time->toIso8601String(),
'end' => $e->end_time->toIso8601String(),
'allDay' => $e->is_all_day,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
]);
return response()->json($events);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmRepMetric;
use App\Models\Crm\CrmSlaTimer;
@@ -20,9 +21,8 @@ class CrmDashboardController extends Controller
/**
* Main CRM dashboard
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
// Cache dashboard data for 1 minute
@@ -32,15 +32,17 @@ class CrmDashboardController extends Controller
return $this->getDashboardData($business, $user);
});
// Ensure $business is always passed to view (not cached)
$data['business'] = $business;
return view('seller.crm.dashboard.index', $data);
}
/**
* Sales performance dashboard
*/
public function sales(Request $request)
public function sales(Request $request, Business $business)
{
$business = $request->user()->business;
// Pipeline summary
$pipelineSummary = CrmDeal::forBusiness($business->id)
@@ -91,9 +93,8 @@ class CrmDashboardController extends Controller
/**
* Team performance dashboard
*/
public function team(Request $request)
public function team(Request $request, Business $business)
{
$business = $request->user()->business;
// SLA metrics
$slaMetrics = $this->slaService->getMetrics($business->id, 30);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmMessageTemplate;
use App\Models\Crm\CrmPipeline;
@@ -16,9 +17,8 @@ class CrmSettingsController extends Controller
/**
* Settings overview
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$stats = [
'channels' => CrmChannel::where('business_id', $business->id)->count(),
@@ -36,9 +36,8 @@ class CrmSettingsController extends Controller
/**
* List channels
*/
public function channels(Request $request)
public function channels(Request $request, Business $business)
{
$business = $request->user()->business;
$channels = CrmChannel::where('business_id', $business->id)
->orderBy('type')
@@ -61,9 +60,8 @@ class CrmSettingsController extends Controller
/**
* Store channel
*/
public function storeChannel(Request $request)
public function storeChannel(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
@@ -95,9 +93,8 @@ class CrmSettingsController extends Controller
/**
* Edit channel
*/
public function editChannel(Request $request, CrmChannel $channel)
public function editChannel(Request $request, Business $business, CrmChannel $channel)
{
$business = $request->user()->business;
if ($channel->business_id !== $business->id) {
abort(404);
@@ -111,9 +108,8 @@ class CrmSettingsController extends Controller
/**
* Update channel
*/
public function updateChannel(Request $request, CrmChannel $channel)
public function updateChannel(Request $request, Business $business, CrmChannel $channel)
{
$business = $request->user()->business;
if ($channel->business_id !== $business->id) {
abort(404);
@@ -143,9 +139,8 @@ class CrmSettingsController extends Controller
/**
* Delete channel
*/
public function destroyChannel(Request $request, CrmChannel $channel)
public function destroyChannel(Request $request, Business $business, CrmChannel $channel)
{
$business = $request->user()->business;
if ($channel->business_id !== $business->id) {
abort(404);
@@ -161,9 +156,8 @@ class CrmSettingsController extends Controller
/**
* List pipelines
*/
public function pipelines(Request $request)
public function pipelines(Request $request, Business $business)
{
$business = $request->user()->business;
$pipelines = CrmPipeline::where('business_id', $business->id)
->withCount('deals')
@@ -184,9 +178,8 @@ class CrmSettingsController extends Controller
/**
* Store pipeline
*/
public function storePipeline(Request $request)
public function storePipeline(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
@@ -220,9 +213,8 @@ class CrmSettingsController extends Controller
/**
* Edit pipeline
*/
public function editPipeline(Request $request, CrmPipeline $pipeline)
public function editPipeline(Request $request, Business $business, CrmPipeline $pipeline)
{
$business = $request->user()->business;
if ($pipeline->business_id !== $business->id) {
abort(404);
@@ -234,9 +226,8 @@ class CrmSettingsController extends Controller
/**
* Update pipeline
*/
public function updatePipeline(Request $request, CrmPipeline $pipeline)
public function updatePipeline(Request $request, Business $business, CrmPipeline $pipeline)
{
$business = $request->user()->business;
if ($pipeline->business_id !== $business->id) {
abort(404);
@@ -269,9 +260,8 @@ class CrmSettingsController extends Controller
/**
* Delete pipeline
*/
public function destroyPipeline(Request $request, CrmPipeline $pipeline)
public function destroyPipeline(Request $request, Business $business, CrmPipeline $pipeline)
{
$business = $request->user()->business;
if ($pipeline->business_id !== $business->id) {
abort(404);
@@ -291,9 +281,8 @@ class CrmSettingsController extends Controller
/**
* List SLA policies
*/
public function slaPolicies(Request $request)
public function slaPolicies(Request $request, Business $business)
{
$business = $request->user()->business;
$policies = CrmSlaPolicy::where('business_id', $business->id)
->orderBy('priority')
@@ -313,9 +302,8 @@ class CrmSettingsController extends Controller
/**
* Store SLA policy
*/
public function storeSlaPolicy(Request $request)
public function storeSlaPolicy(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
@@ -349,9 +337,8 @@ class CrmSettingsController extends Controller
/**
* Edit SLA policy
*/
public function editSlaPolicy(Request $request, CrmSlaPolicy $policy)
public function editSlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
{
$business = $request->user()->business;
if ($policy->business_id !== $business->id) {
abort(404);
@@ -363,9 +350,8 @@ class CrmSettingsController extends Controller
/**
* Update SLA policy
*/
public function updateSlaPolicy(Request $request, CrmSlaPolicy $policy)
public function updateSlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
{
$business = $request->user()->business;
if ($policy->business_id !== $business->id) {
abort(404);
@@ -392,9 +378,8 @@ class CrmSettingsController extends Controller
/**
* Delete SLA policy
*/
public function destroySlaPolicy(Request $request, CrmSlaPolicy $policy)
public function destroySlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
{
$business = $request->user()->business;
if ($policy->business_id !== $business->id) {
abort(404);
@@ -410,9 +395,8 @@ class CrmSettingsController extends Controller
/**
* List tags
*/
public function tags(Request $request)
public function tags(Request $request, Business $business)
{
$business = $request->user()->business;
$tags = CrmTag::where('business_id', $business->id)
->withCount('taggables')
@@ -425,9 +409,8 @@ class CrmSettingsController extends Controller
/**
* Store tag
*/
public function storeTag(Request $request)
public function storeTag(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:50',
@@ -448,9 +431,8 @@ class CrmSettingsController extends Controller
/**
* Update tag
*/
public function updateTag(Request $request, CrmTag $tag)
public function updateTag(Request $request, Business $business, CrmTag $tag)
{
$business = $request->user()->business;
if ($tag->business_id !== $business->id) {
abort(404);
@@ -470,9 +452,8 @@ class CrmSettingsController extends Controller
/**
* Delete tag
*/
public function destroyTag(Request $request, CrmTag $tag)
public function destroyTag(Request $request, Business $business, CrmTag $tag)
{
$business = $request->user()->business;
if ($tag->business_id !== $business->id) {
abort(404);
@@ -488,9 +469,8 @@ class CrmSettingsController extends Controller
/**
* List templates
*/
public function templates(Request $request)
public function templates(Request $request, Business $business)
{
$business = $request->user()->business;
$templates = CrmMessageTemplate::where('business_id', $business->id)
->orderBy('category')
@@ -515,9 +495,8 @@ class CrmSettingsController extends Controller
/**
* Store template
*/
public function storeTemplate(Request $request)
public function storeTemplate(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
@@ -547,9 +526,8 @@ class CrmSettingsController extends Controller
/**
* Edit template
*/
public function editTemplate(Request $request, CrmMessageTemplate $template)
public function editTemplate(Request $request, Business $business, CrmMessageTemplate $template)
{
$business = $request->user()->business;
if ($template->business_id !== $business->id) {
abort(404);
@@ -564,9 +542,8 @@ class CrmSettingsController extends Controller
/**
* Update template
*/
public function updateTemplate(Request $request, CrmMessageTemplate $template)
public function updateTemplate(Request $request, Business $business, CrmMessageTemplate $template)
{
$business = $request->user()->business;
if ($template->business_id !== $business->id) {
abort(404);
@@ -590,9 +567,8 @@ class CrmSettingsController extends Controller
/**
* Delete template
*/
public function destroyTemplate(Request $request, CrmMessageTemplate $template)
public function destroyTemplate(Request $request, Business $business, CrmMessageTemplate $template)
{
$business = $request->user()->business;
if ($template->business_id !== $business->id) {
abort(404);
@@ -608,9 +584,8 @@ class CrmSettingsController extends Controller
/**
* List team roles
*/
public function teamRoles(Request $request)
public function teamRoles(Request $request, Business $business)
{
$business = $request->user()->business;
$roles = CrmTeamRole::where('business_id', $business->id)
->withCount('users')
@@ -623,9 +598,8 @@ class CrmSettingsController extends Controller
/**
* Store team role
*/
public function storeTeamRole(Request $request)
public function storeTeamRole(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:100',
@@ -644,9 +618,8 @@ class CrmSettingsController extends Controller
/**
* Update team role
*/
public function updateTeamRole(Request $request, CrmTeamRole $role)
public function updateTeamRole(Request $request, Business $business, CrmTeamRole $role)
{
$business = $request->user()->business;
if ($role->business_id !== $business->id) {
abort(404);
@@ -665,9 +638,8 @@ class CrmSettingsController extends Controller
/**
* Delete team role
*/
public function destroyTeamRole(Request $request, CrmTeamRole $role)
public function destroyTeamRole(Request $request, Business $business, CrmTeamRole $role)
{
$business = $request->user()->business;
if ($role->business_id !== $business->id) {
abort(404);

View File

@@ -22,10 +22,8 @@ class DealController extends Controller
/**
* Display pipeline board view
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
// Get active pipeline
$pipeline = CrmPipeline::forBusiness($business->id)
->where('id', $request->input('pipeline_id'))
@@ -60,7 +58,7 @@ class DealController extends Controller
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
// Get team members
$teamMembers = User::where('business_id', $business->id)->get();
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
// Calculate stats
$stats = [
@@ -73,22 +71,20 @@ class DealController extends Controller
->sum('value'),
];
return view('seller.crm.deals.index', compact('pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
return view('seller.crm.deals.index', compact('business', 'pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
}
/**
* Show create deal form
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = $request->user()->business;
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})->get();
$teamMembers = User::where('business_id', $business->id)->get();
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
$brands = Brand::where('business_id', $business->id)->get();
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands'));
@@ -97,10 +93,8 @@ class DealController extends Controller
/**
* Store new deal
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'pipeline_id' => 'required|exists:crm_pipelines,id',
@@ -132,7 +126,7 @@ class DealController extends Controller
// SECURITY: Verify owner belongs to business
if (! empty($validated['owner_id'])) {
User::where('id', $validated['owner_id'])
->where('business_id', $business->id)
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->firstOrFail();
}
@@ -168,10 +162,8 @@ class DealController extends Controller
/**
* Show deal details
*/
public function show(Request $request, CrmDeal $deal)
public function show(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -205,10 +197,8 @@ class DealController extends Controller
/**
* Update deal stage (drag & drop)
*/
public function updateStage(Request $request, CrmDeal $deal)
public function updateStage(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
@@ -232,10 +222,8 @@ class DealController extends Controller
/**
* Mark deal as won
*/
public function markWon(Request $request, CrmDeal $deal)
public function markWon(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -253,10 +241,8 @@ class DealController extends Controller
/**
* Mark deal as lost
*/
public function markLost(Request $request, CrmDeal $deal)
public function markLost(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -280,10 +266,8 @@ class DealController extends Controller
/**
* Reopen a closed deal
*/
public function reopen(Request $request, CrmDeal $deal)
public function reopen(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -296,10 +280,8 @@ class DealController extends Controller
/**
* Update deal details
*/
public function update(Request $request, CrmDeal $deal)
public function update(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -326,7 +308,7 @@ class DealController extends Controller
if (! empty($validated['owner_id'])) {
User::where('id', $validated['owner_id'])
->where('business_id', $business->id)
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->firstOrFail();
}
@@ -338,10 +320,8 @@ class DealController extends Controller
/**
* Delete deal
*/
public function destroy(Request $request, CrmDeal $deal)
public function destroy(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmInvoiceItem;
use App\Models\Crm\CrmInvoicePayment;
@@ -14,10 +15,8 @@ class InvoiceController extends Controller
/**
* List invoices
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$query = CrmInvoice::forBusiness($business->id)
->with(['contact', 'account', 'creator'])
->withCount('items');
@@ -56,10 +55,8 @@ class InvoiceController extends Controller
/**
* Show invoice details
*/
public function show(Request $request, CrmInvoice $invoice)
public function show(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -72,10 +69,8 @@ class InvoiceController extends Controller
/**
* Create invoice form
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = $request->user()->business;
$contacts = \App\Models\Contact::where('business_id', $business->id)->get();
$quotes = CrmQuote::forBusiness($business->id)
->where('status', CrmQuote::STATUS_ACCEPTED)
@@ -88,10 +83,8 @@ class InvoiceController extends Controller
/**
* Store new invoice
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
@@ -158,10 +151,8 @@ class InvoiceController extends Controller
/**
* Send invoice to contact
*/
public function send(Request $request, CrmInvoice $invoice)
public function send(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -180,10 +171,8 @@ class InvoiceController extends Controller
/**
* Record a payment
*/
public function recordPayment(Request $request, CrmInvoice $invoice)
public function recordPayment(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -223,10 +212,8 @@ class InvoiceController extends Controller
/**
* Mark invoice as void
*/
public function void(Request $request, CrmInvoice $invoice)
public function void(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -247,10 +234,8 @@ class InvoiceController extends Controller
/**
* Download invoice PDF
*/
public function download(Request $request, CrmInvoice $invoice)
public function download(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -262,10 +247,8 @@ class InvoiceController extends Controller
/**
* Delete invoice
*/
public function destroy(Request $request, CrmInvoice $invoice)
public function destroy(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmMeetingLink;
use App\Services\Crm\CrmCalendarService;
@@ -17,9 +18,8 @@ class MeetingLinkController extends Controller
/**
* List meeting links
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$meetingLinks = CrmMeetingLink::where('business_id', $business->id)
@@ -42,9 +42,8 @@ class MeetingLinkController extends Controller
/**
* Store new meeting link
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$validated = $request->validate([
@@ -89,10 +88,8 @@ class MeetingLinkController extends Controller
/**
* Show meeting link details
*/
public function show(Request $request, CrmMeetingLink $meetingLink)
public function show(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -105,10 +102,8 @@ class MeetingLinkController extends Controller
/**
* Edit meeting link
*/
public function edit(Request $request, CrmMeetingLink $meetingLink)
public function edit(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -119,10 +114,8 @@ class MeetingLinkController extends Controller
/**
* Update meeting link
*/
public function update(Request $request, CrmMeetingLink $meetingLink)
public function update(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -150,10 +143,8 @@ class MeetingLinkController extends Controller
/**
* Toggle active status
*/
public function toggle(Request $request, CrmMeetingLink $meetingLink)
public function toggle(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -166,10 +157,8 @@ class MeetingLinkController extends Controller
/**
* Delete meeting link
*/
public function destroy(Request $request, CrmMeetingLink $meetingLink)
public function destroy(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -250,9 +239,8 @@ class MeetingLinkController extends Controller
/**
* List upcoming bookings
*/
public function bookings(Request $request)
public function bookings(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
@@ -270,10 +258,8 @@ class MeetingLinkController extends Controller
/**
* Cancel a booking
*/
public function cancelBooking(Request $request, CrmMeetingBooking $booking)
public function cancelBooking(Request $request, Business $business, CrmMeetingBooking $booking)
{
$business = $request->user()->business;
if ($booking->meetingLink->business_id !== $business->id) {
abort(404);
}

View File

@@ -9,6 +9,7 @@ use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmQuoteItem;
use App\Models\Product;
use App\Services\Accounting\ArService;
use Illuminate\Http\Request;
class QuoteController extends Controller
@@ -16,10 +17,8 @@ class QuoteController extends Controller
/**
* List quotes
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$query = CrmQuote::forBusiness($business->id)
->with(['contact', 'account', 'deal', 'creator'])
->withCount('items');
@@ -44,10 +43,8 @@ class QuoteController extends Controller
/**
* Show create quote form
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = $request->user()->business;
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
@@ -68,10 +65,8 @@ class QuoteController extends Controller
/**
* Store new quote
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
@@ -148,10 +143,8 @@ class QuoteController extends Controller
/**
* Show quote details
*/
public function show(Request $request, CrmQuote $quote)
public function show(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -164,10 +157,8 @@ class QuoteController extends Controller
/**
* Edit quote
*/
public function edit(Request $request, CrmQuote $quote)
public function edit(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -191,10 +182,8 @@ class QuoteController extends Controller
/**
* Update quote
*/
public function update(Request $request, CrmQuote $quote)
public function update(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -253,10 +242,8 @@ class QuoteController extends Controller
/**
* Send quote to contact
*/
public function send(Request $request, CrmQuote $quote)
public function send(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -275,10 +262,8 @@ class QuoteController extends Controller
/**
* Convert quote to invoice
*/
public function convertToInvoice(Request $request, CrmQuote $quote)
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -291,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)
@@ -300,10 +309,8 @@ class QuoteController extends Controller
/**
* Download quote PDF
*/
public function download(Request $request, CrmQuote $quote)
public function download(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -315,10 +322,8 @@ class QuoteController extends Controller
/**
* Delete quote
*/
public function destroy(Request $request, CrmQuote $quote)
public function destroy(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmTask;
use App\Models\User;
use Illuminate\Http\Request;
class TaskController extends Controller
@@ -16,13 +17,17 @@ class TaskController extends Controller
{
$user = $request->user();
$tasksQuery = CrmTask::where('business_id', $business->id)
->with(['assignee', 'creator', 'related'])
->orderBy('due_date');
$tasksQuery = CrmTask::where('seller_business_id', $business->id)
->with(['assignee', 'creator', 'contact', 'business'])
->orderBy('due_at');
// Filter by status
// Filter by status (completed vs incomplete)
if ($request->filled('status')) {
$tasksQuery->where('status', $request->status);
if ($request->status === 'completed') {
$tasksQuery->whereNotNull('completed_at');
} elseif ($request->status === 'pending') {
$tasksQuery->whereNull('completed_at');
}
}
// Filter by assignee
@@ -39,21 +44,26 @@ class TaskController extends Controller
// Get stats
$stats = [
'my_tasks' => CrmTask::where('business_id', $business->id)
'my_tasks' => CrmTask::where('seller_business_id', $business->id)
->where('assigned_to', $user->id)
->where('status', '!=', 'completed')
->whereNull('completed_at')
->count(),
'overdue' => CrmTask::where('business_id', $business->id)
->where('status', '!=', 'completed')
->where('due_date', '<', now())
'overdue' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->count(),
'due_today' => CrmTask::where('business_id', $business->id)
->where('status', '!=', 'completed')
->whereDate('due_date', today())
'due_today' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->whereDate('due_at', today())
->count(),
];
return view('seller.crm.tasks.index', compact('business', 'tasks', 'stats'));
$counts = $stats; // View expects $counts
// Get team members for assignment filter
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers'));
}
/**
@@ -71,19 +81,26 @@ class TaskController extends Controller
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:call,email,meeting,task,follow_up',
'priority' => 'required|in:low,medium,high,urgent',
'due_date' => 'required|date',
'details' => 'nullable|string',
'type' => 'required|in:call,email,meeting,follow_up,demo,other',
'priority' => 'required|in:low,normal,high,urgent',
'due_at' => 'required|date',
'assigned_to' => 'nullable|exists:users,id',
'contact_id' => 'nullable|exists:contacts,id',
'business_id' => 'nullable|exists:businesses,id',
]);
$task = CrmTask::create([
...$validated,
'business_id' => $business->id,
'title' => $validated['title'],
'details' => $validated['details'] ?? null,
'type' => $validated['type'],
'priority' => $validated['priority'],
'due_at' => $validated['due_at'],
'contact_id' => $validated['contact_id'] ?? null,
'business_id' => $validated['business_id'] ?? null,
'seller_business_id' => $business->id,
'created_by' => $request->user()->id,
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
'status' => 'pending',
]);
return redirect()
@@ -96,7 +113,7 @@ class TaskController extends Controller
*/
public function show(Request $request, Business $business, CrmTask $task)
{
$task->load(['assignee', 'creator', 'related']);
$task->load(['assignee', 'creator', 'contact', 'business', 'opportunity', 'order']);
return view('seller.crm.tasks.show', compact('business', 'task'));
}
@@ -108,11 +125,10 @@ class TaskController extends Controller
{
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'type' => 'sometimes|in:call,email,meeting,task,follow_up',
'priority' => 'sometimes|in:low,medium,high,urgent',
'status' => 'sometimes|in:pending,in_progress,completed,cancelled',
'due_date' => 'sometimes|date',
'details' => 'nullable|string',
'type' => 'sometimes|in:call,email,meeting,follow_up,demo,other',
'priority' => 'sometimes|in:low,normal,high,urgent',
'due_at' => 'sometimes|date',
'assigned_to' => 'nullable|exists:users,id',
]);
@@ -140,10 +156,7 @@ class TaskController extends Controller
*/
public function complete(Request $request, Business $business, CrmTask $task)
{
$task->update([
'status' => 'completed',
'completed_at' => now(),
]);
$task->markComplete($request->user());
return redirect()
->back()

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmActiveView;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmInternalNote;
@@ -24,10 +25,8 @@ class ThreadController extends Controller
/**
* Display unified inbox
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount('messages');
@@ -66,21 +65,19 @@ class ThreadController extends Controller
->paginate(25);
// Get team members for assignment dropdown
$teamMembers = User::where('business_id', $business->id)->get();
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
return view('seller.crm.threads.index', compact('threads', 'teamMembers', 'channels'));
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels'));
}
/**
* Show a single thread
*/
public function show(Request $request, CrmThread $thread)
public function show(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
// SECURITY: Verify business ownership
if ($thread->business_id !== $business->id) {
abort(404);
@@ -116,22 +113,25 @@ class ThreadController extends Controller
// Get available channels for reply
$channels = $this->channelService->getAvailableChannels($business->id);
// Get team members for assignment dropdown
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
return view('seller.crm.threads.show', compact(
'business',
'thread',
'otherViewers',
'slaStatus',
'suggestions',
'channels'
'channels',
'teamMembers'
));
}
/**
* Send a reply in thread
*/
public function reply(Request $request, CrmThread $thread)
public function reply(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -177,10 +177,8 @@ class ThreadController extends Controller
/**
* Assign thread to user
*/
public function assign(Request $request, CrmThread $thread)
public function assign(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -191,7 +189,7 @@ class ThreadController extends Controller
// SECURITY: Verify user belongs to business
$assignee = User::where('id', $validated['assigned_to'])
->where('business_id', $business->id)
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->first();
if (! $assignee) {
@@ -206,10 +204,8 @@ class ThreadController extends Controller
/**
* Close thread
*/
public function close(Request $request, CrmThread $thread)
public function close(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -222,10 +218,8 @@ class ThreadController extends Controller
/**
* Reopen thread
*/
public function reopen(Request $request, CrmThread $thread)
public function reopen(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -241,10 +235,8 @@ class ThreadController extends Controller
/**
* Snooze thread
*/
public function snooze(Request $request, CrmThread $thread)
public function snooze(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -264,10 +256,8 @@ class ThreadController extends Controller
/**
* Add internal note
*/
public function addNote(Request $request, CrmThread $thread)
public function addNote(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -290,10 +280,8 @@ class ThreadController extends Controller
/**
* Generate AI reply draft
*/
public function generateAiReply(Request $request, CrmThread $thread)
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -313,10 +301,8 @@ class ThreadController extends Controller
/**
* Heartbeat for active viewing
*/
public function heartbeat(Request $request, CrmThread $thread)
public function heartbeat(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Expense;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Models\Department;
use App\Services\Accounting\ExpenseService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Staff/Child Business Expense Controller.
*
* Handles expense creation and submission by employees.
* Approval and payment are handled by Management controller.
*/
class ExpensesController extends Controller
{
public function __construct(
protected ExpenseService $expenseService
) {}
/**
* List expenses for the current business.
*
* GET /s/{business}/expenses
*/
public function index(Request $request, Business $business): View
{
$user = auth()->user();
$query = Expense::where('business_id', $business->id)
->with(['department', 'createdBy', 'items']);
// Non-admins only see their own expenses
if (! $this->canViewAllExpenses($user, $business)) {
$query->where('created_by_user_id', $user->id);
}
// Status filter
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Department filter
if ($request->filled('department_id')) {
$query->where('department_id', $request->department_id);
}
$expenses = $query->orderByDesc('expense_date')->paginate(20)->withQueryString();
// Get departments for filter
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Stats for current user
$myStats = [
'draft' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->status(Expense::STATUS_DRAFT)
->count(),
'submitted' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->status(Expense::STATUS_SUBMITTED)
->count(),
'approved' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->status(Expense::STATUS_APPROVED)
->count(),
'total_pending' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->whereIn('status', [Expense::STATUS_SUBMITTED, Expense::STATUS_APPROVED])
->sum('total_amount'),
];
return view('seller.expenses.index', compact(
'business',
'expenses',
'departments',
'myStats'
));
}
/**
* Show create expense form.
*
* GET /s/{business}/expenses/create
*/
public function create(Request $request, Business $business): View
{
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->where('account_type', 'expense')
->orderBy('account_number')
->get();
$paymentMethods = Expense::getPaymentMethods();
return view('seller.expenses.create', compact(
'business',
'departments',
'glAccounts',
'paymentMethods'
));
}
/**
* Store a new expense.
*
* POST /s/{business}/expenses
*/
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'expense_date' => 'required|date',
'department_id' => 'nullable|integer|exists:departments,id',
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
'reference' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.amount' => 'required|numeric|min:0.01',
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
'items.*.department_id' => 'nullable|integer|exists:departments,id',
'items.*.tax_amount' => 'nullable|numeric|min:0',
'submit' => 'nullable|boolean',
]);
$user = auth()->user();
$items = $validated['items'];
unset($validated['items'], $validated['submit']);
// Set default status
$validated['status'] = $request->boolean('submit')
? Expense::STATUS_SUBMITTED
: Expense::STATUS_DRAFT;
$expense = $this->expenseService->createExpense($business, $user, $validated, $items);
$message = $expense->isSubmitted()
? "Expense {$expense->expense_number} submitted for approval."
: "Expense {$expense->expense_number} saved as draft.";
return redirect()
->route('seller.business.expenses.show', [$business, $expense])
->with('success', $message);
}
/**
* Show expense details.
*
* GET /s/{business}/expenses/{expense}
*/
public function show(Request $request, Business $business, Expense $expense): View
{
$this->authorizeExpenseAccess($expense, $business);
$expense->load(['items.glAccount', 'items.department', 'department', 'createdBy', 'approvedBy', 'paidBy']);
return view('seller.expenses.show', compact('business', 'expense'));
}
/**
* Show edit expense form (draft only).
*
* GET /s/{business}/expenses/{expense}/edit
*/
public function edit(Request $request, Business $business, Expense $expense): View
{
$this->authorizeExpenseAccess($expense, $business);
if (! $expense->canEdit()) {
abort(403, 'Only draft expenses can be edited.');
}
$expense->load(['items']);
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->where('account_type', 'expense')
->orderBy('account_number')
->get();
$paymentMethods = Expense::getPaymentMethods();
return view('seller.expenses.edit', compact(
'business',
'expense',
'departments',
'glAccounts',
'paymentMethods'
));
}
/**
* Update an expense (draft only).
*
* PUT /s/{business}/expenses/{expense}
*/
public function update(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->authorizeExpenseAccess($expense, $business);
if (! $expense->canEdit()) {
return back()->with('error', 'Only draft expenses can be edited.');
}
$validated = $request->validate([
'expense_date' => 'required|date',
'department_id' => 'nullable|integer|exists:departments,id',
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
'reference' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.amount' => 'required|numeric|min:0.01',
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
'items.*.department_id' => 'nullable|integer|exists:departments,id',
'items.*.tax_amount' => 'nullable|numeric|min:0',
'submit' => 'nullable|boolean',
]);
$items = $validated['items'];
unset($validated['items'], $validated['submit']);
// Update status if submitting
if ($request->boolean('submit')) {
$validated['status'] = Expense::STATUS_SUBMITTED;
}
$expense = $this->expenseService->updateExpense($expense, $validated, $items);
$message = $expense->isSubmitted()
? "Expense {$expense->expense_number} submitted for approval."
: "Expense {$expense->expense_number} updated.";
return redirect()
->route('seller.business.expenses.show', [$business, $expense])
->with('success', $message);
}
/**
* Submit an expense for approval.
*
* POST /s/{business}/expenses/{expense}/submit
*/
public function submit(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->authorizeExpenseAccess($expense, $business);
try {
$this->expenseService->submitExpense($expense, auth()->user());
return back()->with('success', "Expense {$expense->expense_number} submitted for approval.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Delete a draft expense.
*
* DELETE /s/{business}/expenses/{expense}
*/
public function destroy(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->authorizeExpenseAccess($expense, $business);
try {
$this->expenseService->deleteExpense($expense);
return redirect()
->route('seller.business.expenses.index', $business)
->with('success', 'Expense deleted.');
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Check if user can view all expenses (not just their own).
*/
protected function canViewAllExpenses($user, Business $business): bool
{
// Business owners and admins can view all
$pivot = $user->businesses()
->where('businesses.id', $business->id)
->first()
?->pivot;
if ($pivot && in_array($pivot->role ?? $pivot->contact_type ?? '', ['owner', 'primary', 'admin'])) {
return true;
}
return $user->user_type === 'admin';
}
/**
* Authorize access to a specific expense.
*/
protected function authorizeExpenseAccess(Expense $expense, Business $business): void
{
// Must belong to this business
if ($expense->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$user = auth()->user();
// Must be creator or have view-all permission
if ($expense->created_by_user_id !== $user->id && ! $this->canViewAllExpenses($user, $business)) {
abort(403, 'Access denied.');
}
}
}

View File

@@ -33,7 +33,7 @@ class InvoiceController extends Controller
'quantity_on_hand', 'quantity_allocated', 'type', 'image_path')
->orderBy('name')
->get()
->map(function ($product) {
->map(function ($product) use ($business) {
// Map batches with their COA data
$batches = $product->availableBatches->map(function ($batch) {
$latestLab = $batch->getLatestLab();

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\AccountingReportingService;
use App\Services\Accounting\ReportExportService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AccountingController extends Controller
{
public function __construct(
protected AccountingReportingService $reportingService,
protected ReportExportService $exportService
) {}
/**
* General Ledger Account Detail.
*
* GET /s/{business}/management/accounting/gl
*/
public function gl(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfMonth()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$accountId = $request->get('account_id');
$accounts = $this->reportingService->getAccountsForSelect($business);
$isParent = $this->reportingService->isParentCompany($business);
$ledgerData = null;
if ($accountId) {
$ledgerData = $this->reportingService->getGeneralLedger(
$business,
(int) $accountId,
$fromDate,
$toDate
);
}
return view('seller.management.accounting.gl', compact(
'business',
'accounts',
'ledgerData',
'fromDate',
'toDate',
'accountId',
'isParent'
));
}
/**
* Journal Entry Browser.
*
* GET /s/{business}/management/accounting/journals
*/
public function journals(Request $request, Business $business)
{
$filters = [
'from_date' => $request->get('from_date', now()->startOfMonth()->format('Y-m-d')),
'to_date' => $request->get('to_date', now()->format('Y-m-d')),
'source_type' => $request->get('source_type'),
'status' => $request->get('status'),
'division_id' => $request->get('division_id'),
'include_children' => true,
];
$entries = $this->reportingService->getJournalEntries($business, $filters);
$isParent = $this->reportingService->isParentCompany($business);
$divisions = $isParent ? $this->reportingService->getDivisions($business) : collect();
$sourceTypes = [
'manual' => 'Manual Entry',
'ap_bill' => 'AP Bill',
'ap_payment' => 'AP Payment',
'inter_company' => 'Inter-Company',
];
$statuses = [
'draft' => 'Draft',
'posted' => 'Posted',
'reversed' => 'Reversed',
];
return view('seller.management.accounting.journals', compact(
'business',
'entries',
'filters',
'isParent',
'divisions',
'sourceTypes',
'statuses'
));
}
/**
* Trial Balance Report.
*
* GET /s/{business}/management/accounting/trial-balance
*/
public function trialBalance(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$filters = [
'include_children' => $isParent && $includeChildren,
];
$trialBalance = $this->reportingService->getTrialBalance(
$business,
$fromDate,
$toDate,
$filters
);
// Calculate totals
$totals = [
'debits' => $trialBalance->sum('debits'),
'credits' => $trialBalance->sum('credits'),
'net_debit' => $trialBalance->where('closing_balance', '>', 0)->sum('closing_balance'),
'net_credit' => abs($trialBalance->where('closing_balance', '<', 0)->sum('closing_balance')),
];
return view('seller.management.accounting.trial-balance', compact(
'business',
'trialBalance',
'totals',
'fromDate',
'toDate',
'includeChildren',
'isParent'
));
}
/**
* Export Trial Balance as CSV.
*
* GET /s/{business}/management/accounting/trial-balance/export
*/
public function exportTrialBalance(Request $request, Business $business): StreamedResponse
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$filters = [
'include_children' => $isParent && $includeChildren,
];
$trialBalance = $this->reportingService->getTrialBalance(
$business,
$fromDate,
$toDate,
$filters
);
$filename = 'trial_balance_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportTrialBalance($trialBalance, $filename);
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\AccountingPeriod;
use App\Models\Business;
use App\Services\Accounting\PeriodLockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AccountingPeriodsController extends Controller
{
public function __construct(
protected PeriodLockService $periodLockService
) {}
/**
* Display accounting periods.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$year = $request->input('year', now()->year);
$periods = $this->periodLockService->getPeriodsForBusiness($business, (int) $year);
// Get available years
$yearsWithPeriods = AccountingPeriod::forBusiness($business->id)
->selectRaw('EXTRACT(YEAR FROM period_start) as year')
->distinct()
->pluck('year')
->map(fn ($y) => (int) $y)
->sort()
->values();
// Always include current and next year
$availableYears = $yearsWithPeriods
->push(now()->year)
->push(now()->year + 1)
->unique()
->sort()
->values();
$canClosePeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_close_periods');
$canReopenPeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_reopen_periods');
return view('seller.management.accounting.periods.index', [
'business' => $business,
'periods' => $periods,
'year' => (int) $year,
'availableYears' => $availableYears,
'canClosePeriods' => $canClosePeriods,
'canReopenPeriods' => $canReopenPeriods,
]);
}
/**
* Generate periods for a year.
*/
public function generate(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_close_periods');
$validated = $request->validate([
'year' => 'required|integer|min:2000|max:2100',
]);
$periods = $this->periodLockService->ensurePeriodsExist($business, (int) $validated['year']);
return back()->with('success', 'Generated '.count($periods).' periods for '.$validated['year'].'.');
}
/**
* Close a period.
*/
public function close(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_close_periods');
// Ensure period belongs to this business
if ($period->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'status' => 'required|in:soft_closed,hard_closed',
'notes' => 'nullable|string|max:1000',
]);
$this->periodLockService->closePeriod(
$period,
$validated['status'],
$request->user(),
$validated['notes'] ?? null
);
$statusLabel = $validated['status'] === 'soft_closed' ? 'soft closed' : 'hard closed';
return back()->with('success', "Period {$period->period_label} has been {$statusLabel}.");
}
/**
* Reopen a period.
*/
public function reopen(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_reopen_periods');
// Ensure period belongs to this business
if ($period->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'notes' => 'nullable|string|max:1000',
]);
$this->periodLockService->reopenPeriod(
$period,
$request->user(),
$validated['notes'] ?? null
);
return back()->with('success', "Period {$period->period_label} has been reopened.");
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Require a specific finance permission.
*/
private function requirePermission(Business $business, $user, string $permission): void
{
// Business owners always have access
if ($business->owner_user_id === $user->id) {
return;
}
// Check bypass mode
if (config('finance_roles.bypass_permissions', false)) {
return;
}
if (! $this->periodLockService->userHasPermission($business, $user, $permission)) {
abort(403, 'You do not have permission for this action.');
}
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ArInvoice;
use App\Models\Accounting\Expense;
use App\Models\Accounting\RecurringTransaction;
use App\Models\Business;
use App\Services\Accounting\BillService;
use App\Services\Accounting\ExpenseService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Action Center - Centralized hub for pending approvals and exceptions.
*
* Management Suite only - provides quick access to items needing attention:
* - Bills pending approval
* - Expenses pending approval
* - Recurring drafts needing review
* - AR exceptions (credit limits, holds, past due)
* - Budget exceptions
*/
class ActionCenterController extends Controller
{
public function __construct(
protected BillService $billService,
protected ExpenseService $expenseService
) {}
/**
* Display the Action Center dashboard.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
// 1. Bills Pending Approval
$pendingBills = ApBill::whereIn('business_id', $allBusinessIds)
->where('status', ApBill::STATUS_PENDING)
->with(['vendor', 'business'])
->orderBy('due_date')
->limit(20)
->get();
$pendingBillsCount = ApBill::whereIn('business_id', $allBusinessIds)
->where('status', ApBill::STATUS_PENDING)
->count();
// 2. Expenses Pending Approval
$pendingExpenses = Expense::whereIn('business_id', $allBusinessIds)
->where('status', Expense::STATUS_SUBMITTED)
->with(['user', 'business', 'glAccount'])
->orderBy('expense_date')
->limit(20)
->get();
$pendingExpensesCount = Expense::whereIn('business_id', $allBusinessIds)
->where('status', Expense::STATUS_SUBMITTED)
->count();
// 3. Recurring Drafts Needing Review
$recurringDrafts = collect();
$recurringDraftsCount = 0;
if (class_exists(RecurringTransaction::class)) {
$recurringDrafts = RecurringTransaction::whereIn('business_id', $allBusinessIds)
->where('status', 'draft')
->with('business')
->limit(20)
->get();
$recurringDraftsCount = RecurringTransaction::whereIn('business_id', $allBusinessIds)
->where('status', 'draft')
->count();
}
// 4. AR Exceptions
$arExceptions = $this->getArExceptions($allBusinessIds);
// 5. Budget Exceptions (placeholder - will expand when budget variance tracking exists)
$budgetExceptions = $this->getBudgetExceptions($parentBusiness);
// Summary counts
$totalActionItems = $pendingBillsCount + $pendingExpensesCount + $recurringDraftsCount
+ $arExceptions['count'] + $budgetExceptions['count'];
return view('seller.management.action-center.index', compact(
'business',
'parentBusiness',
'pendingBills',
'pendingBillsCount',
'pendingExpenses',
'pendingExpensesCount',
'recurringDrafts',
'recurringDraftsCount',
'arExceptions',
'budgetExceptions',
'totalActionItems'
));
}
/**
* Bulk approve pending bills.
*/
public function bulkApproveBills(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'bill_ids' => 'required|array',
'bill_ids.*' => 'exists:ap_bills,id',
]);
$approved = 0;
$errors = [];
foreach ($validated['bill_ids'] as $billId) {
try {
$bill = ApBill::findOrFail($billId);
$this->billService->approveBill($bill, auth()->id());
$approved++;
} catch (\Exception $e) {
$errors[] = "Bill #{$billId}: {$e->getMessage()}";
}
}
$message = "{$approved} bill(s) approved successfully.";
if (! empty($errors)) {
$message .= ' Errors: '.implode(', ', $errors);
}
return back()->with($errors ? 'warning' : 'success', $message);
}
/**
* Bulk approve pending expenses.
*/
public function bulkApproveExpenses(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'expense_ids' => 'required|array',
'expense_ids.*' => 'exists:expenses,id',
]);
$approved = 0;
$errors = [];
foreach ($validated['expense_ids'] as $expenseId) {
try {
$expense = Expense::findOrFail($expenseId);
$this->expenseService->approve($expense, auth()->id());
$approved++;
} catch (\Exception $e) {
$errors[] = "Expense #{$expenseId}: {$e->getMessage()}";
}
}
$message = "{$approved} expense(s) approved successfully.";
if (! empty($errors)) {
$message .= ' Errors: '.implode(', ', $errors);
}
return back()->with($errors ? 'warning' : 'success', $message);
}
/**
* Bulk reject pending expenses.
*/
public function bulkRejectExpenses(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'expense_ids' => 'required|array',
'expense_ids.*' => 'exists:expenses,id',
'rejection_reason' => 'required|string|max:500',
]);
$rejected = 0;
foreach ($validated['expense_ids'] as $expenseId) {
$expense = Expense::findOrFail($expenseId);
$this->expenseService->reject($expense, auth()->id(), $validated['rejection_reason']);
$rejected++;
}
return back()->with('success', "{$rejected} expense(s) rejected.");
}
/**
* Get AR exceptions (credit limits, holds, past due).
*/
protected function getArExceptions(array $businessIds): array
{
$exceptions = [
'over_credit_limit' => collect(),
'credit_hold' => collect(),
'past_due_60' => collect(),
'past_due_90' => collect(),
'count' => 0,
];
// Past due > 60 days
$pastDue60 = ArInvoice::whereIn('business_id', $businessIds)
->where('status', ArInvoice::STATUS_OVERDUE)
->where('due_date', '<', now()->subDays(60))
->where('balance_due', '>', 0)
->with(['customer', 'business'])
->get();
$exceptions['past_due_60'] = $pastDue60->filter(fn ($inv) => $inv->due_date >= now()->subDays(90));
// Past due > 90 days
$exceptions['past_due_90'] = ArInvoice::whereIn('business_id', $businessIds)
->where('status', ArInvoice::STATUS_OVERDUE)
->where('due_date', '<', now()->subDays(90))
->where('balance_due', '>', 0)
->with(['customer', 'business'])
->get();
$exceptions['count'] = $exceptions['past_due_60']->count() + $exceptions['past_due_90']->count();
return $exceptions;
}
/**
* Get budget exceptions (over budget items).
*/
protected function getBudgetExceptions(Business $business): array
{
// Placeholder - will expand when budget variance tracking is implemented
return [
'items' => collect(),
'count' => 0,
];
}
}

View File

@@ -0,0 +1,508 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApPayment;
use App\Models\Accounting\ArInvoice;
use App\Models\Accounting\Expense;
use App\Models\Accounting\JournalEntry;
use App\Models\Accounting\JournalEntryLine;
use App\Models\Business;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* Advanced Analytics - Deep dive dashboards for financial analysis.
*
* Provides:
* - AR Analytics (aging, DSO, collection rate)
* - AP Analytics (payment timing, vendor analysis)
* - Cash Analytics (position, forecast, runway)
* - Expense Analytics (category breakdown, trends)
*/
class AdvancedAnalyticsController extends Controller
{
/**
* AR Analytics Dashboard.
*/
public function arAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Aging buckets
$aging = $this->calculateArAging($allBusinessIds);
// DSO (Days Sales Outstanding)
$dso = $this->calculateDSO($allBusinessIds, $startDate, $endDate);
// Collection rate (last 12 months)
$collectionRate = $this->calculateCollectionRate($allBusinessIds, $startDate, $endDate);
// Monthly AR trend
$monthlyTrend = $this->getArMonthlyTrend($allBusinessIds, 12);
// Top customers by AR balance
$topCustomers = ArInvoice::whereIn('business_id', $allBusinessIds)
->where('balance_due', '>', 0)
->selectRaw('customer_id, SUM(balance_due) as total_balance')
->groupBy('customer_id')
->with('customer')
->orderByDesc('total_balance')
->limit(10)
->get();
return view('seller.management.analytics.ar', compact(
'business',
'parentBusiness',
'aging',
'dso',
'collectionRate',
'monthlyTrend',
'topCustomers',
'startDate',
'endDate'
));
}
/**
* AP Analytics Dashboard.
*/
public function apAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Aging buckets
$aging = $this->calculateApAging($allBusinessIds);
// DPO (Days Payable Outstanding)
$dpo = $this->calculateDPO($allBusinessIds, $startDate, $endDate);
// Payment timing analysis
$paymentTiming = $this->analyzePaymentTiming($allBusinessIds, $startDate, $endDate);
// Top vendors by AP balance
$topVendors = ApBill::whereIn('business_id', $allBusinessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->selectRaw('vendor_id, SUM(total - paid_amount) as total_balance')
->groupBy('vendor_id')
->with('vendor')
->orderByDesc('total_balance')
->limit(10)
->get();
// Monthly AP trend
$monthlyTrend = $this->getApMonthlyTrend($allBusinessIds, 12);
return view('seller.management.analytics.ap', compact(
'business',
'parentBusiness',
'aging',
'dpo',
'paymentTiming',
'topVendors',
'monthlyTrend',
'startDate',
'endDate'
));
}
/**
* Cash Analytics Dashboard.
*/
public function cashAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
// Current cash position from GL
$cashPosition = $this->calculateCashPosition($parentBusiness);
// Cash flow by month (last 12 months)
$monthlyCashFlow = $this->getMonthlyCashFlow($parentBusiness, 12);
// Expected collections (upcoming AR)
$expectedCollections = ArInvoice::whereIn('business_id', $allBusinessIds)
->where('balance_due', '>', 0)
->where('due_date', '>=', now())
->where('due_date', '<=', now()->addDays(90))
->selectRaw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END as period,
SUM(balance_due) as total
")
->groupBy(DB::raw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END
"))
->get()
->pluck('total', 'period');
// Expected payments (upcoming AP)
$expectedPayments = ApBill::whereIn('business_id', $allBusinessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->where('due_date', '>=', now())
->where('due_date', '<=', now()->addDays(90))
->selectRaw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END as period,
SUM(total - paid_amount) as total
")
->groupBy(DB::raw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END
"))
->get()
->pluck('total', 'period');
// Cash runway (months of runway based on avg monthly expenses)
$avgMonthlyExpenses = $this->getAverageMonthlyExpenses($allBusinessIds, 6);
$cashRunway = $avgMonthlyExpenses > 0 ? round($cashPosition / $avgMonthlyExpenses, 1) : null;
return view('seller.management.analytics.cash', compact(
'business',
'parentBusiness',
'cashPosition',
'monthlyCashFlow',
'expectedCollections',
'expectedPayments',
'avgMonthlyExpenses',
'cashRunway'
));
}
/**
* Expense Analytics Dashboard.
*/
public function expenseAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Expenses by category
$byCategory = Expense::whereIn('business_id', $allBusinessIds)
->whereBetween('expense_date', [$startDate, $endDate])
->where('status', Expense::STATUS_APPROVED)
->selectRaw('gl_account_id, SUM(amount) as total')
->groupBy('gl_account_id')
->with('glAccount')
->orderByDesc('total')
->get();
// Expenses by division
$byDivision = Expense::whereIn('business_id', $allBusinessIds)
->whereBetween('expense_date', [$startDate, $endDate])
->where('status', Expense::STATUS_APPROVED)
->selectRaw('business_id, SUM(amount) as total')
->groupBy('business_id')
->with('business')
->orderByDesc('total')
->get();
// Monthly expense trend
$monthlyTrend = Expense::whereIn('business_id', $allBusinessIds)
->where('status', Expense::STATUS_APPROVED)
->where('expense_date', '>=', now()->subMonths(12))
->selectRaw("DATE_TRUNC('month', expense_date) as month, SUM(amount) as total")
->groupBy(DB::raw("DATE_TRUNC('month', expense_date)"))
->orderBy('month')
->get();
// Top expense categories (from GL)
$topCategories = JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness, $startDate, $endDate) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED)
->whereBetween('entry_date', [$startDate, $endDate]);
})
->whereHas('glAccount', function ($q) {
$q->where('account_type', 'expense');
})
->selectRaw('gl_account_id, SUM(debit_amount) as total_debit')
->groupBy('gl_account_id')
->with('glAccount')
->orderByDesc('total_debit')
->limit(15)
->get();
return view('seller.management.analytics.expense', compact(
'business',
'parentBusiness',
'byCategory',
'byDivision',
'monthlyTrend',
'topCategories',
'startDate',
'endDate'
));
}
// =========================================================================
// HELPER METHODS
// =========================================================================
protected function calculateArAging(array $businessIds): array
{
$buckets = [
'current' => 0,
'1_30' => 0,
'31_60' => 0,
'61_90' => 0,
'over_90' => 0,
];
$invoices = ArInvoice::whereIn('business_id', $businessIds)
->where('balance_due', '>', 0)
->get();
foreach ($invoices as $invoice) {
$daysOverdue = $invoice->due_date ? now()->diffInDays($invoice->due_date, false) : 0;
if ($daysOverdue <= 0) {
$buckets['current'] += $invoice->balance_due;
} elseif ($daysOverdue <= 30) {
$buckets['1_30'] += $invoice->balance_due;
} elseif ($daysOverdue <= 60) {
$buckets['31_60'] += $invoice->balance_due;
} elseif ($daysOverdue <= 90) {
$buckets['61_90'] += $invoice->balance_due;
} else {
$buckets['over_90'] += $invoice->balance_due;
}
}
$buckets['total'] = array_sum($buckets);
return $buckets;
}
protected function calculateApAging(array $businessIds): array
{
$buckets = [
'current' => 0,
'1_30' => 0,
'31_60' => 0,
'61_90' => 0,
'over_90' => 0,
];
$bills = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->get();
foreach ($bills as $bill) {
$balance = $bill->total - $bill->paid_amount;
$daysOverdue = $bill->due_date ? now()->diffInDays($bill->due_date, false) : 0;
if ($daysOverdue <= 0) {
$buckets['current'] += $balance;
} elseif ($daysOverdue <= 30) {
$buckets['1_30'] += $balance;
} elseif ($daysOverdue <= 60) {
$buckets['31_60'] += $balance;
} elseif ($daysOverdue <= 90) {
$buckets['61_90'] += $balance;
} else {
$buckets['over_90'] += $balance;
}
}
$buckets['total'] = array_sum($buckets);
return $buckets;
}
protected function calculateDSO(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalAR = ArInvoice::whereIn('business_id', $businessIds)
->where('balance_due', '>', 0)
->sum('balance_due');
$totalRevenue = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->sum('total');
$days = $startDate->diffInDays($endDate);
if ($totalRevenue > 0 && $days > 0) {
$avgDailyRevenue = $totalRevenue / $days;
return round($totalAR / $avgDailyRevenue, 1);
}
return 0;
}
protected function calculateDPO(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalAP = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->selectRaw('SUM(total - paid_amount) as balance')
->value('balance') ?? 0;
$totalPurchases = ApBill::whereIn('business_id', $businessIds)
->whereBetween('bill_date', [$startDate, $endDate])
->sum('total');
$days = $startDate->diffInDays($endDate);
if ($totalPurchases > 0 && $days > 0) {
$avgDailyPurchases = $totalPurchases / $days;
return round($totalAP / $avgDailyPurchases, 1);
}
return 0;
}
protected function calculateCollectionRate(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalInvoiced = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->sum('total');
$totalCollected = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->selectRaw('SUM(total - balance_due) as collected')
->value('collected') ?? 0;
if ($totalInvoiced > 0) {
return round(($totalCollected / $totalInvoiced) * 100, 1);
}
return 0;
}
protected function analyzePaymentTiming(array $businessIds, Carbon $startDate, Carbon $endDate): array
{
$payments = ApPayment::whereIn('business_id', $businessIds)
->whereBetween('payment_date', [$startDate, $endDate])
->with('bill')
->get();
$early = 0;
$onTime = 0;
$late = 0;
foreach ($payments as $payment) {
if (! $payment->bill?->due_date) {
continue;
}
$daysDiff = $payment->payment_date->diffInDays($payment->bill->due_date, false);
if ($daysDiff > 5) {
$early++;
} elseif ($daysDiff >= -5) {
$onTime++;
} else {
$late++;
}
}
$total = $early + $onTime + $late;
return [
'early' => $early,
'on_time' => $onTime,
'late' => $late,
'early_pct' => $total > 0 ? round(($early / $total) * 100, 1) : 0,
'on_time_pct' => $total > 0 ? round(($onTime / $total) * 100, 1) : 0,
'late_pct' => $total > 0 ? round(($late / $total) * 100, 1) : 0,
];
}
protected function getArMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
{
return ArInvoice::whereIn('business_id', $businessIds)
->where('invoice_date', '>=', now()->subMonths($months))
->selectRaw("DATE_TRUNC('month', invoice_date) as month, SUM(total) as invoiced, SUM(total - balance_due) as collected")
->groupBy(DB::raw("DATE_TRUNC('month', invoice_date)"))
->orderBy('month')
->get();
}
protected function getApMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
{
return ApBill::whereIn('business_id', $businessIds)
->where('bill_date', '>=', now()->subMonths($months))
->selectRaw("DATE_TRUNC('month', bill_date) as month, SUM(total) as billed, SUM(paid_amount) as paid")
->groupBy(DB::raw("DATE_TRUNC('month', bill_date)"))
->orderBy('month')
->get();
}
protected function calculateCashPosition(Business $parentBusiness): float
{
// Sum of all cash accounts (1000-1099 range)
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED);
})
->whereHas('glAccount', function ($q) {
$q->where('account_number', '>=', '1000')
->where('account_number', '<', '1100');
})
->selectRaw('SUM(debit_amount - credit_amount) as balance')
->value('balance') ?? 0;
}
protected function getMonthlyCashFlow(Business $parentBusiness, int $months): \Illuminate\Support\Collection
{
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED)
->where('entry_date', '>=', now()->subMonths($months));
})
->whereHas('glAccount', function ($q) {
$q->where('account_number', '>=', '1000')
->where('account_number', '<', '1100');
})
->join('journal_entries', 'journal_entry_lines.journal_entry_id', '=', 'journal_entries.id')
->selectRaw("DATE_TRUNC('month', journal_entries.entry_date) as month, SUM(debit_amount) as inflows, SUM(credit_amount) as outflows")
->groupBy(DB::raw("DATE_TRUNC('month', journal_entries.entry_date)"))
->orderBy('month')
->get();
}
protected function getAverageMonthlyExpenses(array $businessIds, int $months): float
{
$total = Expense::whereIn('business_id', $businessIds)
->where('status', Expense::STATUS_APPROVED)
->where('expense_date', '>=', now()->subMonths($months))
->sum('amount');
return $total / max($months, 1);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnalyticsController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$businessIds = $filterData['business_ids'];
// Collect analytics data across all businesses
$analytics = $this->collectAnalytics($businessIds);
return view('seller.management.analytics.index', $this->withDivisionFilter([
'business' => $business,
'analytics' => $analytics,
], $filterData));
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
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) 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.payment_status', ['sent', 'partial', 'overdue'])
->select(
'businesses.name as division_name',
DB::raw('SUM(invoices.total) as total_ar'),
DB::raw('SUM(invoices.amount_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,
],
];
}
}

View File

@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Models\Department;
use App\Models\PurchaseOrder;
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
{
use ManagementDivisionFilter;
public function __construct(
protected BillService $billService,
protected PaymentService $paymentService
) {}
/**
* Bills list page.
*
* GET /s/{business}/management/ap/bills
*/
public function index(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
// Get bills with filters - use division filter
$query = ApBill::forBusinesses($filterData['business_ids'])
->with(['vendor', 'purchaseOrder', 'business']);
// Status filter
if ($request->filled('status')) {
$query->status($request->status);
}
// Vendor filter
if ($request->filled('vendor_id')) {
$query->where('vendor_id', $request->vendor_id);
}
// Date range filter
if ($request->filled('from_date')) {
$query->whereDate('bill_date', '>=', $request->from_date);
}
if ($request->filled('to_date')) {
$query->whereDate('bill_date', '<=', $request->to_date);
}
// Unpaid filter
if ($request->boolean('unpaid')) {
$query->unpaid();
}
// Overdue filter
if ($request->boolean('overdue')) {
$query->overdue();
}
// Sort
$sortField = $request->get('sort', 'due_date');
$sortDir = $request->get('dir', 'asc');
$query->orderBy($sortField, $sortDir);
$bills = $query->paginate(20)->withQueryString();
// Get vendors for filter dropdown (from all filtered businesses)
$vendors = ApVendor::whereIn('business_id', $filterData['business_ids'])
->where('is_active', true)
->orderBy('name')
->get();
// Stats (scoped to filtered businesses)
$stats = [
'total_outstanding' => ApBill::forBusinesses($filterData['business_ids'])->unpaid()->sum('balance_due'),
'overdue_count' => ApBill::forBusinesses($filterData['business_ids'])->overdue()->count(),
'overdue_amount' => ApBill::forBusinesses($filterData['business_ids'])->overdue()->sum('balance_due'),
'pending_approval' => ApBill::forBusinesses($filterData['business_ids'])->whereIn('status', ['draft', 'pending'])->count(),
];
// Check if user can pay (parent company only)
$canPay = $business->parent_id === null;
return view('seller.management.ap.bills.index', $this->withDivisionFilter([
'business' => $business,
'bills' => $bills,
'vendors' => $vendors,
'stats' => $stats,
'canPay' => $canPay,
], $filterData));
}
/**
* Bill detail page.
*
* GET /s/{business}/management/ap/bills/{bill}
*/
public function show(Request $request, Business $business, ApBill $bill)
{
// Verify bill belongs to this business or its divisions
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($bill->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$bill->load([
'vendor',
'items.glAccount',
'items.department',
'purchaseOrder.items',
'paymentApplications.payment',
'approvedBy',
'createdBy',
]);
// Check if user can pay (parent company only)
$canPay = $business->parent_id === null;
// Check if user can approve
$canApprove = in_array($bill->status, [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING]);
return view('seller.management.ap.bills.show', compact(
'business',
'bill',
'canPay',
'canApprove'
));
}
/**
* Create bill page (manual entry).
*
* GET /s/{business}/management/ap/bills/create
*/
public function create(Request $request, Business $business)
{
// Get vendors
$vendors = ApVendor::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Get GL accounts for line items
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->orderBy('account_number')
->get();
// Get departments
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// If creating from PO, get the PO
$purchaseOrder = null;
if ($request->filled('po_id')) {
$purchaseOrder = PurchaseOrder::where('business_id', $business->id)
->with('items')
->findOrFail($request->po_id);
}
return view('seller.management.ap.bills.create', compact(
'business',
'vendors',
'glAccounts',
'departments',
'purchaseOrder'
));
}
/**
* Store a new bill (web form submission).
*
* POST /s/{business}/management/ap/bills
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'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', 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', 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 {
// Check if creating from PO
if (! empty($validated['purchase_order_id'])) {
$po = PurchaseOrder::where('business_id', $business->id)
->findOrFail($validated['purchase_order_id']);
$bill = $this->billService->createFromPurchaseOrder(
$po,
$validated['vendor_invoice_number'],
$validated
);
} else {
$bill = $this->billService->createManualBill(
$business->id,
$validated['vendor_id'],
$validated['vendor_invoice_number'],
$validated['items'],
$validated
);
}
return redirect()
->route('seller.business.management.ap.bills.show', [$business, $bill])
->with('success', "Bill {$bill->bill_number} created successfully.");
} catch (\InvalidArgumentException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
}
/**
* Approve a bill.
*
* POST /s/{business}/management/ap/bills/{bill}/approve
*/
public function approve(Business $business, ApBill $bill)
{
if ($bill->business_id !== $business->id) {
abort(403);
}
try {
$this->billService->approveBill($bill, auth()->id());
return back()->with('success', "Bill {$bill->bill_number} approved.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Pay a bill (parent company only).
*
* POST /s/{business}/management/ap/bills/{bill}/pay
*/
public function pay(Request $request, Business $business, ApBill $bill)
{
// Only parent company can pay
if ($business->parent_id !== null) {
abort(403, 'Only parent company can make payments.');
}
// Bill must be from this business or a child
$canPay = $bill->business_id === $business->id
|| $bill->business->parent_id === $business->id;
if (! $canPay) {
abort(403, 'Cannot pay this bill.');
}
$validated = $request->validate([
'payment_method' => 'required|in:check,ach,wire,card,cash',
'amount' => 'nullable|numeric|min:0.01',
'discount' => 'nullable|numeric|min:0',
'reference_number' => 'nullable|string|max:100',
'memo' => 'nullable|string|max:500',
]);
try {
$discount = $validated['discount'] ?? 0;
$amount = $validated['amount'] ?? bcsub((string) $bill->balance_due, (string) $discount, 2);
$payment = $this->paymentService->createPayment(
$business,
$bill->vendor_id,
(float) $amount,
$validated['payment_method'],
[
[
'bill_id' => $bill->id,
'amount' => $amount,
'discount' => $discount,
],
],
[
'reference_number' => $validated['reference_number'] ?? null,
'memo' => $validated['memo'] ?? null,
]
);
$this->paymentService->completePayment($payment);
return back()->with('success', "Payment {$payment->payment_number} applied to bill {$bill->bill_number}.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Void a bill.
*
* POST /s/{business}/management/ap/bills/{bill}/void
*/
public function void(Request $request, Business $business, ApBill $bill)
{
if ($bill->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'reason' => 'nullable|string|max:500',
]);
try {
$this->billService->voidBill($bill, $validated['reason'] ?? null);
return redirect()
->route('seller.business.management.ap.bills.index', $business)
->with('success', "Bill {$bill->bill_number} voided.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
}

View File

@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
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
{
use ManagementDivisionFilter;
/**
* Vendors list page.
*
* GET /s/{business}/management/ap/vendors
*/
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')
->withCount('bills');
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('contact_email', 'like', "%{$search}%");
});
}
// Active filter
if ($request->has('active')) {
$query->where('is_active', $request->boolean('active'));
}
$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)
->where('is_header', false)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.management.ap.vendors.index', $this->withDivisionFilter([
'business' => $business,
'vendors' => $vendors,
'glAccounts' => $glAccounts,
'isParent' => $isParent,
], $filterData));
}
/**
* Store a new vendor.
*
* POST /s/{business}/management/ap/vendors
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'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', 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',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
// Generate code if not provided
if (empty($validated['code'])) {
$validated['code'] = $this->generateVendorCode($business->id, $validated['name']);
}
$vendor = ApVendor::create([
'business_id' => $business->id,
...$validated,
'is_active' => true,
]);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'vendor' => $vendor,
]);
}
return back()->with('success', "Vendor {$vendor->name} created successfully.");
}
/**
* Show create vendor form.
*
* GET /s/{business}/management/ap/vendors/create
*/
public function create(Request $request, Business $business)
{
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.management.ap.vendors.create', compact(
'business',
'glAccounts'
));
}
/**
* Show vendor details.
*
* GET /s/{business}/management/ap/vendors/{vendor}
*/
public function show(Request $request, Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$vendor->load(['defaultGlAccount']);
// Get recent bills
$recentBills = $vendor->bills()
->with(['glAccount'])
->orderByDesc('bill_date')
->limit(10)
->get();
// Get recent payments
$recentPayments = $vendor->payments()
->with(['bills'])
->orderByDesc('payment_date')
->limit(10)
->get();
// Calculate metrics
$metrics = [
'total_bills' => $vendor->bills()->count(),
'unpaid_balance' => $vendor->bills()->unpaid()->sum('balance_due'),
'overdue_balance' => $vendor->bills()->overdue()->sum('balance_due'),
'ytd_payments' => $vendor->payments()
->whereYear('payment_date', now()->year)
->completed()
->sum('amount'),
];
return view('seller.management.ap.vendors.show', compact(
'business',
'vendor',
'recentBills',
'recentPayments',
'metrics'
));
}
/**
* Show edit vendor form.
*
* GET /s/{business}/management/ap/vendors/{vendor}/edit
*/
public function edit(Request $request, Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.management.ap.vendors.edit', compact(
'business',
'vendor',
'glAccounts'
));
}
/**
* Update a vendor.
*
* PUT /s/{business}/management/ap/vendors/{vendor}
*/
public function update(Request $request, Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'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', 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',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
$vendor->update($validated);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'vendor' => $vendor->fresh(),
]);
}
return back()->with('success', "Vendor {$vendor->name} updated successfully.");
}
/**
* Toggle vendor active status.
*
* POST /s/{business}/management/ap/vendors/{vendor}/toggle-active
*/
public function toggleActive(Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$vendor->update(['is_active' => ! $vendor->is_active]);
$status = $vendor->is_active ? 'activated' : 'deactivated';
return back()->with('success', "Vendor {$vendor->name} {$status}.");
}
/**
* Generate vendor code from name.
*/
protected function generateVendorCode(int $businessId, string $name): string
{
// Take first 3 chars of each word, uppercase
$words = preg_split('/\s+/', strtoupper($name));
$prefix = '';
foreach ($words as $word) {
$prefix .= substr(preg_replace('/[^A-Z0-9]/', '', $word), 0, 3);
if (strlen($prefix) >= 6) {
break;
}
}
$prefix = substr($prefix, 0, 6);
// Check for uniqueness
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ArCustomer;
use App\Models\Business;
use App\Services\Accounting\ArAnalyticsService;
use App\Services\Accounting\ArService;
use App\Services\Accounting\CustomerFinancialService;
use App\Services\Accounting\ReportExportService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ArController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected ArAnalyticsService $analyticsService,
protected ArService $arService,
protected CustomerFinancialService $customerService,
protected ReportExportService $exportService
) {}
/**
* AR Overview dashboard.
*/
public function index(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$metrics = $this->analyticsService->getARMetrics($business, $filterData['business_ids']);
$aging = $this->analyticsService->getARAging($business, $filterData['business_ids']);
$topCustomers = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 5);
return view('seller.management.ar.index', $this->withDivisionFilter([
'business' => $business,
'metrics' => $metrics,
'aging' => $aging,
'topCustomers' => $topCustomers,
], $filterData));
}
/**
* AR Aging detail page.
*/
public function aging(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$aging = $this->analyticsService->getARAging($business, $filterData['business_ids']);
$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.');
}
/**
* Export AR Aging report as CSV.
*/
public function exportAging(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$byCustomer = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 1000);
$filename = 'ar_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportArAging($byCustomer, $filename);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Services\Accounting\BankAccountService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankAccountsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BankAccountService $bankAccountService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the list of bank accounts.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Determine which business to show accounts for
$targetBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
$accounts = $this->bankAccountService->getAccountsForBusiness($targetBusiness, $includeChildren);
$totalBalance = $this->bankAccountService->getTotalCashBalance($targetBusiness, $includeChildren);
return view('seller.management.bank-accounts.index', $this->withDivisionFilter([
'business' => $business,
'accounts' => $accounts,
'totalBalance' => $totalBalance,
], $filterData));
}
/**
* Show the form for creating a new bank account.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$glAccounts = GlAccount::where('business_id', $business->id)
->where('account_type', 'asset')
->orderBy('account_number')
->get();
return view('seller.management.bank-accounts.create', [
'business' => $business,
'glAccounts' => $glAccounts,
'accountTypes' => [
BankAccount::TYPE_CHECKING => 'Checking',
BankAccount::TYPE_SAVINGS => 'Savings',
BankAccount::TYPE_MONEY_MARKET => 'Money Market',
],
]);
}
/**
* Store a newly created bank account.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'account_type' => 'required|string|in:checking,savings,money_market',
'bank_name' => 'nullable|string|max:255',
'account_number_last4' => 'nullable|string|max:4',
'routing_number' => 'nullable|string|max:9',
'current_balance' => 'nullable|numeric|min:0',
'gl_account_id' => 'nullable|exists:gl_accounts,id',
'is_primary' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string',
]);
$this->bankAccountService->createAccount($business, $validated, auth()->user());
return redirect()
->route('seller.business.management.bank-accounts.index', $business)
->with('success', 'Bank account created successfully.');
}
/**
* Display the specified bank account.
*/
public function show(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$recentTransfers = $bankAccount->outgoingTransfers()
->orWhere('to_bank_account_id', $bankAccount->id)
->with(['fromAccount', 'toAccount'])
->orderBy('transfer_date', 'desc')
->limit(10)
->get();
return view('seller.management.bank-accounts.show', [
'business' => $business,
'account' => $bankAccount,
'recentTransfers' => $recentTransfers,
]);
}
/**
* Show the form for editing the bank account.
*/
public function edit(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$glAccounts = GlAccount::where('business_id', $business->id)
->where('account_type', 'asset')
->orderBy('account_number')
->get();
return view('seller.management.bank-accounts.edit', [
'business' => $business,
'account' => $bankAccount,
'glAccounts' => $glAccounts,
'accountTypes' => [
BankAccount::TYPE_CHECKING => 'Checking',
BankAccount::TYPE_SAVINGS => 'Savings',
BankAccount::TYPE_MONEY_MARKET => 'Money Market',
],
]);
}
/**
* Update the specified bank account.
*/
public function update(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'account_type' => 'required|string|in:checking,savings,money_market',
'bank_name' => 'nullable|string|max:255',
'account_number_last4' => 'nullable|string|max:4',
'routing_number' => 'nullable|string|max:9',
'gl_account_id' => 'nullable|exists:gl_accounts,id',
'is_primary' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string',
]);
$this->bankAccountService->updateAccount($bankAccount, $validated);
return redirect()
->route('seller.business.management.bank-accounts.index', $business)
->with('success', 'Bank account updated successfully.');
}
/**
* Toggle the active status of a bank account.
*/
public function toggleActive(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$bankAccount->update(['is_active' => ! $bankAccount->is_active]);
$status = $bankAccount->is_active ? 'activated' : 'deactivated';
return redirect()
->back()
->with('success', "Bank account {$status} successfully.");
}
}

View File

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

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\BankTransfer;
use App\Models\Business;
use App\Services\Accounting\BankAccountService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankTransfersController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BankAccountService $bankAccountService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the list of bank transfers.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$filters = [
'status' => $request->get('status'),
'from_date' => $request->get('from_date'),
'to_date' => $request->get('to_date'),
];
$targetBusiness = $filterData['selected_division'] ?? $business;
$transfers = $this->bankAccountService->getTransfersForBusiness($targetBusiness, $filters);
return view('seller.management.bank-transfers.index', $this->withDivisionFilter([
'business' => $business,
'transfers' => $transfers,
'filters' => $filters,
], $filterData));
}
/**
* Show the form for creating a new bank transfer.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$accounts = BankAccount::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.management.bank-transfers.create', [
'business' => $business,
'accounts' => $accounts,
]);
}
/**
* Store a newly created bank transfer.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'from_bank_account_id' => 'required|exists:bank_accounts,id',
'to_bank_account_id' => 'required|exists:bank_accounts,id|different:from_bank_account_id',
'amount' => 'required|numeric|min:0.01',
'transfer_date' => 'required|date',
'reference' => 'nullable|string|max:255',
'memo' => 'nullable|string',
]);
// Verify accounts belong to this business
$fromAccount = BankAccount::where('id', $validated['from_bank_account_id'])
->where('business_id', $business->id)
->firstOrFail();
$toAccount = BankAccount::where('id', $validated['to_bank_account_id'])
->where('business_id', $business->id)
->firstOrFail();
$transfer = $this->bankAccountService->createTransfer($business, $validated, auth()->user());
return redirect()
->route('seller.business.management.bank-transfers.show', [$business, $transfer])
->with('success', 'Bank transfer created successfully.');
}
/**
* Display the specified bank transfer.
*/
public function show(Request $request, Business $business, BankTransfer $bankTransfer): View
{
$this->requireManagementSuite($business);
if ($bankTransfer->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$bankTransfer->load(['fromAccount', 'toAccount', 'createdBy', 'approvedBy', 'journalEntry']);
return view('seller.management.bank-transfers.show', [
'business' => $business,
'transfer' => $bankTransfer,
]);
}
/**
* Complete/approve a pending bank transfer.
*/
public function complete(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankTransfer->business_id !== $business->id) {
abort(403, 'Access denied.');
}
if (! $bankTransfer->isPending()) {
return redirect()
->back()
->with('error', 'Only pending transfers can be completed.');
}
try {
$this->bankAccountService->completeTransfer($bankTransfer, auth()->user());
return redirect()
->route('seller.business.management.bank-transfers.show', [$business, $bankTransfer])
->with('success', 'Bank transfer completed successfully.');
} catch (\Exception $e) {
return redirect()
->back()
->with('error', 'Failed to complete transfer: '.$e->getMessage());
}
}
/**
* Cancel a pending bank transfer.
*/
public function cancel(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankTransfer->business_id !== $business->id) {
abort(403, 'Access denied.');
}
if (! $bankTransfer->isPending()) {
return redirect()
->back()
->with('error', 'Only pending transfers can be cancelled.');
}
try {
$this->bankAccountService->cancelTransfer($bankTransfer);
return redirect()
->route('seller.business.management.bank-transfers.index', $business)
->with('success', 'Bank transfer cancelled.');
} catch (\Exception $e) {
return redirect()
->back()
->with('error', 'Failed to cancel transfer: '.$e->getMessage());
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Budget;
use App\Models\Business;
use App\Services\Accounting\BudgetService;
use App\Services\Accounting\ReportExportService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BudgetReportingController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BudgetService $budgetService,
protected ReportExportService $exportService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List budgets with variance summary for reporting.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Get all budgets with quick variance summary
$budgets = Budget::whereIn('business_id', $filterData['business_ids'])
->active()
->with(['business'])
->withCount('lines')
->orderByDesc('fiscal_year')
->orderByDesc('created_at')
->get()
->map(function ($budget) {
$summary = $this->budgetService->getBudgetSummary($budget);
return [
'budget' => $budget,
'total_budget' => $summary['total_budget'],
'total_actual' => $summary['total_actual'],
'variance_amount' => $summary['variance_amount'],
'variance_percent' => $summary['variance_percent'],
];
});
return view('seller.management.financials.budget-vs-actual.index', $this->withDivisionFilter([
'business' => $business,
'budgets' => $budgets,
], $filterData));
}
/**
* Show detailed Budget vs Actual report for a specific budget.
*/
public function show(Request $request, Business $business, Budget $budget): View
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$filterData = $this->getDivisionFilterData($business, $request);
// Get grouping preference
$groupBy = $request->get('group_by', 'department');
// Build filters for the report - use snake_case keys from getDivisionFilterData()
$filters = [
'include_children' => ($filterData['selected_division'] === null && $business->hasChildBusinesses()),
];
if ($filterData['selected_division']) {
$filters['division_id'] = $filterData['selected_division']->id;
}
$report = $this->budgetService->getBudgetVsActual($budget, $groupBy, $filters);
// Get all budgets for the selector
$allBudgets = Budget::whereIn('business_id', $filterData['business_ids'])
->active()
->orderByDesc('fiscal_year')
->get();
return view('seller.management.financials.budget-vs-actual.show', $this->withDivisionFilter([
'business' => $business,
'budget' => $budget,
'report' => $report,
'groupBy' => $groupBy,
'allBudgets' => $allBudgets,
], $filterData));
}
/**
* Validate budget belongs to allowed business.
*/
private function authorizeForBusiness(Business $business, Budget $budget): void
{
$allowedIds = $this->getAllowedBusinessIds($business);
if (! in_array($budget->business_id, $allowedIds)) {
abort(404);
}
}
/**
* Export Budget vs Actual report as CSV.
*/
public function export(Request $request, Business $business, Budget $budget): StreamedResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$filterData = $this->getDivisionFilterData($business, $request);
$groupBy = $request->get('group_by', 'department');
$filters = [
'include_children' => ($filterData['selected_division'] === null && $business->hasChildBusinesses()),
];
if ($filterData['selected_division']) {
$filters['division_id'] = $filterData['selected_division']->id;
}
$report = $this->budgetService->getBudgetVsActual($budget, $groupBy, $filters);
$filename = 'budget_vs_actual_'.$budget->name.'_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
$filename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $filename);
return $this->exportService->exportBudgetVsActual($report, $filename);
}
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Budget;
use App\Models\Accounting\BudgetLine;
use App\Models\Business;
use App\Services\Accounting\BudgetService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BudgetsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BudgetService $budgetService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List budgets for the business.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$budgets = Budget::whereIn('business_id', $filterData['business_ids'])
->with(['business', 'createdBy', 'approvedBy'])
->withCount('lines')
->orderByDesc('fiscal_year')
->orderByDesc('created_at')
->paginate(20);
return view('seller.management.budgets.index', $this->withDivisionFilter([
'business' => $business,
'budgets' => $budgets,
], $filterData));
}
/**
* Show budget creation form.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$currentYear = now()->year;
$years = range($currentYear - 1, $currentYear + 2);
return view('seller.management.budgets.create', [
'business' => $business,
'years' => $years,
'periodTypes' => Budget::getPeriodTypes(),
]);
}
/**
* Store a new budget.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'fiscal_year' => 'nullable|integer|min:2020|max:2100',
'currency' => 'nullable|string|size:3',
'notes' => 'nullable|string|max:1000',
]);
$budget = Budget::create([
'business_id' => $business->id,
'name' => $validated['name'],
'fiscal_year' => $validated['fiscal_year'] ?? now()->year,
'currency' => $validated['currency'] ?? 'USD',
'is_active' => true,
'created_by_user_id' => auth()->id(),
'notes' => $validated['notes'],
]);
return redirect()
->route('seller.business.management.budgets.edit', [$business, $budget])
->with('success', 'Budget created. Now add budget lines.');
}
/**
* Show budget details.
*/
public function show(Request $request, Business $business, Budget $budget): View
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$summary = $this->budgetService->getBudgetSummary($budget);
return view('seller.management.budgets.show', [
'business' => $business,
'budget' => $budget->load(['createdBy', 'approvedBy', 'lines.department', 'lines.glAccount']),
'summary' => $summary,
]);
}
/**
* Edit budget (metadata and lines).
*/
public function edit(Request $request, Business $business, Budget $budget): View
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$expenseAccounts = $this->budgetService->getExpenseAccounts($business);
$departments = $this->budgetService->getDepartments($business);
// Group lines by department for the grid view
$lines = $budget->lines()
->with(['department', 'glAccount'])
->orderBy('department_id')
->orderBy('gl_account_id')
->orderBy('period_start')
->get();
return view('seller.management.budgets.edit', [
'business' => $business,
'budget' => $budget,
'lines' => $lines,
'expenseAccounts' => $expenseAccounts,
'departments' => $departments,
'periodTypes' => Budget::getPeriodTypes(),
]);
}
/**
* Update budget metadata.
*/
public function update(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'name' => 'required|string|max:255',
'fiscal_year' => 'nullable|integer|min:2020|max:2100',
'notes' => 'nullable|string|max:1000',
'is_active' => 'boolean',
]);
$budget->update([
'name' => $validated['name'],
'fiscal_year' => $validated['fiscal_year'],
'notes' => $validated['notes'],
'is_active' => $validated['is_active'] ?? $budget->is_active,
]);
return back()->with('success', 'Budget updated.');
}
/**
* Add a budget line.
*/
public function addLine(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'gl_account_id' => 'required|exists:gl_accounts,id',
'department_id' => 'nullable|exists:departments,id',
'period_type' => 'required|in:monthly,quarterly,yearly',
'amount' => 'required|numeric|min:0',
'year' => 'required|integer|min:2020|max:2100',
]);
$year = (int) $validated['year'];
$amount = (float) $validated['amount'];
// Generate lines based on period type
match ($validated['period_type']) {
'monthly' => $this->budgetService->generateMonthlyLines(
$budget,
(int) $validated['gl_account_id'],
$validated['department_id'] ? (int) $validated['department_id'] : null,
$amount,
$year
),
'quarterly' => $this->budgetService->generateQuarterlyLines(
$budget,
(int) $validated['gl_account_id'],
$validated['department_id'] ? (int) $validated['department_id'] : null,
$amount,
$year
),
'yearly' => BudgetLine::create([
'budget_id' => $budget->id,
'gl_account_id' => (int) $validated['gl_account_id'],
'department_id' => $validated['department_id'] ? (int) $validated['department_id'] : null,
'period_type' => Budget::PERIOD_YEARLY,
'period_start' => "{$year}-01-01",
'period_end' => "{$year}-12-31",
'amount' => $amount,
]),
};
return back()->with('success', 'Budget line(s) added.');
}
/**
* Update budget line amounts.
*/
public function updateLines(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'lines' => 'required|array',
'lines.*' => 'required|numeric|min:0',
]);
$this->budgetService->updateBudgetLines($budget, $validated['lines']);
return back()->with('success', 'Budget amounts updated.');
}
/**
* Delete a budget line.
*/
public function deleteLine(Request $request, Business $business, Budget $budget, BudgetLine $line): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
if ($line->budget_id !== $budget->id) {
abort(404);
}
$line->delete();
return back()->with('success', 'Budget line deleted.');
}
/**
* Approve a budget.
*/
public function approve(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$budget->approve(auth()->id());
return back()->with('success', 'Budget approved.');
}
/**
* Unapprove a budget.
*/
public function unapprove(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$budget->unapprove();
return back()->with('success', 'Budget approval removed.');
}
/**
* Copy budget to new fiscal year.
*/
public function copy(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'target_year' => 'required|integer|min:2020|max:2100',
]);
$newBudget = $this->budgetService->copyBudget(
$budget,
(int) $validated['target_year'],
auth()->id()
);
return redirect()
->route('seller.business.management.budgets.edit', [$business, $newBudget])
->with('success', 'Budget copied to '.$validated['target_year'].'.');
}
/**
* Delete a budget.
*/
public function destroy(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$budget->delete();
return redirect()
->route('seller.business.management.budgets.index', $business)
->with('success', 'Budget deleted.');
}
/**
* Validate budget belongs to allowed business.
*/
private function authorizeForBusiness(Business $business, Budget $budget): void
{
$allowedIds = $this->getAllowedBusinessIds($business);
if (! in_array($budget->business_id, $allowedIds)) {
abort(404);
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\CashFlowForecastService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CashFlowForecastController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected CashFlowForecastService $forecastService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the cash flow forecast.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Get forecast options from request
$horizonDays = (int) $request->get('horizon', 60);
$horizonDays = in_array($horizonDays, [30, 60, 90]) ? $horizonDays : 60;
$granularity = $request->get('granularity', 'weekly');
$granularity = in_array($granularity, ['daily', 'weekly']) ? $granularity : 'weekly';
$includeBudgets = $request->boolean('include_budgets', true);
$includeRecurring = $request->boolean('include_recurring', true);
// Determine which business to forecast
$forecastBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
// Generate forecast
$forecast = $this->forecastService->getForecastTimeline($forecastBusiness, [
'horizon_days' => $horizonDays,
'granularity' => $granularity,
'include_children' => $includeChildren,
'include_budgets' => $includeBudgets,
'include_recurring' => $includeRecurring,
]);
return view('seller.management.financials.cash-flow-forecast', $this->withDivisionFilter([
'business' => $business,
'forecast' => $forecast,
'horizonDays' => $horizonDays,
'granularity' => $granularity,
'includeBudgets' => $includeBudgets,
'includeRecurring' => $includeRecurring,
], $filterData));
}
}

View File

@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ArCustomer;
use App\Models\Accounting\ArInvoice;
use App\Models\Accounting\Budget;
use App\Models\Business;
use App\Services\Accounting\ArAnalyticsService;
use App\Services\Accounting\BudgetService;
use App\Services\Accounting\CashFlowForecastService;
use App\Services\Accounting\FinanceAnalyticsService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CfoDashboardController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected CashFlowForecastService $cashFlowService,
protected ArAnalyticsService $arService,
protected FinanceAnalyticsService $financeService,
protected BudgetService $budgetService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the CFO Dashboard.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Determine scope - use snake_case keys from getDivisionFilterData()
$targetBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
$businessIds = $filterData['business_ids'];
// ═══════════════════════════════════════════════════════════════
// CASH POSITION & FORECAST
// ═══════════════════════════════════════════════════════════════
$cashData = $this->getCashData($targetBusiness, $includeChildren);
// ═══════════════════════════════════════════════════════════════
// AR (RECEIVABLES) DATA
// ═══════════════════════════════════════════════════════════════
$arData = $this->getArData($business, $businessIds);
// ═══════════════════════════════════════════════════════════════
// AP (PAYABLES) DATA
// ═══════════════════════════════════════════════════════════════
$apData = $this->getApData($business, $businessIds);
// ═══════════════════════════════════════════════════════════════
// BUDGET VS ACTUAL
// ═══════════════════════════════════════════════════════════════
$budgetData = $this->getBudgetData($business, $businessIds);
// ═══════════════════════════════════════════════════════════════
// TOP CUSTOMERS & VENDORS
// ═══════════════════════════════════════════════════════════════
$topCustomers = $this->getTopCustomers($businessIds);
$topVendors = $this->getTopVendors($businessIds);
// ═══════════════════════════════════════════════════════════════
// RISK INDICATORS
// ═══════════════════════════════════════════════════════════════
$riskData = $this->getRiskIndicators($businessIds);
return view('seller.management.cfo.dashboard', $this->withDivisionFilter([
'business' => $business,
'cashData' => $cashData,
'arData' => $arData,
'apData' => $apData,
'budgetData' => $budgetData,
'topCustomers' => $topCustomers,
'topVendors' => $topVendors,
'riskData' => $riskData,
'isParent' => $business->hasChildBusinesses(),
], $filterData));
}
/**
* Get cash position and forecast data.
*/
protected function getCashData(Business $business, bool $includeChildren): array
{
$startingCash = $this->cashFlowService->getStartingCashBalance($business, now(), $includeChildren);
$forecast = $this->cashFlowService->getForecastTimeline($business, [
'horizon_days' => 30,
'granularity' => 'daily',
'include_children' => $includeChildren,
'include_budgets' => true,
'include_recurring' => true,
]);
return [
'current_cash' => $startingCash['gl_balance'],
'plaid_balance' => $startingCash['plaid_balance'],
'plaid_difference' => $startingCash['difference'],
'projected_30d_ending' => $forecast['summary']['ending_cash'],
'projected_30d_min' => $forecast['summary']['min_cash'],
'projected_30d_max' => $forecast['summary']['max_cash'],
'min_cash_date' => $forecast['summary']['min_cash_date'],
'total_inflows' => $forecast['summary']['total_inflows'],
'total_outflows' => $forecast['summary']['total_outflows'],
'timeline' => $forecast['timeline'],
];
}
/**
* Get AR metrics and aging.
*/
protected function getArData(Business $business, array $businessIds): array
{
$metrics = $this->arService->getARMetrics($business, $businessIds);
$aging = $this->arService->getARAging($business, $businessIds);
// Count at-risk and on-hold customers
$atRiskCount = ArCustomer::whereIn('business_id', $businessIds)
->where(function ($q) {
$q->where('on_credit_hold', true)
->orWhereHas('invoices', function ($q2) {
$q2->where('status', ArInvoice::STATUS_OVERDUE)
->where('balance_due', '>', 0);
});
})
->count();
$onHoldCount = ArCustomer::whereIn('business_id', $businessIds)
->where('on_credit_hold', true)
->count();
return [
'total_outstanding' => $metrics['total_outstanding'],
'overdue_amount' => $metrics['overdue_amount'],
'overdue_count' => $metrics['overdue_count'],
'invoice_count' => $metrics['total_invoice_count'],
'ytd_collections' => $metrics['ytd_collections'],
'avg_days_to_pay' => $metrics['avg_days_to_pay'],
'aging' => $aging,
'at_risk_count' => $atRiskCount,
'on_hold_count' => $onHoldCount,
'over_90_amount' => $aging['over_90'] ?? 0,
];
}
/**
* Get AP metrics and aging.
*/
protected function getApData(Business $business, array $businessIds): array
{
$aging = $this->financeService->getAPAging($business);
// Get 30-day AP due
$next30DaysAp = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('balance_due', '>', 0)
->whereBetween('due_date', [now(), now()->addDays(30)])
->sum('balance_due');
$pastDueAmount = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->sum('balance_due');
return [
'total_outstanding' => $aging['total'],
'past_due_amount' => (float) $pastDueAmount,
'next_30d_due' => (float) $next30DaysAp,
'aging_buckets' => $aging['buckets'],
'overdue_bill_count' => $aging['overdue_bills']->count(),
];
}
/**
* Get budget vs actual summary.
*/
protected function getBudgetData(Business $business, array $businessIds): array
{
$currentYear = now()->year;
// Get the first active budget for current year
$budget = Budget::whereIn('business_id', $businessIds)
->where('fiscal_year', $currentYear)
->active()
->first();
if (! $budget) {
return [
'has_budget' => false,
'total_budget' => 0,
'total_actual' => 0,
'variance_amount' => 0,
'variance_percent' => 0,
'top_overbudget' => [],
];
}
$summary = $this->budgetService->getBudgetSummary($budget);
// Get top 3 departments over budget
$topOverbudget = collect($summary['by_department'])
->filter(fn ($dept) => $dept['actual'] > $dept['budget'])
->sortByDesc(fn ($dept) => $dept['actual'] - $dept['budget'])
->take(3)
->values();
return [
'has_budget' => true,
'budget_name' => $budget->name,
'total_budget' => $summary['total_budget'],
'total_actual' => $summary['total_actual'],
'variance_amount' => $summary['variance_amount'],
'variance_percent' => $summary['variance_percent'],
'top_overbudget' => $topOverbudget,
];
}
/**
* Get top AR customers by outstanding balance.
*/
protected function getTopCustomers(array $businessIds): \Illuminate\Support\Collection
{
return ArInvoice::whereIn('ar_invoices.business_id', $businessIds)
->whereNotIn('ar_invoices.status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('ar_invoices.balance_due', '>', 0)
->with(['customer', 'business'])
->get()
->groupBy('customer_id')
->map(function ($invoices) {
$customer = $invoices->first()->customer;
$oldestInvoice = $invoices->sortBy('due_date')->first();
$daysOverdue = $oldestInvoice->due_date && $oldestInvoice->due_date->isPast()
? $oldestInvoice->due_date->diffInDays(now())
: 0;
return [
'customer' => $customer,
'division' => $invoices->first()->business,
'balance' => (float) $invoices->sum('balance_due'),
'invoice_count' => $invoices->count(),
'days_overdue' => $daysOverdue,
'on_hold' => $customer?->on_credit_hold ?? false,
];
})
->sortByDesc('balance')
->take(5)
->values();
}
/**
* Get top AP vendors by outstanding balance.
*/
protected function getTopVendors(array $businessIds): \Illuminate\Support\Collection
{
return ApBill::whereIn('ap_bills.business_id', $businessIds)
->whereIn('ap_bills.status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('ap_bills.balance_due', '>', 0)
->with(['vendor', 'business'])
->get()
->groupBy('vendor_id')
->map(function ($bills) {
$vendor = $bills->first()->vendor;
$oldestBill = $bills->sortBy('due_date')->first();
$daysOverdue = $oldestBill->due_date && $oldestBill->due_date->isPast()
? $oldestBill->due_date->diffInDays(now())
: 0;
// Get divisions
$divisions = $bills->pluck('business')->unique('id');
return [
'vendor' => $vendor,
'divisions' => $divisions,
'balance' => (float) $bills->sum('balance_due'),
'bill_count' => $bills->count(),
'days_overdue' => $daysOverdue,
];
})
->sortByDesc('balance')
->take(5)
->values();
}
/**
* Get risk indicators summary.
*/
protected function getRiskIndicators(array $businessIds): array
{
// Credit holds
$creditHoldsCount = ArCustomer::whereIn('business_id', $businessIds)
->where('on_credit_hold', true)
->count();
// Severely overdue AR (90+ days)
$severeArCount = ArInvoice::whereIn('business_id', $businessIds)
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now()->subDays(90))
->count();
// Past due AP
$pastDueApCount = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->count();
return [
'credit_holds' => $creditHoldsCount,
'severe_ar_count' => $severeArCount,
'past_due_ap_count' => $pastDueApCount,
'total_risks' => $creditHoldsCount + $severeArCount + $pastDueApCount,
];
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ChartOfAccountsController extends Controller
{
/**
* Display the Chart of Accounts.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $this->getParentBusiness($business);
$query = GlAccount::where('business_id', $parentBusiness->id)
->orderBy('account_number');
// Filter by account type
if ($request->filled('type')) {
$query->where('account_type', $request->type);
}
// Filter by account class
if ($request->filled('class')) {
$query->where('account_class', $request->class);
}
// Filter by active status
if ($request->filled('active')) {
$query->where('is_active', $request->active === 'true');
}
// Search by number or name
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('account_number', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
}
$accounts = $query->get();
// Group accounts by type for tree view
$accountsByType = $accounts->groupBy('account_type');
// Summary stats
$stats = [
'total' => $accounts->count(),
'active' => $accounts->where('is_active', true)->count(),
'inactive' => $accounts->where('is_active', false)->count(),
'reconciliation' => $accounts->where('is_reconciliation', true)->count(),
'system' => $accounts->where('is_system', true)->count(),
];
return view('seller.management.chart-of-accounts.index', compact(
'business',
'parentBusiness',
'accounts',
'accountsByType',
'stats'
));
}
/**
* Show form to create a new GL account.
*/
public function create(Request $request, Business $business): View
{
$parentBusiness = $this->getParentBusiness($business);
// Get existing accounts for parent selection
$parentAccounts = GlAccount::where('business_id', $parentBusiness->id)
->where('is_header', true)
->orderBy('account_number')
->get();
return view('seller.management.chart-of-accounts.create', compact(
'business',
'parentBusiness',
'parentAccounts'
));
}
/**
* Store a new GL account.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
$validated = $request->validate([
'account_number' => [
'required',
'string',
'max:20',
"unique:gl_accounts,account_number,NULL,id,business_id,{$parentBusiness->id}",
],
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'account_type' => 'required|in:asset,liability,equity,revenue,expense',
'account_subtype' => 'nullable|string|max:50',
'parent_account_id' => 'nullable|exists:gl_accounts,id',
'is_header' => 'boolean',
'is_reconciliation' => 'boolean',
'reconciliation_type' => 'nullable|in:ar,ap,fixed_asset,inventory,bank',
'cash_flow_category' => 'nullable|in:operating,investing,financing',
]);
// Set defaults based on account type
$validated['business_id'] = $parentBusiness->id;
$validated['normal_balance'] = GlAccount::getDefaultNormalBalance($validated['account_type']);
$validated['account_class'] = GlAccount::getDefaultAccountClass($validated['account_type']);
$validated['is_operating'] = ! in_array($validated['account_subtype'] ?? '', ['interest', 'other_income', 'other_expense']);
$validated['is_active'] = true;
$validated['is_system'] = false;
GlAccount::create($validated);
return redirect()
->route('seller.business.management.chart-of-accounts.index', $business)
->with('success', 'GL Account created successfully.');
}
/**
* Show form to edit a GL account.
*/
public function edit(Request $request, Business $business, GlAccount $account): View
{
$parentBusiness = $this->getParentBusiness($business);
// Verify account belongs to parent business
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
// Get existing accounts for parent selection
$parentAccounts = GlAccount::where('business_id', $parentBusiness->id)
->where('is_header', true)
->where('id', '!=', $account->id)
->orderBy('account_number')
->get();
return view('seller.management.chart-of-accounts.edit', compact(
'business',
'parentBusiness',
'account',
'parentAccounts'
));
}
/**
* Update a GL account.
*/
public function update(Request $request, Business $business, GlAccount $account): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
// Verify account belongs to parent business
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
// System accounts have limited editability
$rules = [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'cash_flow_category' => 'nullable|in:operating,investing,financing',
];
if (! $account->is_system) {
$rules['account_number'] = [
'required',
'string',
'max:20',
"unique:gl_accounts,account_number,{$account->id},id,business_id,{$parentBusiness->id}",
];
$rules['account_type'] = 'required|in:asset,liability,equity,revenue,expense';
$rules['account_subtype'] = 'nullable|string|max:50';
$rules['parent_account_id'] = 'nullable|exists:gl_accounts,id';
$rules['is_header'] = 'boolean';
$rules['is_reconciliation'] = 'boolean';
$rules['reconciliation_type'] = 'nullable|in:ar,ap,fixed_asset,inventory,bank';
}
$validated = $request->validate($rules);
// Update type-dependent fields if type changed
if (! $account->is_system && isset($validated['account_type'])) {
$validated['normal_balance'] = GlAccount::getDefaultNormalBalance($validated['account_type']);
$validated['account_class'] = GlAccount::getDefaultAccountClass($validated['account_type']);
}
$account->update($validated);
return redirect()
->route('seller.business.management.chart-of-accounts.index', $business)
->with('success', 'GL Account updated successfully.');
}
/**
* Toggle active status of a GL account.
*/
public function toggleActive(Request $request, Business $business, GlAccount $account): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
if ($account->is_system) {
return back()->with('error', 'System accounts cannot be deactivated.');
}
if ($account->is_active && ! $account->canBeDeactivated()) {
return back()->with('error', 'Account has open balance and cannot be deactivated.');
}
$account->update(['is_active' => ! $account->is_active]);
$status = $account->is_active ? 'activated' : 'deactivated';
return back()->with('success', "Account {$status} successfully.");
}
/**
* Delete a GL account.
*/
public function destroy(Request $request, Business $business, GlAccount $account): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
if (! $account->canBeDeleted()) {
return back()->with('error', 'This account cannot be deleted. It is either a system account or has transactions.');
}
$account->delete();
return redirect()
->route('seller.business.management.chart-of-accounts.index', $business)
->with('success', 'GL Account deleted successfully.');
}
/**
* Get the parent business for GL account management.
* GL accounts belong to the parent company, not divisions.
*/
private function getParentBusiness(Business $business): Business
{
return $business->parent ?? $business;
}
}

View File

@@ -0,0 +1,201 @@
<?php
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\CustomerFinancialService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DirectoryCustomersController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected CustomerFinancialService $customerService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List AR customers.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$customers = ArCustomer::whereIn('business_id', $filterData['business_ids'])
->with(['business'])
->withCount('invoices')
->orderBy('name')
->paginate(20);
return view('seller.management.directory.customers.index', $this->withDivisionFilter([
'business' => $business,
'customers' => $customers,
], $filterData));
}
/**
* Show customer financial summary.
*/
public function showFinancials(Request $request, Business $business, ArCustomer $customer): View
{
$this->requireManagementSuite($business);
// Validate ownership
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($customer->business_id, $allowedBusinessIds)) {
abort(404);
}
$isParent = $business->hasChildBusinesses();
$includeChildren = $isParent;
$summary = $this->customerService->getFinancialSummary($customer, $business, $includeChildren);
$invoices = $this->customerService->getInvoices($customer, $business, $includeChildren);
$payments = $this->customerService->getPayments($customer, $business, $includeChildren);
$activities = $this->customerService->getRecentActivity($customer, $business);
return view('seller.management.directory.customers.financials', [
'business' => $business,
'customer' => $customer,
'summary' => $summary,
'invoices' => $invoices,
'payments' => $payments,
'activities' => $activities,
'isParent' => $isParent,
]);
}
/**
* Update customer credit limit.
*/
public function updateCreditLimit(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$validated = $request->validate([
'credit_limit' => 'required|numeric|min:0',
'reason' => 'nullable|string|max:255',
]);
$this->customerService->updateCreditLimit(
$customer,
(float) $validated['credit_limit'],
auth()->id(),
$validated['reason'] ?? null
);
return back()->with('success', 'Credit limit updated.');
}
/**
* Update customer terms.
*/
public function updateTerms(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$validated = $request->validate([
'payment_terms' => 'required|string|max:50',
]);
$this->customerService->updateTerms(
$customer,
$validated['payment_terms'],
auth()->id()
);
return back()->with('success', 'Payment terms updated.');
}
/**
* Place credit hold.
*/
public function placeCreditHold(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$validated = $request->validate([
'reason' => 'required|string|max:255',
]);
$this->customerService->placeCreditHold(
$customer,
auth()->id(),
$validated['reason']
);
return back()->with('success', 'Credit hold placed.');
}
/**
* Remove credit hold.
*/
public function removeCreditHold(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$this->customerService->removeCreditHold(
$customer,
auth()->id()
);
return back()->with('success', 'Credit hold removed.');
}
/**
* Add a note.
*/
public function addNote(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($customer->business_id, $allowedBusinessIds)) {
abort(404);
}
$validated = $request->validate([
'note' => 'required|string|max:1000',
]);
$this->customerService->addNote(
$customer,
auth()->id(),
$validated['note']
);
return back()->with('success', 'Note added.');
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Business;
use App\Services\Accounting\VendorFinancialService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DirectoryVendorsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected VendorFinancialService $vendorService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List AP vendors.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$isParent = $business->hasChildBusinesses();
$vendors = ApVendor::whereIn('business_id', $filterData['business_ids'])
->with(['business'])
->withCount('bills')
->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));
}
/**
* Show vendor financial summary.
*/
public function showFinancials(Request $request, Business $business, ApVendor $vendor): View
{
$this->requireManagementSuite($business);
// Validate ownership
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(404);
}
$isParent = $business->hasChildBusinesses();
$includeChildren = $isParent;
$summary = $this->vendorService->getFinancialSummary($vendor, $business, $includeChildren);
$bills = $this->vendorService->getBills($vendor, $business, $includeChildren);
$payments = $this->vendorService->getPayments($vendor, $business, $includeChildren);
$activities = $this->vendorService->getRecentActivity($vendor, $business);
return view('seller.management.directory.vendors.financials', [
'business' => $business,
'vendor' => $vendor,
'summary' => $summary,
'bills' => $bills,
'payments' => $payments,
'activities' => $activities,
'isParent' => $isParent,
]);
}
/**
* Update vendor terms.
*/
public function updateTerms(Request $request, Business $business, ApVendor $vendor): RedirectResponse
{
$this->requireManagementSuite($business);
if ($vendor->business_id !== $business->id) {
abort(403, 'Can only modify vendors in your own business.');
}
$validated = $request->validate([
'payment_terms' => 'required|string|max:50',
]);
$this->vendorService->updateTerms(
$vendor,
$validated['payment_terms'],
auth()->id()
);
return back()->with('success', 'Payment terms updated.');
}
/**
* Add a note.
*/
public function addNote(Request $request, Business $business, ApVendor $vendor): RedirectResponse
{
$this->requireManagementSuite($business);
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(404);
}
$validated = $request->validate([
'note' => 'required|string|max:1000',
]);
$this->vendorService->addNote(
$vendor,
auth()->id(),
$validated['note']
);
return back()->with('success', 'Note added.');
}
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Expense;
use App\Models\Business;
use App\Models\Department;
use App\Services\Accounting\ExpenseService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Management Suite Expenses Controller.
*
* Handles expense approval, rejection, and payment by finance team.
* Parent companies can see and manage expenses from all child businesses.
*/
class ExpensesController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected ExpenseService $expenseService
) {}
/**
* Validate that the expense belongs to the current business or its divisions.
*/
private function validateExpenseOwnership(Business $business, Expense $expense): void
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($expense->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
}
/**
* Ensure the business has Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List expenses for management review.
*
* GET /s/{business}/management/expenses
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$query = Expense::whereIn('business_id', $filterData['business_ids'])
->with(['department', 'createdBy', 'approvedBy', 'business', 'items']);
// Status filter
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Department filter
if ($request->filled('department_id')) {
$query->where('department_id', $request->department_id);
}
// Payment method filter
if ($request->filled('payment_method')) {
$query->where('payment_method', $request->payment_method);
}
// Date range
if ($request->filled('from_date')) {
$query->whereDate('expense_date', '>=', $request->from_date);
}
if ($request->filled('to_date')) {
$query->whereDate('expense_date', '<=', $request->to_date);
}
$expenses = $query->orderByDesc('expense_date')->paginate(20)->withQueryString();
// Get departments for filter (from all filtered businesses)
$departments = Department::whereIn('business_id', $filterData['business_ids'])
->where('is_active', true)
->orderBy('name')
->get();
// Metrics
$metrics = $this->expenseService->getExpenseMetrics($business, $filterData['business_ids']);
return view('seller.management.expenses.index', $this->withDivisionFilter([
'business' => $business,
'expenses' => $expenses,
'departments' => $departments,
'metrics' => $metrics,
'statuses' => Expense::getStatuses(),
'paymentMethods' => Expense::getPaymentMethods(),
], $filterData));
}
/**
* Show expense details for management review.
*
* GET /s/{business}/management/expenses/{expense}
*/
public function show(Request $request, Business $business, Expense $expense): View
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$expense->load([
'items.glAccount',
'items.department',
'department',
'createdBy',
'approvedBy',
'paidBy',
'business',
'journalEntry',
'apBill',
]);
$isParent = $business->hasChildBusinesses();
return view('seller.management.expenses.show', compact('business', 'expense', 'isParent'));
}
/**
* Approve an expense.
*
* POST /s/{business}/management/expenses/{expense}/approve
*/
public function approve(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$validated = $request->validate([
'payment_method' => 'nullable|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
]);
try {
$this->expenseService->approveExpense($expense, auth()->user(), $validated);
return back()->with('success', "Expense {$expense->expense_number} approved.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Reject an expense.
*
* POST /s/{business}/management/expenses/{expense}/reject
*/
public function reject(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$validated = $request->validate([
'rejection_reason' => 'nullable|string|max:500',
]);
try {
$this->expenseService->rejectExpense($expense, auth()->user(), $validated['rejection_reason'] ?? null);
return back()->with('success', "Expense {$expense->expense_number} rejected.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Mark an expense as paid.
*
* POST /s/{business}/management/expenses/{expense}/mark-paid
*/
public function markPaid(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$validated = $request->validate([
'paid_date' => 'nullable|date',
'reference' => 'nullable|string|max:255',
]);
try {
$this->expenseService->markExpensePaid($expense, auth()->user(), $validated);
return back()->with('success', "Expense {$expense->expense_number} marked as paid.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Bulk approve expenses.
*
* POST /s/{business}/management/expenses/bulk-approve
*/
public function bulkApprove(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'expense_ids' => 'required|array|min:1',
'expense_ids.*' => 'integer|exists:expenses,id',
]);
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
$approver = auth()->user();
$approved = 0;
$errors = [];
foreach ($validated['expense_ids'] as $expenseId) {
$expense = Expense::find($expenseId);
if (! $expense || ! in_array($expense->business_id, $allowedBusinessIds)) {
continue;
}
try {
$this->expenseService->approveExpense($expense, $approver);
$approved++;
} catch (\InvalidArgumentException $e) {
$errors[] = "{$expense->expense_number}: {$e->getMessage()}";
}
}
$message = "{$approved} expense(s) approved.";
if (count($errors) > 0) {
$message .= ' Errors: '.implode('; ', $errors);
}
return back()->with($errors ? 'warning' : 'success', $message);
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
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\Services\Accounting\ReportExportService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinanceController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected FinanceAnalyticsService $analyticsService,
protected ArService $arService,
protected ReportExportService $exportService
) {}
public function apAging(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$aging = $this->analyticsService->getAPAging($business, $filterData['business_ids']);
$byDivision = $this->analyticsService->getAPBreakdownByDivision($business, $filterData['business_ids']);
$byVendor = $this->analyticsService->getAPBreakdownByVendor($business, $filterData['business_ids']);
return view('seller.management.finance.ap-aging', $this->withDivisionFilter([
'business' => $business,
'aging' => $aging,
'byDivision' => $byDivision,
'byVendor' => $byVendor,
], $filterData));
}
public function cashForecast(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$days = $request->integer('days', 30);
$days = in_array($days, [7, 14, 30]) ? $days : 30;
$forecast = $this->analyticsService->getCashForecast($business, $days, $filterData['business_ids']);
return view('seller.management.finance.cash-forecast', $this->withDivisionFilter([
'business' => $business,
'forecast' => $forecast,
'days' => $days,
], $filterData));
}
public function divisionRollup(Request $request, Business $business)
{
if (! $this->analyticsService->isParentCompany($business)) {
abort(403, 'Only parent companies can view divisional rollups.');
}
$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'));
}
public function vendorSpend(Request $request, Business $business)
{
$isParent = $this->analyticsService->isParentCompany($business);
$divisions = collect();
$selectedDivisionId = null;
$selectedDivision = null;
if ($isParent) {
$divisions = $this->analyticsService->getChildBusinesses($business);
$divisionIdParam = $request->get('division_id');
if ($divisionIdParam && $divisionIdParam !== 'all') {
$selectedDivisionId = (int) $divisionIdParam;
$selectedDivision = $divisions->firstWhere('id', $selectedDivisionId);
if (! $selectedDivision) {
$selectedDivisionId = null;
}
}
}
$spend = $this->analyticsService->getVendorSpend($business, $selectedDivisionId);
return view('seller.management.finance.vendor-spend', compact(
'business', 'spend', 'isParent', 'divisions', 'selectedDivisionId', 'selectedDivision'
));
}
public function index(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
// AP Data
$aging = $this->analyticsService->getAPAging($business, $filterData['business_ids']);
$forecast = $this->analyticsService->getCashForecast($business, 7, $filterData['business_ids']);
// AR Data
$arSummary = $this->arService->getArSummary($business, $filterData['business_ids']);
$topArAccounts = $this->arService->getTopArAccounts($business, 5, $filterData['business_ids']);
return view('seller.management.finance.index', $this->withDivisionFilter([
'business' => $business,
'aging' => $aging,
'forecast' => $forecast,
'arSummary' => $arSummary,
'topArAccounts' => $topArAccounts,
], $filterData));
}
/**
* Export AP Aging report as CSV.
*/
public function exportApAging(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$byVendor = $this->analyticsService->getAPBreakdownByVendor($business, $filterData['business_ids']);
$filename = 'ap_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportApAging($byVendor, $filename);
}
/**
* Export AR Aging report as CSV.
*/
public function exportArAging(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$arAccounts = $this->arService->getArAgingReport($business, $filterData['business_ids']);
$filename = 'ar_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportArAging($arAccounts, $filename);
}
/**
* Export Cash Flow Forecast as CSV.
*/
public function exportCashForecast(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$days = $request->integer('days', 30);
$forecast = $this->analyticsService->getCashForecast($business, $days, $filterData['business_ids']);
$filename = 'cash_forecast_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportCashFlowForecast($forecast, $filename);
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\User;
use App\Services\Accounting\PeriodLockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FinanceRolesController extends Controller
{
public function __construct(
protected PeriodLockService $periodLockService
) {}
/**
* Display finance role settings.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
// Get all users in this business
$users = User::whereHas('businesses', function ($query) use ($business) {
$query->where('businesses.id', $business->id);
})->with(['businesses' => function ($query) use ($business) {
$query->where('businesses.id', $business->id);
}])->orderBy('name')->get();
// Add finance roles to each user
$users->each(function ($user) use ($business) {
$user->finance_roles = $this->periodLockService->getUserFinanceRoles($business, $user);
$user->finance_permissions = $this->periodLockService->getUserPermissions($business, $user);
});
$availableRoles = config('finance_roles.roles', []);
$allPermissions = config('finance_roles.permissions', []);
return view('seller.management.settings.finance-roles', [
'business' => $business,
'users' => $users,
'availableRoles' => $availableRoles,
'allPermissions' => $allPermissions,
]);
}
/**
* Update finance roles for a user.
*/
public function update(Request $request, Business $business, User $user): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
$validated = $request->validate([
'finance_roles' => 'nullable|array',
'finance_roles.*' => 'string|in:'.implode(',', array_keys(config('finance_roles.roles', []))),
]);
// Ensure user belongs to this business
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
if (! $pivot) {
return back()->with('error', 'User does not belong to this business.');
}
// Update the pivot record
$user->businesses()->updateExistingPivot($business->id, [
'finance_roles' => json_encode($validated['finance_roles'] ?? []),
]);
return back()->with('success', "Finance roles updated for {$user->name}.");
}
/**
* Bulk update finance roles for multiple users.
*/
public function bulkUpdate(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
$validated = $request->validate([
'users' => 'required|array',
'users.*.id' => 'required|exists:users,id',
'users.*.finance_roles' => 'nullable|array',
'users.*.finance_roles.*' => 'string|in:'.implode(',', array_keys(config('finance_roles.roles', []))),
]);
$updated = 0;
foreach ($validated['users'] as $userData) {
$user = User::find($userData['id']);
// Ensure user belongs to this business
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
if ($pivot) {
$user->businesses()->updateExistingPivot($business->id, [
'finance_roles' => json_encode($userData['finance_roles'] ?? []),
]);
$updated++;
}
}
return back()->with('success', "Finance roles updated for {$updated} users.");
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Require a specific finance permission.
*/
private function requirePermission(Business $business, User $user, string $permission): void
{
// Business owners always have access
if ($business->owner_user_id === $user->id) {
return;
}
// Check bypass mode
if (config('finance_roles.bypass_permissions', false)) {
return;
}
if (! $this->periodLockService->userHasPermission($business, $user, $permission)) {
abort(403, 'You do not have permission to manage finance roles.');
}
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\AccountingReportingService;
use App\Services\Accounting\ReportExportService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinancialsController extends Controller
{
public function __construct(
protected AccountingReportingService $reportingService,
protected ReportExportService $exportService
) {}
/**
* Profit & Loss Statement.
*
* GET /s/{business}/management/financials/profit-and-loss
*/
public function profitAndLoss(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$pnl = $this->reportingService->getProfitAndLoss(
$business,
$fromDate,
$toDate,
$isParent && $includeChildren
);
// Get prior period for comparison (same duration, previous period)
$periodDays = now()->parse($fromDate)->diffInDays(now()->parse($toDate));
$priorFromDate = now()->parse($fromDate)->subDays($periodDays + 1)->format('Y-m-d');
$priorToDate = now()->parse($fromDate)->subDay()->format('Y-m-d');
$priorPnl = $this->reportingService->getProfitAndLoss(
$business,
$priorFromDate,
$priorToDate,
$isParent && $includeChildren
);
return view('seller.management.financials.profit-and-loss', compact(
'business',
'pnl',
'priorPnl',
'fromDate',
'toDate',
'includeChildren',
'isParent'
));
}
/**
* Balance Sheet.
*
* GET /s/{business}/management/financials/balance-sheet
*/
public function balanceSheet(Request $request, Business $business)
{
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$balanceSheet = $this->reportingService->getBalanceSheet(
$business,
$asOfDate,
$isParent && $includeChildren
);
return view('seller.management.financials.balance-sheet', compact(
'business',
'balanceSheet',
'asOfDate',
'includeChildren',
'isParent'
));
}
/**
* Cash Flow Statement (Indirect Method).
*
* GET /s/{business}/management/financials/cash-flow
*/
public function cashFlow(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$cashFlow = $this->reportingService->getCashFlowIndirect(
$business,
$fromDate,
$toDate,
$isParent && $includeChildren
);
return view('seller.management.financials.cash-flow', compact(
'business',
'cashFlow',
'fromDate',
'toDate',
'includeChildren',
'isParent'
));
}
/**
* Consolidated P&L - Side-by-side comparison of all divisions.
*
* GET /s/{business}/management/financials/consolidated-pnl
*/
public function consolidatedPnl(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$parentBusiness = $business->parent ?? $business;
$divisions = Business::where('parent_id', $parentBusiness->id)
->orderBy('name')
->get();
// Get P&L for each division
$divisionPnls = [];
foreach ($divisions as $division) {
$divisionPnls[$division->id] = [
'division' => $division,
'pnl' => $this->reportingService->getProfitAndLoss($division, $fromDate, $toDate, false),
];
}
// Get consolidated (total) P&L
$consolidatedPnl = $this->reportingService->getProfitAndLoss(
$parentBusiness,
$fromDate,
$toDate,
true
);
return view('seller.management.financials.consolidated-pnl', compact(
'business',
'parentBusiness',
'divisions',
'divisionPnls',
'consolidatedPnl',
'fromDate',
'toDate'
));
}
/**
* Consolidated Balance Sheet - Side-by-side comparison of all divisions.
*
* GET /s/{business}/management/financials/consolidated-balance-sheet
*/
public function consolidatedBalanceSheet(Request $request, Business $business)
{
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
$parentBusiness = $business->parent ?? $business;
$divisions = Business::where('parent_id', $parentBusiness->id)
->orderBy('name')
->get();
// Get Balance Sheet for each division
$divisionBalanceSheets = [];
foreach ($divisions as $division) {
$divisionBalanceSheets[$division->id] = [
'division' => $division,
'balanceSheet' => $this->reportingService->getBalanceSheet($division, $asOfDate, false),
];
}
// Get consolidated Balance Sheet
$consolidatedBalanceSheet = $this->reportingService->getBalanceSheet(
$parentBusiness,
$asOfDate,
true
);
return view('seller.management.financials.consolidated-balance-sheet', compact(
'business',
'parentBusiness',
'divisions',
'divisionBalanceSheets',
'consolidatedBalanceSheet',
'asOfDate'
));
}
/**
* Export Profit & Loss as CSV.
*
* GET /s/{business}/management/financials/profit-and-loss/export
*/
public function exportProfitAndLoss(Request $request, Business $business): StreamedResponse
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$pnl = $this->reportingService->getProfitAndLoss(
$business,
$fromDate,
$toDate,
$isParent && $includeChildren
);
$filename = "profit_loss_{$business->slug}_{$fromDate}_to_{$toDate}.csv";
return $this->exportService->exportProfitLoss($pnl, $filename);
}
/**
* Export Balance Sheet as CSV.
*
* GET /s/{business}/management/financials/balance-sheet/export
*/
public function exportBalanceSheet(Request $request, Business $business): StreamedResponse
{
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$balanceSheet = $this->reportingService->getBalanceSheet(
$business,
$asOfDate,
$isParent && $includeChildren
);
$filename = "balance_sheet_{$business->slug}_{$asOfDate}.csv";
return $this->exportService->exportBalanceSheet($balanceSheet, $filename);
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\FixedAsset;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Services\Accounting\FixedAssetService;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FixedAssetsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected FixedAssetService $assetService
) {}
/**
* Validate that the asset belongs to the current business or its divisions.
* Prevents cross-tenant access via route model binding.
*/
private function validateAssetOwnership(Business $business, FixedAsset $asset): void
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($asset->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
}
/**
* Ensure the business has Management Suite access for mutating actions.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Ensure the business is not a child division (read-only access only).
* Only parent companies can create/update/delete assets.
*/
private function requireParentCompany(Business $business): void
{
if ($business->isDivision()) {
abort(403, 'Divisions have read-only access to fixed assets.');
}
}
/**
* Display fixed assets listing.
*/
public function index(Request $request, Business $business): View
{
$filterData = $this->getDivisionFilterData($business, $request);
$query = FixedAsset::whereIn('business_id', $filterData['business_ids'])
->with(['vendor', 'business']);
// Filter by status
if ($status = $request->get('status')) {
$query->where('status', $status);
}
// Filter by category
if ($category = $request->get('category')) {
$query->where('category', $category);
}
$assets = $query->orderBy('name')->paginate(25)->withQueryString();
$metrics = $this->assetService->getAssetMetrics($business, $filterData['business_ids']);
return view('seller.management.fixed-assets.index', $this->withDivisionFilter([
'business' => $business,
'assets' => $assets,
'metrics' => $metrics,
'categories' => FixedAsset::getCategories(),
'statuses' => FixedAsset::getStatuses(),
'currentStatus' => $request->get('status'),
'currentCategory' => $request->get('category'),
], $filterData));
}
/**
* Show create asset form.
* Requires Management Suite and parent company access.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$glAccounts = GlAccount::where('business_id', $business->id)
->orderBy('account_number')
->get();
return view('seller.management.fixed-assets.create', [
'business' => $business,
'categories' => FixedAsset::getCategories(),
'glAccounts' => $glAccounts,
]);
}
/**
* Store new asset.
* Requires Management Suite and parent company access.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'required|string|in:'.implode(',', array_keys(FixedAsset::getCategories())),
'location' => 'nullable|string|max:255',
'serial_number' => 'nullable|string|max:255',
'acquisition_date' => 'required|date',
'acquisition_cost' => 'required|numeric|min:0',
'acquisition_method' => 'required|string|in:purchase,lease,donation',
'useful_life_months' => 'required|integer|min:1',
'salvage_value' => 'nullable|numeric|min:0',
'depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'accumulated_depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'expense_account_id' => 'nullable|exists:gl_accounts,id',
'notes' => 'nullable|string',
]);
$validated['depreciation_method'] = FixedAsset::METHOD_STRAIGHT_LINE;
$validated['salvage_value'] = $validated['salvage_value'] ?? 0;
$asset = $this->assetService->createAsset($business, $validated);
return redirect()
->route('seller.business.management.fixed-assets.show', [$business, $asset])
->with('success', 'Fixed asset created successfully.');
}
/**
* Show asset details.
* Parent companies can view all divisions' assets.
* Divisions can only view their own assets.
*/
public function show(Request $request, Business $business, FixedAsset $fixedAsset): View
{
$this->validateAssetOwnership($business, $fixedAsset);
$fixedAsset->load(['vendor', 'improvements', 'depreciationRuns', 'disposal']);
return view('seller.management.fixed-assets.show', [
'business' => $business,
'asset' => $fixedAsset,
]);
}
/**
* Show edit form.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function edit(Request $request, Business $business, FixedAsset $fixedAsset): View
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$glAccounts = GlAccount::where('business_id', $business->id)
->orderBy('account_number')
->get();
return view('seller.management.fixed-assets.edit', [
'business' => $business,
'asset' => $fixedAsset,
'categories' => FixedAsset::getCategories(),
'glAccounts' => $glAccounts,
]);
}
/**
* Update asset.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function update(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'required|string|in:'.implode(',', array_keys(FixedAsset::getCategories())),
'location' => 'nullable|string|max:255',
'serial_number' => 'nullable|string|max:255',
'useful_life_months' => 'required|integer|min:1',
'salvage_value' => 'nullable|numeric|min:0',
'depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'accumulated_depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'expense_account_id' => 'nullable|exists:gl_accounts,id',
'notes' => 'nullable|string',
]);
$this->assetService->updateAsset($fixedAsset, $validated);
return redirect()
->route('seller.business.management.fixed-assets.show', [$business, $fixedAsset])
->with('success', 'Fixed asset updated successfully.');
}
/**
* Record an improvement.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function storeImprovement(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$validated = $request->validate([
'description' => 'required|string|max:255',
'improvement_date' => 'required|date',
'cost' => 'required|numeric|min:0',
'extends_life' => 'boolean',
'additional_life_months' => 'nullable|integer|min:1',
'notes' => 'nullable|string',
]);
$this->assetService->recordImprovement($fixedAsset, $validated);
return redirect()
->route('seller.business.management.fixed-assets.show', [$business, $fixedAsset])
->with('success', 'Improvement recorded successfully.');
}
/**
* Run depreciation for a period.
* Requires Management Suite and parent company access.
*/
public function runDepreciation(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$validated = $request->validate([
'period_date' => 'required|date',
]);
$periodDate = Carbon::parse($validated['period_date']);
$filterData = $this->getDivisionFilterData($business, $request);
$runs = $this->assetService->runBatchDepreciation($business, $periodDate, $filterData['business_ids']);
return redirect()
->route('seller.business.management.fixed-assets.index', $business)
->with('success', "Depreciation run complete. {$runs->count()} assets depreciated.");
}
/**
* Show disposal form.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function showDisposeForm(Request $request, Business $business, FixedAsset $fixedAsset): View
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
return view('seller.management.fixed-assets.dispose', [
'business' => $business,
'asset' => $fixedAsset,
'methods' => \App\Models\Accounting\FixedAssetDisposal::getMethods(),
]);
}
/**
* Dispose of an asset.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function dispose(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$validated = $request->validate([
'disposal_date' => 'required|date',
'disposal_method' => 'required|string',
'proceeds' => 'nullable|numeric|min:0',
'buyer_name' => 'nullable|string|max:255',
'buyer_contact' => 'nullable|string|max:255',
'reason' => 'nullable|string',
'notes' => 'nullable|string',
]);
$validated['proceeds'] = $validated['proceeds'] ?? 0;
$this->assetService->disposeAsset($fixedAsset, $validated);
return redirect()
->route('seller.business.management.fixed-assets.index', $business)
->with('success', 'Asset disposed successfully.');
}
}

View File

@@ -0,0 +1,184 @@
<?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);
$filterData = $this->getDivisionFilterData($business, $request);
$businessIds = $filterData['business_ids'];
// Generate 12-month forecast
$forecast = $this->generateForecast($businessIds);
return view('seller.management.forecasting.index', $this->withDivisionFilter([
'business' => $business,
'forecast' => $forecast,
], $filterData));
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
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) 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;
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\InterBusinessSettlement;
use App\Models\Business;
use App\Services\Accounting\InterBusinessSettlementService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Inter-Business Settlement - Manage balances and settlements between divisions.
*
* Allows CFOs to:
* - View inter-business balance matrix
* - Create settlements to zero out balances
* - Post settlements (creates journal entries)
*/
class InterBusinessSettlementController extends Controller
{
public function __construct(
protected InterBusinessSettlementService $settlementService
) {}
/**
* Display inter-business balances and settlement history.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
// Get balance matrix
$matrixData = $this->settlementService->getBalanceMatrix($parentBusiness);
// Get outstanding balances for quick view
$outstandingBalances = $this->settlementService->getOutstandingBalances($parentBusiness);
// Get recent settlements
$settlements = InterBusinessSettlement::where('parent_business_id', $parentBusiness->id)
->with(['lines.fromBusiness', 'lines.toBusiness', 'createdByUser', 'postedByUser'])
->orderByDesc('created_at')
->limit(20)
->get();
// Calculate totals
$totalOutstanding = $outstandingBalances->sum('balance');
return view('seller.management.inter-business.index', compact(
'business',
'parentBusiness',
'matrixData',
'outstandingBalances',
'settlements',
'totalOutstanding'
));
}
/**
* Show form to create a new settlement.
*/
public function create(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
// Get divisions
$divisions = Business::where('parent_id', $parentBusiness->id)
->orderBy('name')
->get();
// Suggest settlements based on outstanding balances
$suggestedLines = $this->settlementService->suggestSettlements($parentBusiness);
return view('seller.management.inter-business.create', compact(
'business',
'parentBusiness',
'divisions',
'suggestedLines'
));
}
/**
* Store a new settlement (as draft).
*/
public function store(Request $request, Business $business): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
$validated = $request->validate([
'description' => 'nullable|string|max:500',
'lines' => 'required|array|min:1',
'lines.*.from_business_id' => 'required|exists:businesses,id',
'lines.*.to_business_id' => 'required|exists:businesses,id|different:lines.*.from_business_id',
'lines.*.amount' => 'required|numeric|min:0.01',
'lines.*.description' => 'nullable|string|max:255',
]);
$settlement = $this->settlementService->createSettlement(
$parentBusiness,
$validated['lines'],
$validated['description'],
auth()->id()
);
return redirect()
->route('seller.business.management.inter-business.show', [$business, $settlement])
->with('success', "Settlement {$settlement->settlement_number} created as draft.");
}
/**
* Show settlement details.
*/
public function show(Request $request, Business $business, InterBusinessSettlement $settlement): View
{
$settlement->load(['lines.fromBusiness', 'lines.toBusiness', 'createdByUser', 'postedByUser']);
return view('seller.management.inter-business.show', compact(
'business',
'settlement'
));
}
/**
* Post a settlement (create journal entries).
*/
public function post(Request $request, Business $business, InterBusinessSettlement $settlement): RedirectResponse
{
if (! $settlement->isDraft()) {
return back()->with('error', 'Settlement has already been posted.');
}
try {
$this->settlementService->postSettlement($settlement, auth()->id());
return redirect()
->route('seller.business.management.inter-business.show', [$business, $settlement])
->with('success', "Settlement {$settlement->settlement_number} posted successfully.");
} catch (\RuntimeException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Quick settle all outstanding balances.
*/
public function settleAll(Request $request, Business $business): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
$suggestedLines = $this->settlementService->suggestSettlements($parentBusiness);
if (empty($suggestedLines)) {
return back()->with('warning', 'No outstanding inter-business balances to settle.');
}
$settlement = $this->settlementService->createSettlement(
$parentBusiness,
$suggestedLines,
'Complete inter-business settlement',
auth()->id()
);
return redirect()
->route('seller.business.management.inter-business.show', [$business, $settlement])
->with('success', "Settlement {$settlement->settlement_number} created for all outstanding balances. Review and post when ready.");
}
}

View File

@@ -0,0 +1,69 @@
<?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
) {}
/**
* Ensure the business has Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the inventory valuation dashboard.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Determine scope - use snake_case keys from getDivisionFilterData()
$targetBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
$businessIds = $filterData['business_ids'];
// Get valuation data
$summary = $this->valuationService->getValuationSummary($businessIds);
$byType = $this->valuationService->getValuationByType($businessIds);
$byDivision = $includeChildren ? $this->valuationService->getValuationByDivision($businessIds) : collect();
$byCategory = $this->valuationService->getValuationByCategory($businessIds);
$byLocation = $this->valuationService->getValuationByLocation($businessIds);
$topItems = $this->valuationService->getTopItemsByValue($businessIds, 10);
$aging = $this->valuationService->getInventoryAging($businessIds);
$atRisk = $this->valuationService->getInventoryAtRisk($businessIds);
return view('seller.management.inventory-valuation.index', $this->withDivisionFilter([
'business' => $business,
'summary' => $summary,
'byType' => $byType,
'byDivision' => $byDivision,
'byCategory' => $byCategory,
'byLocation' => $byLocation,
'topItems' => $topItems,
'aging' => $aging,
'atRisk' => $atRisk,
'isParent' => $business->hasChildBusinesses(),
], $filterData));
}
}

View File

@@ -0,0 +1,119 @@
<?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);
$filterData = $this->getDivisionFilterData($business, $request);
$businessIds = $filterData['business_ids'];
// Collect operations data
$operations = $this->collectOperationsData($businessIds);
return view('seller.management.operations.index', $this->withDivisionFilter([
'business' => $business,
'operations' => $operations,
], $filterData));
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
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.quantity_on_hand <= products.low_stock_threshold AND products.quantity_on_hand > 0 THEN 1 END) as low_stock_products'),
DB::raw('COUNT(CASE WHEN products.quantity_on_hand = 0 THEN 1 END) as out_of_stock_products'),
])
->first();
// Customer stats (AR customers)
$customerStats = DB::table('ar_customers')
->whereIn('business_id', $businessIds)
->where('is_active', 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('COALESCE(SUM(CASE WHEN status IN (\'pending\', \'approved\') THEN total ELSE 0 END), 0) 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('COALESCE(SUM(CASE WHEN status = \'pending\' THEN total_amount ELSE 0 END), 0) 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,
];
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\User;
use App\Services\Management\ManagementPermissionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Management Suite Permissions Controller.
*
* Allows CFOs/Admins to manage fine-grained permissions for users
* within the Management Suite.
*/
class PermissionsController extends Controller
{
public function __construct(
protected ManagementPermissionService $permissionService
) {}
/**
* Display list of users and their permission levels.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$users = $this->permissionService->getUsersWithAccess($parentBusiness);
// Get permission summary for each user
$userSummaries = [];
foreach ($users as $user) {
$userSummaries[$user->id] = [
'user' => $user,
'role' => $user->businesses->first()?->pivot?->role ?? 'member',
'summary' => $this->permissionService->getPermissionSummary($user, $parentBusiness),
'permissions' => $this->permissionService->getUserPermissions($user, $parentBusiness),
];
}
$roleTemplates = $this->permissionService->getRoleTemplates();
return view('seller.management.permissions.index', compact(
'business',
'parentBusiness',
'users',
'userSummaries',
'roleTemplates'
));
}
/**
* Show edit form for a user's permissions.
*/
public function edit(Request $request, Business $business, User $user): View
{
$parentBusiness = $business->parent ?? $business;
// Verify user belongs to this business
$pivot = $user->businesses()
->where('businesses.id', $parentBusiness->id)
->first()?->pivot;
if (! $pivot) {
abort(404, 'User not found in this business.');
}
$permissionCategories = $this->permissionService->getPermissionDefinitions();
$currentPermissions = $this->permissionService->getUserPermissions($user, $parentBusiness);
$roleTemplates = $this->permissionService->getRoleTemplates();
return view('seller.management.permissions.edit', compact(
'business',
'parentBusiness',
'user',
'pivot',
'permissionCategories',
'currentPermissions',
'roleTemplates'
));
}
/**
* Update a user's permissions.
*/
public function update(Request $request, Business $business, User $user): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
// Verify user belongs to this business
$pivot = $user->businesses()
->where('businesses.id', $parentBusiness->id)
->first()?->pivot;
if (! $pivot) {
abort(404, 'User not found in this business.');
}
$validated = $request->validate([
'permissions' => 'nullable|array',
'permissions.*' => 'string',
]);
$permissions = $validated['permissions'] ?? [];
$this->permissionService->setUserPermissions($user, $parentBusiness, $permissions);
return redirect()
->route('seller.business.management.permissions.index', $business)
->with('success', "Permissions updated for {$user->name}.");
}
/**
* Apply a role template to a user.
*/
public function applyTemplate(Request $request, Business $business, User $user): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
// Verify user belongs to this business
$pivot = $user->businesses()
->where('businesses.id', $parentBusiness->id)
->first()?->pivot;
if (! $pivot) {
abort(404, 'User not found in this business.');
}
$validated = $request->validate([
'template' => 'required|string',
]);
$templates = $this->permissionService->getRoleTemplates();
if (! isset($templates[$validated['template']])) {
return back()->with('error', 'Invalid role template.');
}
$this->permissionService->applyRoleTemplate($user, $parentBusiness, $validated['template']);
$templateLabel = $templates[$validated['template']]['label'];
return redirect()
->route('seller.business.management.permissions.edit', [$business, $user])
->with('success', "{$templateLabel} template applied to {$user->name}.");
}
}

View File

@@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\ArCustomer;
use App\Models\Accounting\GlAccount;
use App\Models\Accounting\RecurringApTemplate;
use App\Models\Accounting\RecurringArTemplate;
use App\Models\Accounting\RecurringJournalEntryTemplate;
use App\Models\Accounting\RecurringSchedule;
use App\Models\Business;
use App\Models\Department;
use App\Services\Accounting\RecurringSchedulerService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RecurringController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected RecurringSchedulerService $schedulerService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List recurring schedules.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filters = [
'type' => $request->type,
'is_active' => $request->has('is_active') ? (bool) $request->is_active : null,
];
$schedules = $this->schedulerService->getSchedulesForBusiness($business, $filters);
return view('seller.management.recurring.index', [
'business' => $business,
'schedules' => $schedules,
'types' => RecurringSchedule::getTypes(),
]);
}
/**
* Show create form.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$type = $request->type ?? RecurringSchedule::TYPE_AR_INVOICE;
return view('seller.management.recurring.create', [
'business' => $business,
'type' => $type,
'types' => RecurringSchedule::getTypes(),
'frequencies' => RecurringSchedule::getFrequencies(),
'weekdays' => RecurringSchedule::getWeekdays(),
'customers' => ArCustomer::where('business_id', $business->id)->orderBy('name')->get(),
'vendors' => ApVendor::where('business_id', $business->id)->orderBy('name')->get(),
'glAccounts' => GlAccount::where('business_id', $business->id)->orderBy('account_number')->get(),
'departments' => Department::where('business_id', $business->id)->where('is_active', true)->orderBy('name')->get(),
]);
}
/**
* Store new recurring schedule.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:ar_invoice,ap_bill,journal_entry',
'frequency' => 'required|in:weekly,monthly,quarterly,yearly,custom',
'interval' => 'required|integer|min:1|max:365',
'day_of_month' => 'nullable|integer|min:1|max:31',
'weekday' => 'nullable|string',
'next_run_date' => 'required|date|after_or_equal:today',
'end_date' => 'nullable|date|after:next_run_date',
'auto_post' => 'boolean',
'create_as_draft' => 'boolean',
// AR template fields
'ar_customer_id' => 'required_if:type,ar_invoice|nullable|exists:ar_customers,id',
'ar_terms' => 'nullable|string|max:50',
'ar_memo' => 'nullable|string|max:255',
'ar_items' => 'required_if:type,ar_invoice|nullable|array|min:1',
'ar_items.*.description' => 'required_with:ar_items|string',
'ar_items.*.quantity' => 'required_with:ar_items|numeric|min:0.0001',
'ar_items.*.unit_price' => 'required_with:ar_items|numeric|min:0',
'ar_items.*.gl_revenue_account_id' => 'required_with:ar_items|exists:gl_accounts,id',
// AP template fields
'vendor_id' => 'required_if:type,ap_bill|nullable|exists:ap_vendors,id',
'ap_terms' => 'nullable|string|max:50',
'ap_memo' => 'nullable|string|max:255',
'ap_items' => 'required_if:type,ap_bill|nullable|array|min:1',
'ap_items.*.description' => 'required_with:ap_items|string',
'ap_items.*.amount' => 'required_with:ap_items|numeric|min:0',
'ap_items.*.gl_expense_account_id' => 'required_with:ap_items|exists:gl_accounts,id',
'ap_items.*.department_id' => 'nullable|exists:departments,id',
// JE template fields
'je_memo' => 'nullable|string|max:255',
'je_lines' => 'required_if:type,journal_entry|nullable|array|min:2',
'je_lines.*.gl_account_id' => 'required_with:je_lines|exists:gl_accounts,id',
'je_lines.*.department_id' => 'nullable|exists:departments,id',
'je_lines.*.debit' => 'nullable|numeric|min:0',
'je_lines.*.credit' => 'nullable|numeric|min:0',
'je_lines.*.description' => 'nullable|string',
]);
// Validate JE balance
if ($validated['type'] === 'journal_entry' && ! empty($validated['je_lines'])) {
$totalDebit = collect($validated['je_lines'])->sum(fn ($l) => (float) ($l['debit'] ?? 0));
$totalCredit = collect($validated['je_lines'])->sum(fn ($l) => (float) ($l['credit'] ?? 0));
if (abs($totalDebit - $totalCredit) > 0.01) {
return back()->withInput()->withErrors(['je_lines' => 'Journal entry must be balanced (debits = credits).']);
}
}
// Create schedule
$schedule = RecurringSchedule::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'type' => $validated['type'],
'frequency' => $validated['frequency'],
'interval' => $validated['interval'],
'day_of_month' => $validated['day_of_month'] ?? null,
'weekday' => $validated['weekday'] ?? null,
'next_run_date' => $validated['next_run_date'],
'end_date' => $validated['end_date'] ?? null,
'auto_post' => $validated['auto_post'] ?? false,
'create_as_draft' => $validated['create_as_draft'] ?? true,
'is_active' => true,
'created_by_user_id' => auth()->id(),
]);
// Create template based on type
match ($validated['type']) {
'ar_invoice' => $this->createArTemplate($schedule, $validated),
'ap_bill' => $this->createApTemplate($schedule, $validated),
'journal_entry' => $this->createJeTemplate($schedule, $validated),
};
return redirect()
->route('seller.business.management.recurring.show', [$business, $schedule])
->with('success', 'Recurring schedule created successfully.');
}
/**
* Show schedule details.
*/
public function show(Request $request, Business $business, RecurringSchedule $recurring): View
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->load([
'arTemplate.items.glAccount',
'arTemplate.customer',
'apTemplate.items.glAccount',
'apTemplate.items.department',
'apTemplate.vendor',
'journalEntryTemplate.lines.glAccount',
'journalEntryTemplate.lines.department',
'createdBy',
]);
$generatedTransactions = $this->schedulerService->getGeneratedTransactions($recurring);
return view('seller.management.recurring.show', [
'business' => $business,
'schedule' => $recurring,
'generatedTransactions' => $generatedTransactions,
]);
}
/**
* Show edit form.
*/
public function edit(Request $request, Business $business, RecurringSchedule $recurring): View
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->load([
'arTemplate.items',
'apTemplate.items',
'journalEntryTemplate.lines',
]);
return view('seller.management.recurring.edit', [
'business' => $business,
'schedule' => $recurring,
'types' => RecurringSchedule::getTypes(),
'frequencies' => RecurringSchedule::getFrequencies(),
'weekdays' => RecurringSchedule::getWeekdays(),
'customers' => ArCustomer::where('business_id', $business->id)->orderBy('name')->get(),
'vendors' => ApVendor::where('business_id', $business->id)->orderBy('name')->get(),
'glAccounts' => GlAccount::where('business_id', $business->id)->orderBy('account_number')->get(),
'departments' => Department::where('business_id', $business->id)->where('is_active', true)->orderBy('name')->get(),
]);
}
/**
* Update schedule.
*/
public function update(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'frequency' => 'required|in:weekly,monthly,quarterly,yearly,custom',
'interval' => 'required|integer|min:1|max:365',
'day_of_month' => 'nullable|integer|min:1|max:31',
'weekday' => 'nullable|string',
'next_run_date' => 'required|date',
'end_date' => 'nullable|date|after:next_run_date',
'auto_post' => 'boolean',
'create_as_draft' => 'boolean',
'is_active' => 'boolean',
]);
$recurring->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'frequency' => $validated['frequency'],
'interval' => $validated['interval'],
'day_of_month' => $validated['day_of_month'] ?? null,
'weekday' => $validated['weekday'] ?? null,
'next_run_date' => $validated['next_run_date'],
'end_date' => $validated['end_date'] ?? null,
'auto_post' => $validated['auto_post'] ?? false,
'create_as_draft' => $validated['create_as_draft'] ?? true,
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.business.management.recurring.show', [$business, $recurring])
->with('success', 'Recurring schedule updated.');
}
/**
* Toggle active status.
*/
public function toggle(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->update(['is_active' => ! $recurring->is_active]);
$status = $recurring->is_active ? 'activated' : 'deactivated';
return back()->with('success', "Schedule {$status}.");
}
/**
* Delete schedule.
*/
public function destroy(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->delete();
return redirect()
->route('seller.business.management.recurring.index', $business)
->with('success', 'Recurring schedule deleted.');
}
/**
* Show review queue for draft recurring transactions.
*/
public function review(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$drafts = $this->schedulerService->getDraftTransactionsForReview($business, $filterData['business_ids']);
return view('seller.management.recurring.review', $this->withDivisionFilter([
'business' => $business,
'arInvoices' => $drafts['ar_invoices'],
'apBills' => $drafts['ap_bills'],
'journalEntries' => $drafts['journal_entries'],
], $filterData));
}
/**
* Run schedule manually.
*/
public function runNow(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
try {
$result = $this->schedulerService->runSchedule($recurring, now());
if ($result) {
$type = class_basename($result);
return back()->with('success', "{$type} generated successfully.");
}
return back()->with('error', 'Schedule is not due for execution.');
} catch (\Exception $e) {
return back()->with('error', 'Failed to run schedule: '.$e->getMessage());
}
}
/**
* Create AR template with items.
*/
private function createArTemplate(RecurringSchedule $schedule, array $data): void
{
$template = RecurringArTemplate::create([
'recurring_schedule_id' => $schedule->id,
'ar_customer_id' => $data['ar_customer_id'],
'terms' => $data['ar_terms'] ?? null,
'default_memo' => $data['ar_memo'] ?? null,
'currency' => 'USD',
]);
foreach ($data['ar_items'] as $item) {
$template->items()->create([
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'gl_revenue_account_id' => $item['gl_revenue_account_id'],
]);
}
}
/**
* Create AP template with items.
*/
private function createApTemplate(RecurringSchedule $schedule, array $data): void
{
$template = RecurringApTemplate::create([
'recurring_schedule_id' => $schedule->id,
'vendor_id' => $data['vendor_id'],
'terms' => $data['ap_terms'] ?? null,
'default_memo' => $data['ap_memo'] ?? null,
'currency' => 'USD',
]);
foreach ($data['ap_items'] as $item) {
$template->items()->create([
'description' => $item['description'],
'amount' => $item['amount'],
'gl_expense_account_id' => $item['gl_expense_account_id'],
'department_id' => $item['department_id'] ?? null,
]);
}
}
/**
* Create JE template with lines.
*/
private function createJeTemplate(RecurringSchedule $schedule, array $data): void
{
$template = RecurringJournalEntryTemplate::create([
'recurring_schedule_id' => $schedule->id,
'memo' => $data['je_memo'] ?? $schedule->name,
]);
foreach ($data['je_lines'] as $line) {
$template->lines()->create([
'gl_account_id' => $line['gl_account_id'],
'department_id' => $line['department_id'] ?? null,
'debit' => $line['debit'] ?? 0,
'credit' => $line['credit'] ?? 0,
'description' => $line['description'] ?? null,
]);
}
}
}

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Business;
use App\Models\PurchaseOrder;
use App\Models\Purchasing\PurchaseRequisition;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Controller for Management Suite requisition approval workflow.
*
* Parent companies (Canopy) use this to:
* - View all requisitions from child businesses
* - Approve or reject requisitions
* - Convert approved requisitions to Purchase Orders
*/
class RequisitionsApprovalController extends Controller
{
use ManagementDivisionFilter;
/**
* Display list of requisitions from all divisions.
*
* GET /s/{business}/management/requisitions
*/
public function index(Request $request, Business $business): View
{
// Only parent companies can access this
if (! $business->isParentCompany()) {
abort(403, 'Only parent companies can manage requisition approvals.');
}
$filterData = $this->getDivisionFilterData($business, $request);
// Get requisitions from child businesses (not from parent itself)
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
$queryBusinessIds = $filterData['selected_division_id']
? [$filterData['selected_division_id']]
: $childIds;
$query = PurchaseRequisition::whereIn('business_id', $queryBusinessIds)
->with(['requestedBy', 'vendor', 'department', 'approvedBy', 'business'])
->withCount('items');
// Status filter
if ($status = $request->get('status')) {
$query->where('status', $status);
}
// Priority filter
if ($priority = $request->get('priority')) {
$query->where('priority', $priority);
}
// Search
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('requisition_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
});
}
$requisitions = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
// Status counts for all child businesses
$statusCounts = PurchaseRequisition::whereIn('business_id', $childIds)
->selectRaw('status, COUNT(*) as count')
->groupBy('status')
->pluck('count', 'status');
// Summary stats
$stats = [
'awaiting_approval' => PurchaseRequisition::whereIn('business_id', $childIds)
->whereIn('status', [PurchaseRequisition::STATUS_SUBMITTED, PurchaseRequisition::STATUS_UNDER_REVIEW])
->count(),
'approved_pending_po' => PurchaseRequisition::whereIn('business_id', $childIds)
->where('status', PurchaseRequisition::STATUS_APPROVED)
->whereNull('linked_po_id')
->count(),
'urgent_count' => PurchaseRequisition::whereIn('business_id', $childIds)
->whereIn('status', [PurchaseRequisition::STATUS_SUBMITTED, PurchaseRequisition::STATUS_UNDER_REVIEW])
->where('priority', PurchaseRequisition::PRIORITY_URGENT)
->count(),
];
return view('seller.management.requisitions.index', $this->withDivisionFilter([
'business' => $business,
'requisitions' => $requisitions,
'statusCounts' => $statusCounts,
'stats' => $stats,
'filters' => $request->only(['status', 'priority', 'search']),
], $filterData));
}
/**
* Show a single requisition for review/approval.
*
* GET /s/{business}/management/requisitions/{requisition}
*/
public function show(Request $request, Business $business, PurchaseRequisition $requisition): View
{
if (! $business->isParentCompany()) {
abort(403, 'Only parent companies can manage requisition approvals.');
}
// Verify the requisition is from a child business
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
if (! in_array($requisition->business_id, $childIds)) {
abort(403, 'This requisition does not belong to your divisions.');
}
$requisition->load([
'items.suggestedVendor',
'items.glAccount',
'requestedBy',
'approvedBy',
'vendor',
'department',
'purchaseOrder',
'business',
]);
// Get available vendors for PO creation (from parent business)
$vendors = ApVendor::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.management.requisitions.show', [
'business' => $business,
'requisition' => $requisition,
'vendors' => $vendors,
]);
}
/**
* Mark a requisition as under review.
*
* POST /s/{business}/management/requisitions/{requisition}/review
*/
public function markUnderReview(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->isSubmitted()) {
return back()->with('error', 'Only submitted requisitions can be marked under review.');
}
$requisition->markUnderReview();
return back()->with('success', 'Requisition marked as under review.');
}
/**
* Approve a requisition.
*
* POST /s/{business}/management/requisitions/{requisition}/approve
*/
public function approve(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->canBeApproved()) {
return back()->with('error', 'This requisition cannot be approved.');
}
$requisition->approve(auth()->user());
return back()->with('success', 'Requisition approved.');
}
/**
* Reject a requisition.
*
* POST /s/{business}/management/requisitions/{requisition}/reject
*/
public function reject(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->canBeApproved()) {
return back()->with('error', 'This requisition cannot be rejected.');
}
$validated = $request->validate([
'rejection_reason' => 'required|string|max:1000',
]);
$requisition->reject(auth()->user(), $validated['rejection_reason']);
return back()->with('success', 'Requisition rejected.');
}
/**
* Convert an approved requisition to a Purchase Order.
*
* POST /s/{business}/management/requisitions/{requisition}/convert-to-po
*/
public function convertToPo(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->canBeConvertedToPo()) {
return back()->with('error', 'This requisition cannot be converted to a PO. It must be approved and not already linked to a PO.');
}
$validated = $request->validate([
'vendor_id' => 'nullable|exists:ap_vendors,id',
'supplier_name' => 'required_without:vendor_id|nullable|string|max:255',
'expected_delivery_date' => 'nullable|date|after:today',
'notes' => 'nullable|string|max:2000',
]);
// Use vendor from requisition or from form
$vendor = null;
$supplierName = $validated['supplier_name'] ?? null;
if (! empty($validated['vendor_id'])) {
$vendor = ApVendor::find($validated['vendor_id']);
$supplierName = $vendor?->name;
} elseif ($requisition->vendor_id) {
$vendor = $requisition->vendor;
$supplierName = $vendor?->name;
}
// Create the PO on the PARENT business (Canopy)
$po = PurchaseOrder::create([
'business_id' => $business->id, // Parent creates the PO
'po_number' => $this->generatePoNumber($business),
'supplier_name' => $supplierName ?? 'Unknown Supplier',
'supplier_contact' => $vendor?->contact_name,
'supplier_phone' => $vendor?->phone,
'supplier_email' => $vendor?->email,
'product_type' => 'materials',
'quantity' => $requisition->items->sum('quantity'),
'unit' => 'ea',
'price_per_unit' => $requisition->estimated_total / max(1, $requisition->items->sum('quantity')),
'price_unit' => 'ea',
'status' => 'pending',
'order_date' => now(),
'expected_delivery_date' => $validated['expected_delivery_date'] ?? $requisition->needed_by_date,
'notes' => "Created from requisition {$requisition->requisition_number} (Division: {$requisition->business->name})\n\n".($validated['notes'] ?? ''),
'created_by_user_id' => auth()->id(),
'metadata' => [
'source_requisition_id' => $requisition->id,
'source_requisition_number' => $requisition->requisition_number,
'source_business_id' => $requisition->business_id,
'source_business_name' => $requisition->business->name,
'items' => $requisition->items->map(fn ($item) => [
'description' => $item->description,
'quantity' => $item->quantity,
'unit' => $item->unit,
'est_unit_cost' => $item->est_unit_cost,
])->toArray(),
],
]);
// Link the requisition to the PO
$requisition->markConvertedToPo($po);
return redirect()
->route('seller.business.management.requisitions.show', [$business, $requisition])
->with('success', "Purchase Order #{$po->po_number} created successfully.");
}
/**
* Authorize that the current user can perform actions on this requisition.
*/
protected function authorizeAction(Business $business, PurchaseRequisition $requisition): void
{
if (! $business->isParentCompany()) {
abort(403, 'Only parent companies can manage requisition approvals.');
}
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
if (! in_array($requisition->business_id, $childIds)) {
abort(403, 'This requisition does not belong to your divisions.');
}
}
/**
* Generate a PO number for the business.
*/
protected function generatePoNumber(Business $business): string
{
$prefix = 'PO';
$year = now()->format('y');
$lastPo = PurchaseOrder::where('business_id', $business->id)
->whereYear('created_at', now()->year)
->orderByDesc('id')
->first();
if ($lastPo && preg_match('/PO-\d{2}-(\d+)/', $lastPo->po_number ?? '', $matches)) {
$nextNum = (int) $matches[1] + 1;
} else {
$nextNum = 1;
}
return sprintf('%s-%s-%04d', $prefix, $year, $nextNum);
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UsageBillingController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->requireManagementSuite($business);
$divisions = $this->getChildDivisionsIfAny($business);
$selectedDivision = $this->getSelectedDivision($request, $business);
$includeChildren = $this->shouldIncludeChildren($request);
$businessIds = $this->getBusinessIdsForScope($business, $selectedDivision, $includeChildren);
// Collect usage data
$usage = $this->collectUsageData($business, $businessIds);
return view('seller.management.usage-billing.index', [
'business' => $business,
'divisions' => $divisions,
'selectedDivision' => $selectedDivision,
'includeChildren' => $includeChildren,
'usage' => $usage,
]);
}
protected function collectUsageData(Business $parentBusiness, array $businessIds): array
{
$startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
// Get suite limits from config
$defaults = config('suites.defaults.sales_suite', []);
// Count active brands
$brandCount = DB::table('brands')
->whereIn('business_id', $businessIds)
->where('is_active', true)
->count();
// Count active products (SKUs)
$skuCount = DB::table('products')
->join('brands', 'products.brand_id', '=', 'brands.id')
->whereIn('brands.business_id', $businessIds)
->where('products.is_active', true)
->count();
// Count messages sent this month
$messageCount = DB::table('messages')
->whereIn('business_id', $businessIds)
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->count();
// Count menu sends this month
$menuSendCount = DB::table('menu_sends')
->whereIn('business_id', $businessIds)
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->count();
// Count CRM contacts
$contactCount = DB::table('contacts')
->whereIn('business_id', $businessIds)
->count();
// Calculate limits based on number of brands
$brandLimit = $parentBusiness->brand_limit ?? $defaults['brand_limit'] ?? 1;
$skuLimitPerBrand = $defaults['sku_limit_per_brand'] ?? 15;
$messageLimitPerBrand = $defaults['message_limit_per_brand'] ?? 500;
$menuLimitPerBrand = $defaults['menu_limit_per_brand'] ?? 100;
$contactLimitPerBrand = $defaults['contact_limit_per_brand'] ?? 1000;
$totalSkuLimit = $brandCount * $skuLimitPerBrand;
$totalMessageLimit = $brandCount * $messageLimitPerBrand;
$totalMenuLimit = $brandCount * $menuLimitPerBrand;
$totalContactLimit = $brandCount * $contactLimitPerBrand;
// Is enterprise plan?
$isEnterprise = $parentBusiness->is_enterprise_plan ?? false;
// Get suites enabled
$enabledSuites = $this->getEnabledSuites($parentBusiness);
// Usage by division
$usageByDivision = [];
if (count($businessIds) > 1) {
$usageByDivision = DB::table('businesses')
->whereIn('businesses.id', $businessIds)
->leftJoin('brands', 'brands.business_id', '=', 'businesses.id')
->leftJoin('products', 'products.brand_id', '=', 'brands.id')
->select(
'businesses.id',
'businesses.name',
DB::raw('COUNT(DISTINCT brands.id) as brand_count'),
DB::raw('COUNT(DISTINCT products.id) as sku_count')
)
->groupBy('businesses.id', 'businesses.name')
->get();
}
return [
'brands' => [
'current' => $brandCount,
'limit' => $isEnterprise ? null : $brandLimit,
'percentage' => $brandLimit > 0 ? min(100, ($brandCount / $brandLimit) * 100) : 0,
],
'skus' => [
'current' => $skuCount,
'limit' => $isEnterprise ? null : $totalSkuLimit,
'percentage' => $totalSkuLimit > 0 ? min(100, ($skuCount / $totalSkuLimit) * 100) : 0,
],
'messages' => [
'current' => $messageCount,
'limit' => $isEnterprise ? null : $totalMessageLimit,
'percentage' => $totalMessageLimit > 0 ? min(100, ($messageCount / $totalMessageLimit) * 100) : 0,
],
'menu_sends' => [
'current' => $menuSendCount,
'limit' => $isEnterprise ? null : $totalMenuLimit,
'percentage' => $totalMenuLimit > 0 ? min(100, ($menuSendCount / $totalMenuLimit) * 100) : 0,
],
'contacts' => [
'current' => $contactCount,
'limit' => $isEnterprise ? null : $totalContactLimit,
'percentage' => $totalContactLimit > 0 ? min(100, ($contactCount / $totalContactLimit) * 100) : 0,
],
'is_enterprise' => $isEnterprise,
'enabled_suites' => $enabledSuites,
'usage_by_division' => $usageByDivision,
'billing_period' => [
'start' => $startOfMonth->format('M j, Y'),
'end' => $endOfMonth->format('M j, Y'),
],
];
}
protected function getEnabledSuites(Business $business): array
{
$suites = [];
if ($business->hasSalesSuite()) {
$suites[] = ['name' => 'Sales Suite', 'key' => 'sales'];
}
if ($business->hasProcessingSuite()) {
$suites[] = ['name' => 'Processing Suite', 'key' => 'processing'];
}
if ($business->hasManufacturingSuite()) {
$suites[] = ['name' => 'Manufacturing Suite', 'key' => 'manufacturing'];
}
if ($business->hasDeliverySuite()) {
$suites[] = ['name' => 'Delivery Suite', 'key' => 'delivery'];
}
if ($business->hasManagementSuite()) {
$suites[] = ['name' => 'Management Suite', 'key' => 'management'];
}
if ($business->hasDispensarySuite()) {
$suites[] = ['name' => 'Dispensary Suite', 'key' => 'dispensary'];
}
return $suites;
}
protected function getBusinessIdsForScope(Business $business, ?Business $selectedDivision, bool $includeChildren): array
{
if ($selectedDivision) {
if ($includeChildren) {
return $selectedDivision->divisions()->pluck('id')
->prepend($selectedDivision->id)
->toArray();
}
return [$selectedDivision->id];
}
if ($includeChildren && $business->hasChildBusinesses()) {
return $business->divisions()->pluck('id')
->prepend($business->id)
->toArray();
}
return [$business->id];
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Seller\Crm\InboxController as CrmInboxController;
use App\Http\Controllers\Seller\Crm\ThreadController as CrmThreadController;
use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Http\Request;
@@ -21,8 +21,8 @@ class MessagingController extends Controller
public function index(Request $request, \App\Models\Business $business)
{
// If CRM is enabled, use the enhanced CRM inbox
if ($business->has_crm) {
return app(CrmInboxController::class)->index($request, $business);
if ($business->hasCrmAccess()) {
return app(CrmThreadController::class)->index($request, $business);
}
// Basic conversations view
@@ -66,8 +66,8 @@ class MessagingController extends Controller
public function show(Request $request, \App\Models\Business $business, Conversation $conversation)
{
// If CRM is enabled, use the enhanced CRM inbox view
if ($business->has_crm) {
return app(CrmInboxController::class)->show($request, $business, $conversation);
if ($business->hasCrmAccess()) {
return app(CrmThreadController::class)->show($request, $business, $conversation);
}
// Ensure business owns this conversation

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Processing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use Illuminate\Http\Request;
class WashReportController extends Controller
@@ -10,10 +11,8 @@ class WashReportController extends Controller
/**
* Display list of wash reports.
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement wash reports listing
// This will show all wash batches for the business
@@ -25,10 +24,8 @@ class WashReportController extends Controller
/**
* Display active washes dashboard.
*/
public function activeDashboard(Request $request)
public function activeDashboard(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement active washes dashboard
// This will show currently active wash batches
@@ -40,10 +37,8 @@ class WashReportController extends Controller
/**
* Display daily performance report.
*/
public function dailyPerformance(Request $request)
public function dailyPerformance(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement daily performance reporting
// This will show wash performance metrics for today
@@ -55,10 +50,8 @@ class WashReportController extends Controller
/**
* Display historical wash search.
*/
public function search(Request $request)
public function search(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement wash report search
// This will allow searching historical wash data

View File

@@ -79,29 +79,36 @@ class ProductController extends Controller
$sortDir = $request->get('sort_dir', 'asc');
$query->orderBy($sortBy, $sortDir);
// Get all products and format as arrays for the view
$products = $query->get()
// Paginate for performance (925 products = 400KB JSON payload)
$perPage = $request->get('per_page', 50);
$paginator = $query->paginate($perPage);
// Get paginated products and format as arrays for the view
$products = $paginator->getCollection()
->filter(fn ($product) => ! empty($product->hashid)) // Skip products without hashid (pre-migration)
->map(function ($product) use ($business) {
// TODO: Replace mock metrics with real data from analytics/order tracking
// Image URL uses product image or falls back to brand logo
$imageUrl = $product->getImageUrl('thumb');
// Map varieties for nested display
$varieties = $product->varieties->map(function ($variety) use ($business) {
return [
'id' => $variety->id,
'hashid' => $variety->hashid,
'name' => $variety->name,
'sku' => $variety->sku ?? 'N/A',
'price' => $variety->wholesale_price ?? 0,
'status' => $variety->is_active ? 'active' : 'inactive',
'image_url' => $variety->getImageUrl('thumb'),
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
'units_sold' => $variety->orderItems()->sum('quantity'),
'stock' => $variety->available_quantity,
'is_unlimited' => $variety->isUnlimited(),
];
})->values()->toArray();
// Map varieties for nested display (avoid N+1 queries)
$varieties = $product->varieties
->filter(fn ($variety) => ! empty($variety->hashid)) // Skip varieties without hashid
->map(function ($variety) use ($business) {
return [
'id' => $variety->id,
'hashid' => $variety->hashid,
'name' => $variety->name,
'sku' => $variety->sku ?? 'N/A',
'price' => $variety->wholesale_price ?? 0,
'status' => $variety->is_active ? 'active' : 'inactive',
'image_url' => $variety->getImageUrl('thumb'),
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
'units_sold' => 0, // TODO: Eager load with withSum() for performance
'stock' => $variety->available_quantity,
'is_unlimited' => $variety->isUnlimited(),
];
})->values()->toArray();
return [
'id' => $product->id,
@@ -127,7 +134,17 @@ class ProductController extends Controller
];
});
return view('seller.products.index', compact('business', 'products', 'missingBomCount'));
// Pass pagination info to the view
$pagination = [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
];
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
}
/**
@@ -991,6 +1008,7 @@ class ProductController extends Controller
$products = Product::whereIn('brand_id', $brandIds)
->with(['brand', 'images'])
->get()
->filter(fn ($product) => ! empty($product->hashid)) // Skip products without hashid (pre-migration)
->map(function ($product) use ($business) {
// TODO: Replace mock metrics with real data from analytics/order tracking
return [

View File

@@ -38,6 +38,13 @@ class PurchaseOrderController extends Controller
*/
public function create(Business $business)
{
// Child businesses (divisions) must use the requisition workflow
if ($business->parent_id !== null) {
return redirect()
->route('seller.business.purchasing.requisitions.create', $business)
->with('info', 'Please submit a Purchase Requisition. Direct PO creation is managed by the parent company.');
}
return view('seller.purchase-orders.create', compact('business'));
}
@@ -46,6 +53,11 @@ class PurchaseOrderController extends Controller
*/
public function store(Business $business, Request $request)
{
// Child businesses (divisions) cannot create POs directly
if ($business->parent_id !== null) {
abort(403, 'Divisions cannot create Purchase Orders directly. Please use the Purchase Requisition workflow.');
}
$validated = $request->validate([
'supplier_name' => 'required|string|max:255',
'supplier_contact' => 'nullable|string|max:255',

View File

@@ -0,0 +1,435 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Purchasing;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Models\Department;
use App\Models\Purchasing\PurchaseRequisition;
use App\Models\Purchasing\PurchaseRequisitionItem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RequisitionsController extends Controller
{
/**
* Display list of requisitions for the business.
*/
public function index(Request $request, Business $business): View
{
$user = auth()->user();
$query = PurchaseRequisition::forBusiness($business->id)
->with(['requestedBy', 'vendor', 'department', 'approvedBy'])
->withCount('items');
// Non-owners see only their own requisitions
if ($business->owner_user_id !== $user->id && ! $this->userCanViewAllRequisitions($user, $business)) {
$query->where('requested_by_user_id', $user->id);
}
// Filters
if ($status = $request->get('status')) {
$query->where('status', $status);
}
if ($priority = $request->get('priority')) {
$query->where('priority', $priority);
}
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('requisition_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
});
}
$requisitions = $query->orderByDesc('created_at')->paginate(20);
// Get counts for tabs
$statusCounts = PurchaseRequisition::forBusiness($business->id)
->selectRaw('status, COUNT(*) as count')
->groupBy('status')
->pluck('count', 'status');
return view('seller.purchasing.requisitions.index', [
'business' => $business,
'requisitions' => $requisitions,
'statusCounts' => $statusCounts,
'filters' => $request->only(['status', 'priority', 'search']),
]);
}
/**
* Show form to create a new requisition.
*/
public function create(Request $request, Business $business): View
{
$this->authorizeCreate($business);
$vendors = ApVendor::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.purchasing.requisitions.create', [
'business' => $business,
'vendors' => $vendors,
'departments' => $departments,
'glAccounts' => $glAccounts,
'priorities' => PurchaseRequisition::getPriorityOptions(),
]);
}
/**
* Store a new requisition.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->authorizeCreate($business);
$validated = $request->validate([
'department_id' => 'nullable|exists:departments,id',
'vendor_id' => 'nullable|exists:ap_vendors,id',
'priority' => 'required|in:low,normal,high,urgent',
'needed_by_date' => 'nullable|date|after:today',
'notes' => 'nullable|string|max:2000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:500',
'items.*.quantity' => 'required|numeric|min:0.0001',
'items.*.unit' => 'nullable|string|max:50',
'items.*.est_unit_cost' => 'nullable|numeric|min:0',
'items.*.suggested_vendor_id' => 'nullable|exists:ap_vendors,id',
'items.*.gl_account_id' => 'nullable|exists:gl_accounts,id',
'items.*.notes' => 'nullable|string|max:500',
'submit_action' => 'required|in:save_draft,submit',
]);
$requisition = PurchaseRequisition::create([
'business_id' => $business->id,
'department_id' => $validated['department_id'] ?? null,
'requested_by_user_id' => auth()->id(),
'vendor_id' => $validated['vendor_id'] ?? null,
'priority' => $validated['priority'],
'needed_by_date' => $validated['needed_by_date'] ?? null,
'notes' => $validated['notes'] ?? null,
'status' => PurchaseRequisition::STATUS_DRAFT,
]);
foreach ($validated['items'] as $itemData) {
$requisition->items()->create([
'description' => $itemData['description'],
'quantity' => $itemData['quantity'],
'unit' => $itemData['unit'] ?? null,
'est_unit_cost' => $itemData['est_unit_cost'] ?? null,
'suggested_vendor_id' => $itemData['suggested_vendor_id'] ?? null,
'gl_account_id' => $itemData['gl_account_id'] ?? null,
'notes' => $itemData['notes'] ?? null,
]);
}
// Submit if requested
if ($validated['submit_action'] === 'submit') {
$requisition->submit();
return redirect()
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
->with('success', 'Requisition submitted for approval.');
}
return redirect()
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
->with('success', 'Requisition draft saved.');
}
/**
* Show a single requisition.
*/
public function show(Request $request, Business $business, PurchaseRequisition $requisition): View
{
$this->authorizeView($business, $requisition);
$requisition->load(['items.suggestedVendor', 'items.glAccount', 'requestedBy', 'approvedBy', 'vendor', 'department', 'purchaseOrder']);
return view('seller.purchasing.requisitions.show', [
'business' => $business,
'requisition' => $requisition,
]);
}
/**
* Show form to edit a requisition.
*/
public function edit(Request $request, Business $business, PurchaseRequisition $requisition): View
{
$this->authorizeEdit($business, $requisition);
$requisition->load('items');
$vendors = ApVendor::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.purchasing.requisitions.edit', [
'business' => $business,
'requisition' => $requisition,
'vendors' => $vendors,
'departments' => $departments,
'glAccounts' => $glAccounts,
'priorities' => PurchaseRequisition::getPriorityOptions(),
]);
}
/**
* Update a requisition.
*/
public function update(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeEdit($business, $requisition);
$validated = $request->validate([
'department_id' => 'nullable|exists:departments,id',
'vendor_id' => 'nullable|exists:ap_vendors,id',
'priority' => 'required|in:low,normal,high,urgent',
'needed_by_date' => 'nullable|date|after:today',
'notes' => 'nullable|string|max:2000',
'items' => 'required|array|min:1',
'items.*.id' => 'nullable|exists:purchase_requisition_items,id',
'items.*.description' => 'required|string|max:500',
'items.*.quantity' => 'required|numeric|min:0.0001',
'items.*.unit' => 'nullable|string|max:50',
'items.*.est_unit_cost' => 'nullable|numeric|min:0',
'items.*.suggested_vendor_id' => 'nullable|exists:ap_vendors,id',
'items.*.gl_account_id' => 'nullable|exists:gl_accounts,id',
'items.*.notes' => 'nullable|string|max:500',
'submit_action' => 'required|in:save_draft,submit',
]);
$requisition->update([
'department_id' => $validated['department_id'] ?? null,
'vendor_id' => $validated['vendor_id'] ?? null,
'priority' => $validated['priority'],
'needed_by_date' => $validated['needed_by_date'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
// Sync items - delete removed, update existing, create new
$existingItemIds = collect($validated['items'])
->pluck('id')
->filter()
->toArray();
// Delete items not in the update
$requisition->items()
->whereNotIn('id', $existingItemIds)
->delete();
foreach ($validated['items'] as $itemData) {
if (! empty($itemData['id'])) {
// Update existing item
PurchaseRequisitionItem::where('id', $itemData['id'])
->update([
'description' => $itemData['description'],
'quantity' => $itemData['quantity'],
'unit' => $itemData['unit'] ?? null,
'est_unit_cost' => $itemData['est_unit_cost'] ?? null,
'suggested_vendor_id' => $itemData['suggested_vendor_id'] ?? null,
'gl_account_id' => $itemData['gl_account_id'] ?? null,
'notes' => $itemData['notes'] ?? null,
]);
} else {
// Create new item
$requisition->items()->create([
'description' => $itemData['description'],
'quantity' => $itemData['quantity'],
'unit' => $itemData['unit'] ?? null,
'est_unit_cost' => $itemData['est_unit_cost'] ?? null,
'suggested_vendor_id' => $itemData['suggested_vendor_id'] ?? null,
'gl_account_id' => $itemData['gl_account_id'] ?? null,
'notes' => $itemData['notes'] ?? null,
]);
}
}
// Submit if requested and still in draft
if ($validated['submit_action'] === 'submit' && $requisition->isDraft()) {
$requisition->submit();
return redirect()
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
->with('success', 'Requisition submitted for approval.');
}
return redirect()
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
->with('success', 'Requisition updated.');
}
/**
* Submit a draft requisition for approval.
*/
public function submit(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeEdit($business, $requisition);
if (! $requisition->isDraft()) {
return back()->with('error', 'Only draft requisitions can be submitted.');
}
if ($requisition->items->isEmpty()) {
return back()->with('error', 'Requisition must have at least one item.');
}
$requisition->submit();
return redirect()
->route('seller.business.purchasing.requisitions.show', [$business, $requisition])
->with('success', 'Requisition submitted for approval.');
}
/**
* Delete a draft requisition.
*/
public function destroy(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeDelete($business, $requisition);
if (! $requisition->isDraft()) {
return back()->with('error', 'Only draft requisitions can be deleted.');
}
$requisition->delete();
return redirect()
->route('seller.business.purchasing.requisitions.index', $business)
->with('success', 'Requisition deleted.');
}
// =========================================================================
// AUTHORIZATION HELPERS
// =========================================================================
protected function authorizeCreate(Business $business): void
{
$user = auth()->user();
// Check if user has permission to submit requisitions
if (! $this->userCanSubmitRequisitions($user, $business)) {
abort(403, 'You do not have permission to create requisitions.');
}
}
protected function authorizeView(Business $business, PurchaseRequisition $requisition): void
{
if ($requisition->business_id !== $business->id) {
abort(404);
}
$user = auth()->user();
// Owner can view all
if ($business->owner_user_id === $user->id) {
return;
}
// User can view their own
if ($requisition->requested_by_user_id === $user->id) {
return;
}
// Users with view all permission
if ($this->userCanViewAllRequisitions($user, $business)) {
return;
}
abort(403, 'You do not have permission to view this requisition.');
}
protected function authorizeEdit(Business $business, PurchaseRequisition $requisition): void
{
$this->authorizeView($business, $requisition);
if (! $requisition->canBeEdited()) {
abort(403, 'This requisition cannot be edited.');
}
$user = auth()->user();
// Only the requester can edit their own requisition (unless owner)
if ($requisition->requested_by_user_id !== $user->id && $business->owner_user_id !== $user->id) {
abort(403, 'You can only edit your own requisitions.');
}
}
protected function authorizeDelete(Business $business, PurchaseRequisition $requisition): void
{
$this->authorizeEdit($business, $requisition);
}
protected function userCanSubmitRequisitions($user, Business $business): bool
{
// Owner always can
if ($business->owner_user_id === $user->id) {
return true;
}
// Check pivot permission
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
if (! $pivot) {
return false;
}
$permissions = $pivot->permissions ?? [];
return in_array('can_submit_requisition', $permissions) || in_array('*', $permissions);
}
protected function userCanViewAllRequisitions($user, Business $business): bool
{
// Owner always can
if ($business->owner_user_id === $user->id) {
return true;
}
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
if (! $pivot) {
return false;
}
$permissions = $pivot->permissions ?? [];
return in_array('can_view_all_requisitions', $permissions)
|| in_array('can_approve_requisition', $permissions)
|| in_array('*', $permissions);
}
}

View File

@@ -0,0 +1,349 @@
<?php
namespace App\Http\Controllers\Seller\Settings;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class BrandSettingsController extends Controller
{
/**
* Display the brands list.
*/
public function index(Business $business)
{
$this->authorizeOwnerAccess($business);
$brands = $business->brands()
->withCount('products')
->with(['users' => fn ($q) => $q->select('users.id', 'users.first_name', 'users.last_name', 'users.email')])
->orderBy('sort_order')
->orderBy('name')
->get();
// Get internal team members (users associated with this business, not brand managers)
$teamMembers = $business->users()
->wherePivot('contact_type', '!=', 'brand_manager')
->orWherePivotNull('contact_type')
->orderBy('first_name')
->get();
return view('seller.settings.brands', compact('business', 'brands', 'teamMembers'));
}
/**
* Store a new brand.
*/
public function store(Business $business, Request $request)
{
$this->authorizeOwnerAccess($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'sku_prefix' => 'nullable|string|max:10|alpha_num',
'description' => 'nullable|string|max:1000',
'is_active' => 'nullable|boolean',
'is_sales_enabled' => 'nullable|boolean',
'logo' => 'nullable|image|max:2048',
]);
DB::beginTransaction();
try {
$brand = Brand::create([
'business_id' => $business->id,
'name' => $validated['name'],
'slug' => Str::slug($validated['name']),
'sku_prefix' => $validated['sku_prefix'] ?? null,
'description' => $validated['description'] ?? null,
'is_active' => $request->has('is_active'),
'is_sales_enabled' => $request->has('is_sales_enabled'),
'sort_order' => $business->brands()->max('sort_order') + 1,
]);
// Handle logo upload
if ($request->hasFile('logo')) {
$path = $request->file('logo')->store('brands/'.$brand->hashid.'/logos', 'public');
$brand->update(['logo_path' => $path]);
}
DB::commit();
return redirect()
->route('seller.business.settings.brands.index', $business->slug)
->with('success', "Brand \"{$brand->name}\" created successfully.");
} catch (\Exception $e) {
DB::rollBack();
report($e);
return back()
->withInput()
->withErrors(['error' => 'Failed to create brand. Please try again.']);
}
}
/**
* Show edit form for a brand.
*/
public function edit(Business $business, Brand $brand)
{
$this->authorizeOwnerAccess($business);
$this->authorizeBrandAccess($business, $brand);
// Get internal team members for user assignment
$teamMembers = $business->users()
->where(function ($q) {
$q->wherePivot('contact_type', '!=', 'brand_manager')
->orWherePivotNull('contact_type');
})
->orderBy('first_name')
->get();
// Get users currently assigned to this brand
$brandUsers = $brand->users()->get();
return view('seller.settings.brands-edit', compact('business', 'brand', 'teamMembers', 'brandUsers'));
}
/**
* Update a brand.
*/
public function update(Business $business, Brand $brand, Request $request)
{
$this->authorizeOwnerAccess($business);
$this->authorizeBrandAccess($business, $brand);
$validated = $request->validate([
'name' => 'required|string|max:255',
'sku_prefix' => 'nullable|string|max:10|alpha_num',
'description' => 'nullable|string|max:1000',
'tagline' => 'nullable|string|max:255',
'is_active' => 'nullable|boolean',
'is_sales_enabled' => 'nullable|boolean',
'is_public' => 'nullable|boolean',
'is_featured' => 'nullable|boolean',
'logo' => 'nullable|image|max:2048',
'remove_logo' => 'nullable|boolean',
]);
DB::beginTransaction();
try {
// Handle logo removal
if ($request->has('remove_logo') && $brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo
if ($brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('brands/'.$brand->hashid.'/logos', 'public');
}
// Convert checkbox values
$validated['is_active'] = $request->has('is_active');
$validated['is_sales_enabled'] = $request->has('is_sales_enabled');
$validated['is_public'] = $request->has('is_public');
$validated['is_featured'] = $request->has('is_featured');
// Remove file inputs from validated data
unset($validated['logo'], $validated['remove_logo']);
$brand->update($validated);
DB::commit();
return redirect()
->route('seller.business.settings.brands.index', $business->slug)
->with('success', "Brand \"{$brand->name}\" updated successfully.");
} catch (\Exception $e) {
DB::rollBack();
report($e);
return back()
->withInput()
->withErrors(['error' => 'Failed to update brand. Please try again.']);
}
}
/**
* Toggle brand active status.
*/
public function toggleActive(Business $business, Brand $brand, Request $request)
{
$this->authorizeOwnerAccess($business);
$this->authorizeBrandAccess($business, $brand);
$brand->update(['is_active' => ! $brand->is_active]);
$status = $brand->is_active ? 'activated' : 'deactivated';
return back()->with('success', "Brand \"{$brand->name}\" has been {$status}.");
}
/**
* Delete a brand (soft delete).
*/
public function destroy(Business $business, Brand $brand, Request $request)
{
$this->authorizeOwnerAccess($business);
$this->authorizeBrandAccess($business, $brand);
// Check if brand has products
if ($brand->products()->count() > 0) {
return back()->withErrors([
'error' => "Cannot delete brand \"{$brand->name}\" because it has associated products. Please move or delete the products first.",
]);
}
$brandName = $brand->name;
$brand->delete();
return redirect()
->route('seller.business.settings.brands.index', $business->slug)
->with('success', "Brand \"{$brandName}\" has been deleted.");
}
/**
* Update user access for a brand.
*/
public function updateUsers(Business $business, Brand $brand, Request $request)
{
$this->authorizeOwnerAccess($business);
$this->authorizeBrandAccess($business, $brand);
$validated = $request->validate([
'users' => 'nullable|array',
'users.*.id' => 'required|exists:users,id',
'users.*.permissions' => 'nullable|array',
'users.*.permissions.*' => 'string',
]);
DB::beginTransaction();
try {
// Get current brand users
$currentUserIds = $brand->users()->pluck('users.id')->toArray();
// Build new user list
$newUserIds = collect($validated['users'] ?? [])->pluck('id')->toArray();
// Verify all users belong to this business
$validUserIds = $business->users()
->whereIn('users.id', $newUserIds)
->pluck('users.id')
->toArray();
// Detach removed users
$usersToRemove = array_diff($currentUserIds, $validUserIds);
if (! empty($usersToRemove)) {
$brand->users()->detach($usersToRemove);
}
// Add or update users
foreach ($validated['users'] ?? [] as $userData) {
if (! in_array($userData['id'], $validUserIds)) {
continue;
}
$permissions = $userData['permissions'] ?? [];
if ($brand->users()->where('users.id', $userData['id'])->exists()) {
$brand->users()->updateExistingPivot($userData['id'], [
'permissions' => json_encode($permissions),
]);
} else {
$brand->users()->attach($userData['id'], [
'role' => 'member',
'is_primary' => false,
'permissions' => json_encode($permissions),
]);
}
}
DB::commit();
return back()->with('success', 'Brand user access updated successfully.');
} catch (\Exception $e) {
DB::rollBack();
report($e);
return back()->withErrors(['error' => 'Failed to update user access. Please try again.']);
}
}
/**
* Reorder brands.
*/
public function reorder(Business $business, Request $request)
{
$this->authorizeOwnerAccess($business);
$validated = $request->validate([
'order' => 'required|array',
'order.*' => 'integer|exists:brands,id',
]);
DB::beginTransaction();
try {
foreach ($validated['order'] as $position => $brandId) {
Brand::where('id', $brandId)
->where('business_id', $business->id)
->update(['sort_order' => $position]);
}
DB::commit();
return response()->json(['success' => true]);
} catch (\Exception $e) {
DB::rollBack();
report($e);
return response()->json(['success' => false, 'error' => 'Failed to reorder brands.'], 500);
}
}
/**
* Authorize that current user can manage brands for this business.
* Allows: business owner, super admin, or users with owner/admin role in the business.
*/
private function authorizeOwnerAccess(Business $business): void
{
$user = auth()->user();
$isOwner = $business->owner_user_id === $user->id;
$isSuperAdmin = $user->user_type === 'admin';
// Check if user has admin role in this business via pivot
$businessRole = $business->users()
->where('users.id', $user->id)
->first()
?->pivot
?->role;
$isBusinessAdmin = in_array($businessRole, ['owner', 'admin', 'manager']);
if (! $isOwner && ! $isSuperAdmin && ! $isBusinessAdmin) {
abort(403, 'Only business owners and administrators can manage brands.');
}
}
/**
* Authorize that the brand belongs to the business.
*/
private function authorizeBrandAccess(Business $business, Brand $brand): void
{
if ($brand->business_id !== $business->id) {
abort(404, 'Brand not found.');
}
}
}

View File

@@ -6,7 +6,9 @@ use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\DeliveryWindow;
use App\Models\Department;
use App\Models\DepartmentSuitePermission;
use App\Models\Driver;
use App\Models\Suite;
use App\Models\User;
use App\Models\Vehicle;
use Illuminate\Http\Request;
@@ -141,11 +143,6 @@ class SettingsController extends Controller
{
$query = $business->users();
// Exclude the business owner from the list
if ($business->owner_user_id) {
$query->where('users.id', '!=', $business->owner_user_id);
}
// Search
if ($request->filled('search')) {
$search = $request->search;
@@ -155,10 +152,15 @@ class SettingsController extends Controller
});
}
// Filter by account type (role)
if ($request->filled('account_type')) {
$query->whereHas('roles', function ($q) use ($request) {
$q->where('name', $request->account_type);
// Filter by role
if ($request->filled('role')) {
$query->wherePivot('role', $request->role);
}
// Filter by department
if ($request->filled('department_id')) {
$query->whereHas('departments', function ($q) use ($request) {
$q->where('departments.id', $request->department_id);
});
}
@@ -170,9 +172,20 @@ class SettingsController extends Controller
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
}
$users = $query->with('roles')->paginate(15);
$users = $query->with(['roles', 'departments'])->paginate(15);
return view('seller.settings.users', compact('business', 'users'));
// Get all departments for this business
$departments = Department::where('business_id', $business->id)
->orderBy('sort_order')
->get();
// Get business owner info
$owner = $business->owner;
// Get the suites assigned to this business for permission reference
$businessSuites = $business->suites()->active()->get();
return view('seller.settings.users', compact('business', 'users', 'departments', 'owner', 'businessSuites'));
}
/**
@@ -186,7 +199,9 @@ class SettingsController extends Controller
'email' => 'required|email|unique:users,email',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
'role' => 'required|string|in:owner,admin,manager,member',
'department_ids' => 'nullable|array',
'department_ids.*' => 'exists:departments,id',
'is_point_of_contact' => 'nullable|boolean',
]);
@@ -196,21 +211,32 @@ class SettingsController extends Controller
// Create user and associate with business
$user = \App\Models\User::create([
'name' => $fullName,
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'],
'phone' => $validated['phone'] ?? null,
'position' => $validated['position'] ?? null,
'user_type' => $business->business_type, // Match business type
'password' => bcrypt(str()->random(32)), // Temporary password
]);
// Assign role
$user->assignRole($validated['role']);
// Associate with business with additional pivot data
// Associate with business with role
$business->users()->attach($user->id, [
'role' => $validated['role'],
'is_primary' => false,
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
'permissions' => [], // Empty initially, assigned via department
]);
// Assign to departments if specified
if (! empty($validated['department_ids'])) {
foreach ($validated['department_ids'] as $departmentId) {
$user->departments()->attach($departmentId, [
'role' => 'operator', // Default department role
]);
}
}
// TODO: Send invitation email with password reset link
return redirect()
@@ -264,10 +290,33 @@ class SettingsController extends Controller
->orderBy('sort_order')
->get();
// Define permission categories (existing permissions system)
$permissionCategories = $this->getPermissionCategories();
// Get the suites assigned to this business
$businessSuites = $business->suites()->active()->get();
return view('seller.settings.users-edit', compact('business', 'user', 'isOwner', 'departments', 'permissionCategories'));
// Build suite permissions structure based on business's assigned suites
$suitePermissions = $this->getSuitePermissions($businessSuites);
// Get user's current permissions from pivot
$pivotPermissions = $business->users()
->where('users.id', $user->id)
->first()
->pivot
->permissions ?? [];
// Ensure permissions is an array (JSON column may return string in PostgreSQL)
$userPermissions = is_array($pivotPermissions)
? $pivotPermissions
: (is_string($pivotPermissions) ? json_decode($pivotPermissions, true) ?? [] : []);
return view('seller.settings.users-edit', compact(
'business',
'user',
'isOwner',
'departments',
'businessSuites',
'suitePermissions',
'userPermissions'
));
}
/**
@@ -324,63 +373,122 @@ class SettingsController extends Controller
}
/**
* Get permission categories for the permissions system.
* Get suite-based permission structure for the assigned suites.
*
* Groups permissions by functional area for better UX.
*/
private function getPermissionCategories(): array
private function getSuitePermissions($businessSuites): array
{
return [
'ecommerce' => [
'name' => 'Ecommerce',
'icon' => 'lucide--shopping-cart',
'permissions' => [
'view_orders' => ['name' => 'View Orders', 'description' => 'View all orders and order details'],
'manage_orders' => ['name' => 'Manage Orders', 'description' => 'Accept, reject, and update orders'],
'view_invoices' => ['name' => 'View Invoices', 'description' => 'View invoices and payment information'],
'manage_invoices' => ['name' => 'Manage Invoices', 'description' => 'Create and edit invoices'],
],
],
'products' => [
'name' => 'Products & Inventory',
'icon' => 'lucide--package',
'permissions' => [
'view_products' => ['name' => 'View Products', 'description' => 'View product catalog'],
'manage_products' => ['name' => 'Manage Products', 'description' => 'Create, edit, and delete products'],
'view_inventory' => ['name' => 'View Inventory', 'description' => 'View inventory levels and stock'],
'manage_inventory' => ['name' => 'Manage Inventory', 'description' => 'Update inventory and stock levels'],
],
],
'data_visibility' => [
'name' => 'Data & Analytics Visibility',
'icon' => 'lucide--eye',
'permissions' => [
'view_sales_data' => ['name' => 'View Sales Data', 'description' => 'See revenue, pricing, profit margins, and sales metrics'],
'view_performance_data' => ['name' => 'View Performance Data', 'description' => 'See yields, efficiency, quality metrics, and production stats'],
'view_cost_data' => ['name' => 'View Cost Data', 'description' => 'See material costs, labor costs, and expense breakdowns'],
'view_customer_data' => ['name' => 'View Customer Data', 'description' => 'See customer names, contact info, and purchase history'],
],
],
'manufacturing' => [
'name' => 'Manufacturing & Processing',
'icon' => 'lucide--factory',
'permissions' => [
'view_work_orders' => ['name' => 'View Work Orders', 'description' => 'View work orders (limited to own department)'],
'manage_work_orders' => ['name' => 'Manage Work Orders', 'description' => 'Create, edit, and complete work orders'],
'view_wash_reports' => ['name' => 'View Wash Reports', 'description' => 'View solventless wash reports and data'],
'manage_wash_reports' => ['name' => 'Manage Wash Reports', 'description' => 'Create and edit wash reports'],
'view_all_departments' => ['name' => 'View All Departments', 'description' => 'See data across all departments (not just own)'],
],
],
'business' => [
'name' => 'Business Management',
'icon' => 'lucide--building-2',
'permissions' => [
'view_settings' => ['name' => 'View Settings', 'description' => 'View business settings and configuration'],
'manage_settings' => ['name' => 'Manage Settings', 'description' => 'Update business settings and configuration'],
'view_users' => ['name' => 'View Users', 'description' => 'View user list and permissions'],
'manage_users' => ['name' => 'Manage Users', 'description' => 'Invite and manage user permissions'],
],
],
$suitePermissions = [];
foreach ($businessSuites as $suite) {
$permissions = DepartmentSuitePermission::getAvailablePermissions($suite->key);
if (empty($permissions)) {
continue;
}
// Group permissions by functional area
$grouped = $this->groupPermissionsByArea($permissions, $suite->key);
$suitePermissions[$suite->key] = [
'name' => $suite->name,
'description' => $suite->description,
'icon' => $suite->icon,
'color' => $suite->color,
'groups' => $grouped,
];
}
return $suitePermissions;
}
/**
* Group permissions by functional area for better organization.
*/
private function groupPermissionsByArea(array $permissions, string $suiteKey): array
{
// Define permission groupings based on prefix patterns
$areaPatterns = [
'dashboard' => ['view_dashboard', 'view_org_dashboard', 'view_analytics', 'view_all_analytics', 'export_analytics'],
'products' => ['view_products', 'manage_products', 'view_inventory', 'adjust_inventory', 'view_costs', 'view_margin'],
'orders' => ['view_orders', 'create_orders', 'manage_orders', 'view_invoices', 'create_invoices'],
'menus' => ['view_menus', 'manage_menus', 'view_promotions', 'manage_promotions'],
'campaigns' => ['view_campaigns', 'manage_campaigns', 'send_campaigns', 'manage_templates'],
'crm' => ['view_pipeline', 'edit_pipeline', 'manage_accounts', 'view_buyer_intelligence'],
'automations' => ['view_automations', 'manage_automations', 'use_copilot'],
'batches' => ['view_batches', 'manage_batches', 'create_batches'],
'processing' => ['view_wash_reports', 'create_wash_reports', 'edit_wash_reports', 'manage_extractions', 'view_yields', 'manage_biomass_intake', 'manage_material_transfers'],
'manufacturing' => ['view_bom', 'manage_bom', 'create_bom', 'view_production_queue', 'manage_production', 'manage_packaging', 'manage_labeling', 'create_skus', 'manage_lot_tracking'],
'work_orders' => ['view_work_orders', 'create_work_orders', 'manage_work_orders'],
'delivery' => ['view_pick_pack', 'manage_pick_pack', 'view_manifests', 'create_manifests', 'view_delivery_windows', 'manage_delivery_windows', 'view_drivers', 'manage_drivers', 'view_vehicles', 'manage_vehicles', 'view_routes', 'manage_routing', 'view_deliveries', 'complete_deliveries', 'manage_proof_of_delivery'],
'compliance' => ['view_compliance', 'manage_licenses', 'manage_coas', 'view_compliance_reports'],
'finance' => ['view_ap', 'manage_ap', 'pay_bills', 'view_ar', 'manage_ar', 'view_budgets', 'manage_budgets', 'approve_budget_exceptions', 'view_inter_company_ledger', 'manage_inter_company', 'view_financial_reports', 'export_financial_data', 'view_forecasting', 'manage_forecasting', 'view_kpis', 'view_usage_billing', 'manage_billing', 'view_cross_business'],
'messaging' => ['view_conversations', 'send_messages', 'manage_contacts'],
'procurement' => ['view_vendors', 'manage_vendors', 'view_requisitions', 'create_requisitions', 'approve_requisitions', 'view_purchase_orders', 'create_purchase_orders', 'receive_goods'],
'tools' => ['manage_settings', 'manage_users', 'manage_departments', 'view_audit_log', 'manage_integrations'],
'marketplace' => ['view_marketplace', 'browse_products', 'manage_cart', 'manage_favorites', 'view_buyer_portal', 'view_account'],
'brand_view' => ['view_sales', 'view_buyers'],
];
$areaLabels = [
'dashboard' => ['name' => 'Dashboard & Analytics', 'icon' => 'lucide--layout-dashboard'],
'products' => ['name' => 'Products & Inventory', 'icon' => 'lucide--package'],
'orders' => ['name' => 'Orders & Invoicing', 'icon' => 'lucide--shopping-cart'],
'menus' => ['name' => 'Menus & Promotions', 'icon' => 'lucide--menu'],
'campaigns' => ['name' => 'Campaigns & Marketing', 'icon' => 'lucide--megaphone'],
'crm' => ['name' => 'CRM & Accounts', 'icon' => 'lucide--users'],
'automations' => ['name' => 'Automations & AI', 'icon' => 'lucide--bot'],
'batches' => ['name' => 'Batches', 'icon' => 'lucide--layers'],
'processing' => ['name' => 'Processing Operations', 'icon' => 'lucide--flask-conical'],
'manufacturing' => ['name' => 'Manufacturing', 'icon' => 'lucide--factory'],
'work_orders' => ['name' => 'Work Orders', 'icon' => 'lucide--clipboard-list'],
'delivery' => ['name' => 'Delivery & Fulfillment', 'icon' => 'lucide--truck'],
'compliance' => ['name' => 'Compliance', 'icon' => 'lucide--shield-check'],
'finance' => ['name' => 'Finance & Budgets', 'icon' => 'lucide--banknote'],
'messaging' => ['name' => 'Messaging', 'icon' => 'lucide--message-square'],
'procurement' => ['name' => 'Procurement', 'icon' => 'lucide--clipboard-list'],
'tools' => ['name' => 'Tools & Settings', 'icon' => 'lucide--settings'],
'marketplace' => ['name' => 'Marketplace', 'icon' => 'lucide--store'],
'brand_view' => ['name' => 'Brand Data', 'icon' => 'lucide--eye'],
];
$grouped = [];
$assigned = [];
foreach ($areaPatterns as $area => $areaPermissions) {
$matchedPermissions = [];
foreach ($permissions as $key => $description) {
if (in_array($key, $areaPermissions) && ! isset($assigned[$key])) {
$matchedPermissions[$key] = $description;
$assigned[$key] = true;
}
}
if (! empty($matchedPermissions)) {
$grouped[$area] = [
'name' => $areaLabels[$area]['name'] ?? ucwords(str_replace('_', ' ', $area)),
'icon' => $areaLabels[$area]['icon'] ?? 'lucide--folder',
'permissions' => $matchedPermissions,
];
}
}
// Add any remaining permissions to "Other"
$remaining = [];
foreach ($permissions as $key => $description) {
if (! isset($assigned[$key])) {
$remaining[$key] = $description;
}
}
if (! empty($remaining)) {
$grouped['other'] = [
'name' => 'Other',
'icon' => 'lucide--more-horizontal',
'permissions' => $remaining,
];
}
return $grouped;
}
/**
@@ -426,19 +534,6 @@ class SettingsController extends Controller
->with('success', 'Order settings updated successfully!');
}
/**
* Display the brands management page.
*/
public function brands(Business $business)
{
$brands = $business->brands()
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.settings.brands', compact('business', 'brands'));
}
/**
* Display the payment settings page.
*/
@@ -601,7 +696,7 @@ class SettingsController extends Controller
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
// CRM notification checkbox values
if ($business->has_crm) {
if ($business->hasCrmAccess()) {
$validated['crm_task_reminder_enabled'] = $request->has('crm_task_reminder_enabled');
$validated['crm_event_reminder_enabled'] = $request->has('crm_event_reminder_enabled');
$validated['crm_daily_digest_enabled'] = $request->has('crm_daily_digest_enabled');

View File

@@ -9,10 +9,12 @@ use Symfony\Component\HttpFoundation\Response;
/**
* Middleware to ensure the user has Brand Portal access.
*
* Brand Portal access requires:
* 1. User must be in "Brand Partner" department only (no other departments)
* 2. User must have at least one linked brand for this business
* 3. Business must have the brand_portal suite enabled
* Brand Portal access is granted to:
* 1. Brand Portal users (in "Brand Partner" department with linked brands)
* 2. Brand Manager users (contact_type = 'brand_manager' with linked brands)
*
* Both access modes provide read-only, brand-scoped access to:
* - Orders, Accounts, Inventory, Promotions, Conversations
*
* This middleware is applied to /s/{business}/brand-portal/* routes.
*/
@@ -38,15 +40,21 @@ class EnsureBrandPortalAccess
abort(404, 'Business not found.');
}
// Check if user is in Brand Portal mode for this business
if (! $user->isBrandPortalUser($business)) {
// If user is NOT in Brand Portal mode but is trying to access Brand Portal routes,
// redirect them to the main dashboard
// Check if user has Brand Portal OR Brand Manager access
$hasBrandPortalAccess = $user->isBrandPortalUser($business);
$hasBrandManagerAccess = $user->isBrandManagerUser($business);
if (! $hasBrandPortalAccess && ! $hasBrandManagerAccess) {
// User doesn't have either type of brand access
return redirect()
->route('seller.business.dashboard', $business->slug)
->with('error', 'You do not have Brand Portal access. Contact your administrator.');
}
// Set attributes for use in controllers (which type of access)
$request->attributes->set('is_brand_portal_user', $hasBrandPortalAccess);
$request->attributes->set('is_brand_manager_user', $hasBrandManagerAccess);
return $next($request);
}
}

View File

@@ -52,10 +52,8 @@ class EnsureBusinessHasCrm
// This relies on route model binding: Route::prefix('{business}')
$business = $request->route('business');
// Check if business has Sales Suite or CRM feature
// CRM is included in the Sales Suite
$hasAccess = $business
&& ($business->hasSalesSuite() || $business->hasSuiteFeature('crm'));
// Check if business has CRM access (via Sales Suite or standalone CRM feature)
$hasAccess = $business && $business->hasCrmAccess();
if (! $hasAccess) {
return response()->view('seller.crm.feature-disabled', [

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Business;
use App\Services\CanopyVisibilityService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware to enforce Canopy parent/child visibility rules.
*
* This middleware checks if the user can view child business financial data
* when navigating through the Canopy parent context.
*
* BEFORE 12/1/2025:
* - Only business owners can see their own division's data through Canopy
* - Canopy management users cannot see any child data
*
* AFTER 12/1/2025:
* - Normal parent visibility: Canopy can see all child financial data
*
* Usage:
* - Apply to all Management routes under /s/{parent}/management/...
* - The middleware detects if a child business is being viewed via:
* 1. Route parameter (division_id, child_business)
* 2. Request input (business_id, division_id filters)
* 3. Session context
*
* @see App\Services\CanopyVisibilityService
* @see config/canopy.php
*/
class EnsureCanopyChildVisibility
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
// Get the business from the route
$business = $request->route('business');
if (! $business instanceof Business) {
return $next($request);
}
// Only apply visibility rules when viewing through Canopy parent
if (! CanopyVisibilityService::isCanopyParent($business)) {
// Child business context - normal access, no special visibility rules
return $next($request);
}
// We're viewing through Canopy (parent) - check for child data access
$childBusiness = $this->detectChildBusinessContext($request, $business);
if ($childBusiness) {
// User is trying to access a specific child's data through Canopy
if (! CanopyVisibilityService::canopyCanSeeChildData($user, $childBusiness)) {
return $this->denyAccess($request);
}
}
// Store visible children in request for controllers to use
$visibleChildren = CanopyVisibilityService::getVisibleChildBusinesses($user);
$request->attributes->set('canopy_visible_children', $visibleChildren);
$request->attributes->set('canopy_visibility_restricted', CanopyVisibilityService::isVisibilityRestricted());
return $next($request);
}
/**
* Detect if the request is targeting a specific child business.
*/
protected function detectChildBusinessContext(Request $request, Business $parent): ?Business
{
// Check route parameters first
$divisionId = $request->route('division_id')
?? $request->route('division')
?? $request->route('child_business');
if ($divisionId) {
return $this->findChildBusiness($divisionId, $parent);
}
// Check request input (query params or form data)
$businessId = $request->input('business_id')
?? $request->input('division_id')
?? $request->input('child_business_id');
if ($businessId) {
return $this->findChildBusiness($businessId, $parent);
}
// Check for division filter in query string
$divisionFilter = $request->input('division');
if ($divisionFilter && $divisionFilter !== 'all') {
return $this->findChildBusiness($divisionFilter, $parent);
}
// No specific child business targeted
return null;
}
/**
* Find a child business by ID or slug.
*/
protected function findChildBusiness(mixed $identifier, Business $parent): ?Business
{
if (is_numeric($identifier)) {
return Business::where('id', $identifier)
->where('parent_id', $parent->id)
->first();
}
// Try by slug
return Business::where('slug', $identifier)
->where('parent_id', $parent->id)
->first();
}
/**
* Deny access with appropriate response.
*/
protected function denyAccess(Request $request): Response
{
$message = CanopyVisibilityService::getRestrictedMessage();
if ($request->expectsJson()) {
return response()->json([
'error' => 'Visibility Restricted',
'message' => $message,
], 403);
}
// For web requests, redirect back with error or show 403
if ($request->hasSession()) {
return redirect()
->back()
->with('error', $message);
}
abort(403, $message);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Business;
use App\Services\Accounting\PeriodLockService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureFinancePermission
{
public function __construct(
protected PeriodLockService $periodLockService
) {}
/**
* Handle an incoming request.
*
* Usage: ->middleware('finance.permission:can_approve_ap')
* Multiple permissions: ->middleware('finance.permission:can_approve_ap,can_view_ap')
*/
public function handle(Request $request, Closure $next, string ...$permissions): Response
{
// Get business from route
$business = $request->route('business');
if (! $business instanceof Business) {
abort(404, 'Business not found.');
}
$user = $request->user();
if (! $user) {
abort(401, 'Authentication required.');
}
// Check bypass mode
if (config('finance_roles.bypass_permissions', false)) {
return $next($request);
}
// Business owners have all permissions
if ($business->owner_user_id === $user->id) {
return $next($request);
}
// Check if user has any of the required permissions
foreach ($permissions as $permission) {
if ($this->periodLockService->userHasPermission($business, $user, $permission)) {
return $next($request);
}
}
abort(403, 'You do not have permission to perform this action.');
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Suite Permission Middleware
*
* Two-level permission check:
* 1. Business must have the suite assigned (enables features for the business)
* 2. User's department must have the specific permission (controls who can access)
*
* Usage in routes:
* ->middleware('suite:sales,view_pipeline') // Single permission
* ->middleware('suite:sales,view_pipeline|edit_pipeline') // Any of these permissions
*
* Permission Flow:
* Business has Suite Enables all features in suite
* Owner assigns Department Permissions Controls which users can access what
* User in Department Gets department's permissions
*
* Special Cases:
* - Business owners/admins bypass department checks (they have full access)
* - Users without a department are denied (must be assigned to a department)
*
* @see App\Models\DepartmentSuitePermission::SUITE_PERMISSIONS for available permissions
* @see docs/architecture/SUITES_AND_PRICING_MODEL.md
*/
class EnsureSuitePermission
{
/**
* Handle an incoming request.
*
* @param string $suite The suite key (e.g., 'sales', 'processing')
* @param string $permissions Pipe-separated permissions (e.g., 'view_pipeline|edit_pipeline')
*/
public function handle(Request $request, Closure $next, string $suite, string $permissions): Response
{
$user = $request->user();
$business = $request->route('business');
if (! $user || ! $business) {
abort(403, 'Authentication required');
}
// Level 1: Check if business has the suite
if (! $business->hasSuite($suite)) {
return $this->denyBusinessAccess($business, $suite);
}
// Level 2: Check user's department permissions
// Business owners and admins bypass department checks
if ($this->isBusinessOwnerOrAdmin($user, $business)) {
return $next($request);
}
// Parse permissions (pipe-separated means "any of these")
$requiredPermissions = explode('|', $permissions);
// Check if user has ANY of the required permissions via their department
foreach ($requiredPermissions as $permission) {
if ($user->hasSuitePermission($suite, trim($permission), $business)) {
return $next($request);
}
}
return $this->denyUserAccess($user, $business, $suite, $requiredPermissions);
}
/**
* Check if user is a business owner or admin (bypasses department checks).
*/
protected function isBusinessOwnerOrAdmin($user, $business): bool
{
// Check if user is the business owner
if ($business->owner_id === $user->id) {
return true;
}
// Check if user has admin role for this business
$pivot = $user->businesses()->where('business_id', $business->id)->first()?->pivot;
return $pivot && in_array($pivot->role ?? '', ['owner', 'admin']);
}
/**
* Deny access when business doesn't have the suite.
*/
protected function denyBusinessAccess($business, string $suite): Response
{
$suiteNames = [
'sales' => 'Sales Suite',
'processing' => 'Processing Suite',
'manufacturing' => 'Manufacturing Suite',
'delivery' => 'Delivery Suite',
'management' => 'Management Suite',
'dispensary' => 'Dispensary Suite',
];
$suiteName = $suiteNames[$suite] ?? ucfirst($suite).' Suite';
return response()->view('errors.suite-required', [
'business' => $business,
'suite' => $suite,
'suiteName' => $suiteName,
'message' => "This feature requires the {$suiteName}.",
], 403);
}
/**
* Deny access when user's department doesn't have permission.
*/
protected function denyUserAccess($user, $business, string $suite, array $permissions): Response
{
$department = $user->departments()
->where('business_id', $business->id)
->first();
if (! $department) {
$message = 'You must be assigned to a department to access this feature. Please contact your administrator.';
} else {
$message = "Your department ({$department->name}) does not have permission to access this feature. Please contact your administrator.";
}
return response()->view('errors.permission-denied', [
'business' => $business,
'user' => $user,
'department' => $department,
'suite' => $suite,
'permissions' => $permissions,
'message' => $message,
], 403);
}
}

View File

@@ -67,9 +67,9 @@ class ProcessCrmCommandJob implements ShouldQueue
return;
}
// Check if business has CRM enabled
if (! $business->has_crm) {
Log::debug('CRM command job: Business does not have CRM enabled', [
// Check if business has CRM access (via Sales Suite or standalone feature)
if (! $business->hasCrmAccess()) {
Log::debug('CRM command job: Business does not have CRM access', [
'business_id' => $business->id,
]);

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Models\Accounting;
use App\Models\Business;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AccountingPeriod extends Model
{
public const STATUS_OPEN = 'open';
public const STATUS_SOFT_CLOSED = 'soft_closed';
public const STATUS_HARD_CLOSED = 'hard_closed';
protected $fillable = [
'business_id',
'period_start',
'period_end',
'status',
'closed_by_user_id',
'closed_at',
'notes',
];
protected $casts = [
'period_start' => 'date',
'period_end' => 'date',
'closed_at' => 'datetime',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function closedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'closed_by_user_id');
}
// Scopes
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeOpen($query)
{
return $query->where('status', self::STATUS_OPEN);
}
public function scopeClosed($query)
{
return $query->whereIn('status', [self::STATUS_SOFT_CLOSED, self::STATUS_HARD_CLOSED]);
}
public function scopeHardClosed($query)
{
return $query->where('status', self::STATUS_HARD_CLOSED);
}
public function scopeContainingDate($query, $date)
{
$date = Carbon::parse($date)->toDateString();
return $query->where('period_start', '<=', $date)
->where('period_end', '>=', $date);
}
// Helpers
public function isOpen(): bool
{
return $this->status === self::STATUS_OPEN;
}
public function isSoftClosed(): bool
{
return $this->status === self::STATUS_SOFT_CLOSED;
}
public function isHardClosed(): bool
{
return $this->status === self::STATUS_HARD_CLOSED;
}
public function isClosed(): bool
{
return $this->isSoftClosed() || $this->isHardClosed();
}
public function containsDate($date): bool
{
$date = Carbon::parse($date);
return $date->between($this->period_start, $this->period_end);
}
public function close(string $status, User $user, ?string $notes = null): self
{
$this->update([
'status' => $status,
'closed_by_user_id' => $user->id,
'closed_at' => now(),
'notes' => $notes,
]);
return $this;
}
public function reopen(?string $notes = null): self
{
$this->update([
'status' => self::STATUS_OPEN,
'closed_by_user_id' => null,
'closed_at' => null,
'notes' => $notes,
]);
return $this;
}
public function getPeriodLabelAttribute(): string
{
if ($this->period_start->isSameMonth($this->period_end)) {
return $this->period_start->format('F Y');
}
return $this->period_start->format('M Y').' - '.$this->period_end->format('M Y');
}
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_OPEN => 'Open',
self::STATUS_SOFT_CLOSED => 'Soft Closed',
self::STATUS_HARD_CLOSED => 'Hard Closed',
default => ucfirst($this->status),
};
}
public function getStatusBadgeClassAttribute(): string
{
return match ($this->status) {
self::STATUS_OPEN => 'badge-success',
self::STATUS_SOFT_CLOSED => 'badge-warning',
self::STATUS_HARD_CLOSED => 'badge-error',
default => 'badge-ghost',
};
}
/**
* Generate monthly periods for a fiscal year.
*/
public static function generateMonthlyPeriods(int $businessId, int $year): array
{
$periods = [];
for ($month = 1; $month <= 12; $month++) {
$start = Carbon::create($year, $month, 1);
$end = $start->copy()->endOfMonth();
$periods[] = self::firstOrCreate(
[
'business_id' => $businessId,
'period_start' => $start->toDateString(),
'period_end' => $end->toDateString(),
],
[
'status' => self::STATUS_OPEN,
]
);
}
return $periods;
}
/**
* Get the period for a given date, creating it if it doesn't exist.
*/
public static function forDate(int $businessId, $date): ?self
{
$date = Carbon::parse($date);
return self::where('business_id', $businessId)
->containingDate($date)
->first();
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Models\Accounting;
use App\Models\Business;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class ApBill extends Model implements AuditableContract
{
use Auditable, SoftDeletes;
protected $table = 'ap_bills';
public const STATUS_DRAFT = 'draft';
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_PARTIAL = 'partial';
public const STATUS_PAID = 'paid';
public const STATUS_VOID = 'void';
public const STATUSES = [
self::STATUS_DRAFT => 'Draft',
self::STATUS_PENDING => 'Pending Approval',
self::STATUS_APPROVED => 'Approved',
self::STATUS_PARTIAL => 'Partially Paid',
self::STATUS_PAID => 'Paid',
self::STATUS_VOID => 'Void',
];
protected $fillable = [
'business_id',
'vendor_id',
'purchase_order_id',
'bill_number',
'vendor_invoice_number',
'bill_date',
'due_date',
'payment_terms',
'subtotal',
'tax_amount',
'total',
'amount_paid',
'balance_due',
'currency',
'status',
'department_id',
'document_path',
'notes',
'approved_at',
'approved_by_user_id',
'created_by_user_id',
];
protected $casts = [
'bill_date' => 'date',
'due_date' => 'date',
'approved_at' => 'datetime',
'subtotal' => 'decimal:2',
'tax_amount' => 'decimal:2',
'total' => 'decimal:2',
'amount_paid' => 'decimal:2',
'balance_due' => 'decimal:2',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function vendor(): BelongsTo
{
return $this->belongsTo(ApVendor::class, 'vendor_id');
}
public function glAccount(): BelongsTo
{
return $this->belongsTo(GlAccount::class, 'gl_account_id');
}
public function items(): HasMany
{
return $this->hasMany(ApBillItem::class, 'ap_bill_id');
}
public function paymentApplications(): HasMany
{
return $this->hasMany(ApPaymentApplication::class, 'ap_bill_id');
}
public function payments(): BelongsToMany
{
return $this->belongsToMany(ApPayment::class, 'ap_payment_applications', 'ap_bill_id', 'ap_payment_id')
->withPivot('amount_applied', 'discount_taken')
->withTimestamps();
}
// Scopes
public function scopeForBusiness(Builder $query, int $businessId): Builder
{
return $query->where('business_id', $businessId);
}
public function scopeForBusinesses(Builder $query, array $businessIds): Builder
{
return $query->whereIn('business_id', $businessIds);
}
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
public function scopeDraft(Builder $query): Builder
{
return $query->where('status', self::STATUS_DRAFT);
}
public function scopePending(Builder $query): Builder
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeApproved(Builder $query): Builder
{
return $query->where('status', self::STATUS_APPROVED);
}
public function scopeUnpaid(Builder $query): Builder
{
return $query->whereNotIn('status', [self::STATUS_PAID, self::STATUS_VOID])
->where('balance_due', '>', 0);
}
public function scopeOverdue(Builder $query): Builder
{
return $query->where('due_date', '<', now())
->whereNotIn('status', [self::STATUS_PAID, self::STATUS_VOID])
->where('balance_due', '>', 0);
}
// Accessors
public function isOverdue(): bool
{
return $this->due_date && $this->due_date->isPast() && $this->balance_due > 0;
}
public function getDaysOverdueAttribute(): int
{
if (! $this->isOverdue()) {
return 0;
}
return (int) $this->due_date->diffInDays(now());
}
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
public function isPaid(): bool
{
return $this->status === self::STATUS_PAID;
}
public function isVoid(): bool
{
return $this->status === self::STATUS_VOID;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models\Accounting;
use App\Models\Department;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class ApBillItem extends Model implements AuditableContract
{
use Auditable, HasFactory;
protected $table = 'ap_bill_items';
protected $fillable = [
'ap_bill_id',
'purchase_order_item_id',
'description',
'quantity',
'unit_price',
'line_total',
'gl_account_id',
'department_id',
];
protected $casts = [
'quantity' => 'decimal:2',
'unit_price' => 'decimal:2',
'line_total' => 'decimal:2',
];
protected $auditExclude = [
'created_at',
'updated_at',
];
public function bill(): BelongsTo
{
return $this->belongsTo(ApBill::class, 'ap_bill_id');
}
public function purchaseOrderItem(): BelongsTo
{
return $this->belongsTo(PurchaseOrderItem::class, 'purchase_order_item_id');
}
public function glAccount(): BelongsTo
{
return $this->belongsTo(GlAccount::class, 'gl_account_id');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Models\Accounting;
use App\Models\Business;
use App\Models\User;
use App\Traits\BelongsToBusinessDirectly;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class ApPayment extends Model implements AuditableContract
{
use Auditable, BelongsToBusinessDirectly, HasFactory, SoftDeletes;
protected $table = 'ap_payments';
public const METHOD_CHECK = 'check';
public const METHOD_ACH = 'ach';
public const METHOD_WIRE = 'wire';
public const METHOD_CARD = 'card';
public const METHOD_CASH = 'cash';
public const METHODS = [
self::METHOD_CHECK,
self::METHOD_ACH,
self::METHOD_WIRE,
self::METHOD_CARD,
self::METHOD_CASH,
];
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_VOIDED = 'voided';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_COMPLETED,
self::STATUS_VOIDED,
];
protected $fillable = [
'business_id',
'vendor_id',
'payment_number',
'payment_date',
'payment_method',
'reference_number',
'amount',
'currency',
'bank_account_id',
'memo',
'status',
'voided_at',
'voided_by_user_id',
'created_by_user_id',
];
protected $casts = [
'payment_date' => 'date',
'voided_at' => 'datetime',
'amount' => 'decimal:2',
];
protected $auditExclude = [
'created_at',
'updated_at',
'deleted_at',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function vendor(): BelongsTo
{
return $this->belongsTo(ApVendor::class, 'vendor_id');
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function voidedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'voided_by_user_id');
}
public function applications(): HasMany
{
return $this->hasMany(ApPaymentApplication::class, 'ap_payment_id');
}
public function bills(): BelongsToMany
{
return $this->belongsToMany(ApBill::class, 'ap_payment_applications', 'ap_payment_id', 'ap_bill_id')
->withPivot('amount_applied', 'discount_taken')
->withTimestamps();
}
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeCompleted($query)
{
return $query->where('status', self::STATUS_COMPLETED);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function isVoided(): bool
{
return $this->status === self::STATUS_VOIDED;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models\Accounting;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class ApPaymentApplication extends Model implements AuditableContract
{
use Auditable, HasFactory;
protected $table = 'ap_payment_applications';
protected $fillable = [
'ap_payment_id',
'ap_bill_id',
'amount_applied',
'discount_taken',
];
protected $casts = [
'amount_applied' => 'decimal:2',
'discount_taken' => 'decimal:2',
];
protected $auditExclude = [
'created_at',
'updated_at',
];
public function payment(): BelongsTo
{
return $this->belongsTo(ApPayment::class, 'ap_payment_id');
}
public function bill(): BelongsTo
{
return $this->belongsTo(ApBill::class, 'ap_bill_id');
}
public function getTotalAppliedAttribute(): float
{
return $this->amount_applied + $this->discount_taken;
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models\Accounting;
use App\Models\Business;
use App\Traits\BelongsToBusinessDirectly;
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 ApVendor extends Model implements AuditableContract
{
use Auditable, BelongsToBusinessDirectly, HasFactory, SoftDeletes;
protected $table = 'ap_vendors';
protected $fillable = [
'business_id',
'code',
'name',
'legal_name',
'tax_id',
'default_payment_terms',
'default_gl_account_id',
'contact_name',
'contact_email',
'contact_phone',
'address_line1',
'address_line2',
'city',
'state',
'postal_code',
'country',
'is_1099',
'is_active',
'notes',
];
protected $casts = [
'default_payment_terms' => 'integer',
'is_1099' => 'boolean',
'is_active' => 'boolean',
];
protected $auditExclude = [
'created_at',
'updated_at',
'deleted_at',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function defaultGlAccount(): BelongsTo
{
return $this->belongsTo(GlAccount::class, 'default_gl_account_id');
}
public function bills(): HasMany
{
return $this->hasMany(ApBill::class, 'vendor_id');
}
public function payments(): HasMany
{
return $this->hasMany(ApPayment::class, 'vendor_id');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Models\Accounting;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ArCustomer extends Model
{
use SoftDeletes;
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',
'address_line_1',
'address_line_2',
'city',
'state',
'postal_code',
'country',
'payment_terms',
'payment_terms_days',
'credit_limit',
'credit_granted',
'credit_limit_approved_by',
'credit_approved_at',
'on_credit_hold',
'credit_status',
'hold_reason',
'ar_notes',
'notes',
'is_active',
];
protected $casts = [
'credit_limit' => 'decimal:2',
'credit_granted' => 'boolean',
'credit_approved_at' => 'datetime',
'on_credit_hold' => 'boolean',
'is_active' => 'boolean',
];
public function business(): BelongsTo
{
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');
}
public function invoices(): HasMany
{
return $this->hasMany(ArInvoice::class, 'customer_id');
}
public function payments(): HasMany
{
return $this->hasMany(ArPayment::class, 'customer_id');
}
public function getOutstandingBalanceAttribute(): float
{
return (float) $this->invoices()
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->sum('balance_due');
}
public function getPastDueAmountAttribute(): float
{
return (float) $this->invoices()
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('due_date', '<', now())
->sum('balance_due');
}
public function isOverCreditLimit(): bool
{
if (! $this->credit_limit || $this->credit_limit <= 0) {
return false;
}
return $this->outstanding_balance > $this->credit_limit;
}
public function getAvailableCreditAttribute(): float
{
if (! $this->credit_limit || $this->credit_limit <= 0) {
return 0;
}
return max(0, $this->credit_limit - $this->outstanding_balance);
}
public function scopeOnHold($query)
{
return $query->where('on_credit_hold', true);
}
public function scopeAtRisk($query)
{
return $query->where(function ($q) {
$q->where('on_credit_hold', true)
->orWhere('credit_status', self::CREDIT_STATUS_WATCH)
->orWhere('credit_status', self::CREDIT_STATUS_HOLD)
->orWhereHas('invoices', function ($inv) {
$inv->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('due_date', '<', now());
});
});
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Models\Accounting;
use App\Models\Business;
use App\Models\Crm\CrmInvoice;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ArInvoice extends Model
{
use SoftDeletes;
protected $table = 'ar_invoices';
public const STATUS_DRAFT = 'draft';
public const STATUS_SENT = 'sent';
public const STATUS_PARTIAL = 'partial';
public const STATUS_PAID = 'paid';
public const STATUS_VOID = 'void';
public const STATUS_OVERDUE = 'overdue';
protected $fillable = [
'business_id',
'customer_id',
'crm_invoice_id',
'invoice_number',
'invoice_date',
'due_date',
'description',
'subtotal',
'tax_amount',
'total_amount',
'balance_due',
'currency',
'status',
'gl_account_id',
'memo',
'sent_at',
];
protected $casts = [
'invoice_date' => 'date',
'due_date' => 'date',
'sent_at' => 'datetime',
'subtotal' => 'decimal:2',
'tax_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'balance_due' => 'decimal:2',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(ArCustomer::class, 'customer_id');
}
public function glAccount(): BelongsTo
{
return $this->belongsTo(GlAccount::class, 'gl_account_id');
}
/**
* The CRM invoice this AR invoice was created from (if any).
* Links operational billing (Sales Suite) to financial layer (Management Suite).
*/
public function crmInvoice(): BelongsTo
{
return $this->belongsTo(CrmInvoice::class, 'crm_invoice_id');
}
public function payments(): HasMany
{
return $this->hasMany(ArPayment::class, 'invoice_id');
}
public function items(): HasMany
{
return $this->hasMany(ArInvoiceItem::class, 'invoice_id');
}
public function isOverdue(): bool
{
return $this->due_date && $this->due_date->isPast() && $this->balance_due > 0;
}
public function getDaysOverdueAttribute(): int
{
if (! $this->isOverdue()) {
return 0;
}
return (int) $this->due_date->diffInDays(now());
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\Accounting;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArInvoiceItem extends Model
{
protected $table = 'ar_invoice_items';
protected $fillable = [
'invoice_id',
'description',
'quantity',
'unit_price',
'amount',
'gl_account_id',
];
protected $casts = [
'quantity' => 'decimal:4',
'unit_price' => 'decimal:2',
'amount' => 'decimal:2',
];
public function invoice(): BelongsTo
{
return $this->belongsTo(ArInvoice::class, 'invoice_id');
}
public function glAccount(): BelongsTo
{
return $this->belongsTo(GlAccount::class, 'gl_account_id');
}
}

Some files were not shown because too many files have changed in this diff Show More