Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add BrandStoresController with stores index, store detail, and orders pages
- Add routes for /brands/{brand}/stores and /brands/{brand}/orders
- Add stores_url and orders_url to brand tiles on index page
- Add getBrandStoreMetrics stub method to CannaiqClient
- Fix sidebar double-active issue with exact_match and url_fallback
- Fix user invite using wrong user_type (manufacturer -> seller)
1439 lines
57 KiB
PHP
1439 lines
57 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Seller;
|
|
|
|
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;
|
|
|
|
class SettingsController extends Controller
|
|
{
|
|
/**
|
|
* Display the settings dashboard with tiles for all settings areas.
|
|
* Only accessible by business Owner or Super Admin.
|
|
*/
|
|
public function index(Business $business)
|
|
{
|
|
// Authorization: Only Owner or Super Admin can access settings dashboard
|
|
$user = auth()->user();
|
|
$isOwner = $business->owner_user_id === $user->id;
|
|
$isSuperAdmin = $user->user_type === 'admin';
|
|
|
|
if (! $isOwner && ! $isSuperAdmin) {
|
|
abort(403, 'Only business owners and administrators can access settings.');
|
|
}
|
|
|
|
return view('seller.settings.index', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Display the consolidated deliveries settings page.
|
|
* Includes delivery windows, drivers, and vehicles.
|
|
*/
|
|
public function deliveries(Business $business)
|
|
{
|
|
$windows = DeliveryWindow::where('business_id', $business->id)
|
|
->orderBy('day_of_week')
|
|
->orderBy('start_time')
|
|
->get();
|
|
|
|
$drivers = Driver::where('business_id', $business->id)
|
|
->orderBy('first_name')
|
|
->get();
|
|
|
|
$vehicles = Vehicle::where('business_id', $business->id)
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
return view('seller.settings.deliveries', compact('business', 'windows', 'drivers', 'vehicles'));
|
|
}
|
|
|
|
/**
|
|
* Display the company information settings page.
|
|
*/
|
|
public function companyInformation(Business $business)
|
|
{
|
|
return view('seller.settings.company-information', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Update the company information.
|
|
*/
|
|
public function updateCompanyInformation(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'dba_name' => 'nullable|string|max:255',
|
|
'description' => 'nullable|string|max:1000',
|
|
'business_type' => 'nullable|string',
|
|
'tin_ein' => 'nullable|string|max:20',
|
|
'license_number' => 'nullable|string|max:255',
|
|
'license_type' => 'nullable|string',
|
|
'physical_address' => 'nullable|string|max:255',
|
|
'physical_suite' => 'nullable|string|max:50',
|
|
'physical_city' => 'nullable|string|max:100',
|
|
'physical_state' => 'nullable|string|max:2',
|
|
'physical_zipcode' => 'nullable|string|max:10',
|
|
'business_phone' => 'nullable|string|max:20',
|
|
'business_email' => 'nullable|email|max:255',
|
|
'logo' => 'nullable|image|max:2048', // 2MB max
|
|
'banner' => 'nullable|image|max:4096', // 4MB max
|
|
'remove_logo' => 'nullable|boolean',
|
|
'remove_banner' => 'nullable|boolean',
|
|
]);
|
|
|
|
// Handle logo removal
|
|
if ($request->has('remove_logo') && $business->logo_path) {
|
|
\Storage::delete($business->logo_path);
|
|
$validated['logo_path'] = null;
|
|
}
|
|
|
|
// Handle logo upload
|
|
if ($request->hasFile('logo')) {
|
|
// Delete old logo if exists
|
|
if ($business->logo_path) {
|
|
\Storage::delete($business->logo_path);
|
|
}
|
|
$validated['logo_path'] = $request->file('logo')->store('businesses/logos', 'public');
|
|
}
|
|
|
|
// Handle banner removal
|
|
if ($request->has('remove_banner') && $business->banner_path) {
|
|
\Storage::delete($business->banner_path);
|
|
$validated['banner_path'] = null;
|
|
}
|
|
|
|
// Handle banner upload
|
|
if ($request->hasFile('banner')) {
|
|
// Delete old banner if exists
|
|
if ($business->banner_path) {
|
|
\Storage::delete($business->banner_path);
|
|
}
|
|
$validated['banner_path'] = $request->file('banner')->store('businesses/banners', 'public');
|
|
}
|
|
|
|
// Remove file inputs from validated data (already handled above)
|
|
unset($validated['logo'], $validated['banner'], $validated['remove_logo'], $validated['remove_banner']);
|
|
|
|
$business->update($validated);
|
|
|
|
// Return JSON for Precognition requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'message' => 'Company information updated successfully!',
|
|
]);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.company-information', $business->slug)
|
|
->with('success', 'Company information updated successfully!');
|
|
}
|
|
|
|
/**
|
|
* Display the users management settings page.
|
|
*/
|
|
public function users(Business $business, Request $request)
|
|
{
|
|
$query = $business->users();
|
|
|
|
// Search
|
|
if ($request->filled('search')) {
|
|
$search = $request->search;
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('name', 'ilike', "%{$search}%")
|
|
->orWhere('email', 'ilike', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
// Filter by last login date range
|
|
if ($request->filled('last_login_start')) {
|
|
$query->where('last_login_at', '>=', $request->last_login_start);
|
|
}
|
|
if ($request->filled('last_login_end')) {
|
|
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
|
|
}
|
|
|
|
$users = $query->with(['roles', 'departments'])->paginate(15);
|
|
|
|
// 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'));
|
|
}
|
|
|
|
/**
|
|
* Store a newly created user invitation.
|
|
*/
|
|
public function inviteUser(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'first_name' => 'required|string|max:255',
|
|
'last_name' => 'required|string|max:255',
|
|
'email' => 'required|email|unique:users,email',
|
|
'phone' => 'nullable|string|max:20',
|
|
'position' => 'nullable|string|max:255',
|
|
'role' => 'required|string|in:owner,admin,manager,member',
|
|
'department_ids' => 'nullable|array',
|
|
'department_ids.*' => 'exists:departments,id',
|
|
'is_point_of_contact' => 'nullable|boolean',
|
|
]);
|
|
|
|
// Combine first and last name
|
|
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
|
|
|
|
// 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'] ?? null,
|
|
'position' => $validated['position'] ?? null,
|
|
'user_type' => 'seller', // Users in seller area are sellers
|
|
'password' => bcrypt(str()->random(32)), // Temporary password
|
|
]);
|
|
|
|
// 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()
|
|
->route('seller.business.settings.users', $business->slug)
|
|
->with('success', 'User invited successfully!');
|
|
}
|
|
|
|
/**
|
|
* Remove user from business.
|
|
*/
|
|
public function removeUser(Business $business, \App\Models\User $user)
|
|
{
|
|
// Check if user belongs to this business
|
|
if (! $business->users()->where('users.id', $user->id)->exists()) {
|
|
abort(403, 'User does not belong to this business');
|
|
}
|
|
|
|
// Detach user from business
|
|
$business->users()->detach($user->id);
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.users', $business->slug)
|
|
->with('success', 'User removed successfully!');
|
|
}
|
|
|
|
/**
|
|
* Show the edit user permissions page.
|
|
*/
|
|
public function editUser(Business $business, User $user)
|
|
{
|
|
// Check if user belongs to this business
|
|
if (! $user->businesses->contains($business->id)) {
|
|
abort(404, 'User not found in this business.');
|
|
}
|
|
|
|
// Load user's departments with pivot data
|
|
$user->load('departments');
|
|
|
|
// Check if user is the business owner
|
|
$isOwner = $business->owner_user_id === $user->id;
|
|
|
|
// Get all departments for this business and its parent
|
|
$departments = Department::query()
|
|
->where(function ($query) use ($business) {
|
|
$query->where('business_id', $business->id);
|
|
if ($business->parent_id) {
|
|
$query->orWhere('business_id', $business->parent_id);
|
|
}
|
|
})
|
|
->orderBy('business_id')
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
// Get the suites assigned to this business
|
|
$businessSuites = $business->suites()->active()->get();
|
|
|
|
// 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'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Update user permissions and department assignments.
|
|
*/
|
|
public function updateUser(Business $business, User $user, Request $request)
|
|
{
|
|
// Check if user belongs to this business
|
|
if (! $user->businesses->contains($business->id)) {
|
|
abort(404, 'User not found in this business.');
|
|
}
|
|
|
|
// Check if user is the business owner
|
|
$isOwner = $business->owner_user_id === $user->id;
|
|
|
|
if ($isOwner) {
|
|
return redirect()
|
|
->route('seller.business.settings.users.edit', [$business->slug, $user->uuid])
|
|
->with('error', 'Cannot modify owner permissions.');
|
|
}
|
|
|
|
// Update user info
|
|
$user->update([
|
|
'position' => $request->input('position'),
|
|
'company' => $request->input('company'),
|
|
]);
|
|
|
|
// Update pivot table data
|
|
$business->users()->updateExistingPivot($user->id, [
|
|
'contact_type' => $request->input('contact_type'),
|
|
'role' => $request->input('role'),
|
|
'role_template' => $request->input('role_template'),
|
|
'permissions' => $request->input('permissions', []),
|
|
]);
|
|
|
|
// Update department assignments
|
|
$departmentAssignments = $request->input('departments', []);
|
|
$syncData = [];
|
|
|
|
foreach ($departmentAssignments as $departmentId => $data) {
|
|
if (isset($data['assigned']) && $data['assigned']) {
|
|
$syncData[$departmentId] = [
|
|
'role' => $data['role'] ?? 'operator',
|
|
];
|
|
}
|
|
}
|
|
|
|
// Sync departments (this will add new, update existing, and remove unchecked)
|
|
$user->departments()->sync($syncData);
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.users.edit', [$business->slug, $user->uuid])
|
|
->with('success', 'User permissions and department assignments updated successfully!');
|
|
}
|
|
|
|
/**
|
|
* Get suite-based permission structure for the assigned suites.
|
|
*
|
|
* Groups permissions by functional area for better UX.
|
|
*/
|
|
private function getSuitePermissions($businessSuites): array
|
|
{
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Display the order settings page.
|
|
*/
|
|
public function orders(Business $business)
|
|
{
|
|
return view('seller.settings.orders', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Update the order settings.
|
|
*/
|
|
public function updateOrders(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'separate_orders_by_brand' => 'nullable|boolean',
|
|
'auto_increment_order_ids' => 'nullable|boolean',
|
|
'show_mark_as_paid' => 'nullable|boolean',
|
|
'display_crm_license_on_orders' => 'nullable|boolean',
|
|
'order_minimum' => 'nullable|numeric|min:0',
|
|
'default_shipping_charge' => 'nullable|numeric|min:0',
|
|
'free_shipping_minimum' => 'nullable|numeric|min:0',
|
|
'order_disclaimer' => 'nullable|string|max:2000',
|
|
'order_invoice_footer' => 'nullable|string|max:1000',
|
|
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
|
|
'az_require_patient_count' => 'nullable|boolean',
|
|
'az_require_allotment_verification' => 'nullable|boolean',
|
|
]);
|
|
|
|
// Convert checkbox values (null means unchecked)
|
|
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
|
|
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
|
|
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
|
|
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
|
|
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
|
|
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
|
|
|
|
$business->update($validated);
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.orders', $business->slug)
|
|
->with('success', 'Order settings updated successfully!');
|
|
}
|
|
|
|
/**
|
|
* Display the payment settings page.
|
|
*/
|
|
public function payments(Business $business)
|
|
{
|
|
return view('seller.settings.payments', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Display the invoice settings page.
|
|
*/
|
|
public function invoices(Business $business)
|
|
{
|
|
return view('seller.settings.invoices', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Update the invoice settings.
|
|
*/
|
|
public function updateInvoices(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'invoice_payable_company_name' => 'nullable|string|max:255',
|
|
'invoice_payable_address' => 'nullable|string|max:255',
|
|
'invoice_payable_city' => 'nullable|string|max:100',
|
|
'invoice_payable_state' => 'nullable|string|max:2',
|
|
'invoice_payable_zipcode' => 'nullable|string|max:10',
|
|
]);
|
|
|
|
$business->update($validated);
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.invoices', $business->slug)
|
|
->with('success', 'Invoice settings updated successfully!');
|
|
}
|
|
|
|
/**
|
|
* Display the manage licenses page.
|
|
* Only available to Processing/Manufacturing/Compliance businesses.
|
|
*/
|
|
public function manageLicenses(Business $business)
|
|
{
|
|
// Gate: Sales Suite-only businesses don't need license management
|
|
if (! $business->requiresLicenseManagement()) {
|
|
return redirect()
|
|
->route('seller.business.settings.index', $business->slug)
|
|
->with('info', 'License management is available for processing and manufacturing operations.');
|
|
}
|
|
|
|
// Load locations with license data
|
|
$locations = $business->locations()->orderBy('name')->get();
|
|
|
|
// Find locations with expired licenses
|
|
$expiredLocations = $locations->filter(function ($location) {
|
|
return $location->license_expiration && $location->license_expiration->isPast();
|
|
});
|
|
|
|
// Find locations with licenses expiring within 30 days
|
|
$expiringLocations = $locations->filter(function ($location) {
|
|
return $location->license_expiration
|
|
&& $location->license_expiration->isFuture()
|
|
&& $location->license_expiration->diffInDays(now()) <= 30;
|
|
});
|
|
|
|
return view('seller.settings.manage-licenses', compact(
|
|
'business',
|
|
'locations',
|
|
'expiredLocations',
|
|
'expiringLocations'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Display the plans and billing page.
|
|
*/
|
|
public function plansAndBilling(Business $business)
|
|
{
|
|
return view('seller.settings.plans-and-billing', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Display the notification preferences page.
|
|
*/
|
|
public function notifications(Business $business)
|
|
{
|
|
// Only business owners and super admins can manage business notifications
|
|
$user = auth()->user();
|
|
$isOwner = $business->owner_user_id === $user->id;
|
|
$isSuperAdmin = $user->hasRole('Super Admin');
|
|
|
|
if (! $isOwner && ! $isSuperAdmin) {
|
|
abort(403, 'Only business owners can manage notification settings.');
|
|
}
|
|
|
|
return view('seller.settings.notifications', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Update the notification settings.
|
|
*
|
|
* EMAIL NOTIFICATION RULES DOCUMENTATION:
|
|
*
|
|
* 1. NEW ORDER EMAIL NOTIFICATIONS (new_order_email_notifications)
|
|
* Base: Email these addresses when a new order is placed
|
|
* - If 'new_order_only_when_no_sales_rep' checked: ONLY send if buyer has NO sales rep assigned
|
|
* - If 'new_order_do_not_send_to_admins' checked: Do NOT send to company admins (only to these addresses)
|
|
*
|
|
* 2. ORDER ACCEPTED EMAIL NOTIFICATIONS (order_accepted_email_notifications)
|
|
* Base: Email these addresses when an order is accepted
|
|
* - If 'enable_shipped_emails_for_sales_reps' checked: Sales reps assigned to customer get email when order marked Shipped
|
|
*
|
|
* 3. PLATFORM INQUIRY EMAIL NOTIFICATIONS (platform_inquiry_email_notifications)
|
|
* Base: Email these addresses for inquiries
|
|
* - Sales reps associated with customer ALWAYS receive email
|
|
* - If field is blank AND no sales reps exist: company admins receive notifications
|
|
*
|
|
* 4. MANUAL ORDER EMAIL NOTIFICATIONS
|
|
* - If 'enable_manual_order_email_notifications' checked: Send same emails for manual orders as buyer-created orders
|
|
* - If 'enable_manual_order_email_notifications' unchecked: Only send for buyer-created orders
|
|
* - If 'manual_order_emails_internal_only' checked: Send manual order emails to internal recipients only (not buyers)
|
|
*
|
|
* 5. LOW INVENTORY EMAIL NOTIFICATIONS (low_inventory_email_notifications)
|
|
* Base: Email these addresses when inventory is low
|
|
*
|
|
* 6. CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS (certified_seller_status_email_notifications)
|
|
* Base: Email these addresses when seller status changes
|
|
*/
|
|
public function updateNotifications(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'new_order_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
|
'new_order_only_when_no_sales_rep' => 'nullable|boolean',
|
|
'new_order_do_not_send_to_admins' => 'nullable|boolean',
|
|
'order_accepted_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
|
'enable_shipped_emails_for_sales_reps' => 'nullable|boolean',
|
|
'platform_inquiry_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
|
'enable_manual_order_email_notifications' => 'nullable|boolean',
|
|
'manual_order_emails_internal_only' => 'nullable|boolean',
|
|
'low_inventory_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
|
'certified_seller_status_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
|
// CRM Notification Settings
|
|
'crm_task_reminder_enabled' => 'nullable|boolean',
|
|
'crm_task_reminder_minutes' => 'nullable|integer|min:5|max:1440',
|
|
'crm_event_reminder_enabled' => 'nullable|boolean',
|
|
'crm_event_reminder_minutes' => 'nullable|integer|min:5|max:1440',
|
|
'crm_daily_digest_enabled' => 'nullable|boolean',
|
|
'crm_daily_digest_time' => 'nullable|string|date_format:H:i',
|
|
'crm_notification_emails' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
|
'crm_notify_on_opportunity_stage_change' => 'nullable|boolean',
|
|
'crm_notify_on_task_assigned' => 'nullable|boolean',
|
|
'crm_notify_on_task_due_today' => 'nullable|boolean',
|
|
'crm_notify_on_overdue_tasks' => 'nullable|boolean',
|
|
]);
|
|
|
|
// Convert checkbox values (null means unchecked)
|
|
$validated['new_order_only_when_no_sales_rep'] = $request->has('new_order_only_when_no_sales_rep');
|
|
$validated['new_order_do_not_send_to_admins'] = $request->has('new_order_do_not_send_to_admins');
|
|
$validated['enable_shipped_emails_for_sales_reps'] = $request->has('enable_shipped_emails_for_sales_reps');
|
|
$validated['enable_manual_order_email_notifications'] = $request->has('enable_manual_order_email_notifications');
|
|
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
|
|
|
|
// CRM notification checkbox values
|
|
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');
|
|
$validated['crm_notify_on_opportunity_stage_change'] = $request->has('crm_notify_on_opportunity_stage_change');
|
|
$validated['crm_notify_on_task_assigned'] = $request->has('crm_notify_on_task_assigned');
|
|
$validated['crm_notify_on_task_due_today'] = $request->has('crm_notify_on_task_due_today');
|
|
$validated['crm_notify_on_overdue_tasks'] = $request->has('crm_notify_on_overdue_tasks');
|
|
}
|
|
|
|
$business->update($validated);
|
|
|
|
// Return JSON for Precognition requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'message' => 'Notification settings updated successfully!',
|
|
]);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.notifications', $business->slug)
|
|
->with('success', 'Notification settings updated successfully!');
|
|
}
|
|
|
|
/**
|
|
* Display the report settings page.
|
|
*/
|
|
public function reports(Business $business)
|
|
{
|
|
return view('seller.settings.reports', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Display the personal profile page.
|
|
*/
|
|
public function profile(Business $business)
|
|
{
|
|
$user = auth()->user();
|
|
$loginHistory = collect(); // Placeholder - will be implemented with login history tracking
|
|
|
|
return view('seller.settings.profile', compact('business', 'loginHistory'));
|
|
}
|
|
|
|
/**
|
|
* Update the personal profile.
|
|
*/
|
|
public function updateProfile(Business $business, Request $request)
|
|
{
|
|
$user = auth()->user();
|
|
|
|
$validated = $request->validate([
|
|
'first_name' => 'required|string|max:255',
|
|
'last_name' => 'required|string|max:255',
|
|
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
|
|
'avatar' => 'nullable|image|max:2048',
|
|
'remove_avatar' => 'nullable|boolean',
|
|
'use_gravatar' => 'nullable|boolean',
|
|
'linkedin_url' => 'nullable|url|max:255',
|
|
'twitter_url' => 'nullable|url|max:255',
|
|
'facebook_url' => 'nullable|url|max:255',
|
|
'instagram_url' => 'nullable|url|max:255',
|
|
'github_url' => 'nullable|url|max:255',
|
|
]);
|
|
|
|
// Handle avatar removal
|
|
if ($request->has('remove_avatar') && $user->avatar_path) {
|
|
\Storage::delete($user->avatar_path);
|
|
$validated['avatar_path'] = null;
|
|
}
|
|
|
|
// Handle avatar upload
|
|
if ($request->hasFile('avatar')) {
|
|
// Delete old avatar if exists
|
|
if ($user->avatar_path) {
|
|
\Storage::delete($user->avatar_path);
|
|
}
|
|
|
|
$path = $request->file('avatar')->store('avatars', 'public');
|
|
$validated['avatar_path'] = $path;
|
|
}
|
|
|
|
$user->update($validated);
|
|
|
|
// Return JSON for Precognition requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'message' => 'Profile updated successfully.',
|
|
]);
|
|
}
|
|
|
|
return redirect()->route('seller.business.settings.profile', $business->slug)
|
|
->with('success', 'Profile updated successfully.');
|
|
}
|
|
|
|
/**
|
|
* Update the user's password.
|
|
*/
|
|
public function updatePassword(Business $business, Request $request)
|
|
{
|
|
$user = auth()->user();
|
|
|
|
$validated = $request->validate([
|
|
'current_password' => 'required|current_password',
|
|
'password' => 'required|string|min:8|confirmed',
|
|
'logout_other_sessions' => 'nullable|boolean',
|
|
]);
|
|
|
|
$user->update([
|
|
'password' => bcrypt($validated['password']),
|
|
]);
|
|
|
|
// Logout other sessions if requested
|
|
if ($request->has('logout_other_sessions')) {
|
|
auth()->logoutOtherDevices($validated['password']);
|
|
}
|
|
|
|
// Return JSON for Precognition requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'message' => 'Password updated successfully.',
|
|
]);
|
|
}
|
|
|
|
return redirect()->route('seller.business.settings.profile', $business->slug)
|
|
->with('success', 'Password updated successfully.');
|
|
}
|
|
|
|
/**
|
|
* Display the sales configuration page (orders + invoices).
|
|
*/
|
|
public function salesConfig(Business $business)
|
|
{
|
|
return view('seller.settings.sales-config', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Update the sales configuration settings (orders + invoices).
|
|
*/
|
|
public function updateSalesConfig(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
// Order settings
|
|
'separate_orders_by_brand' => 'nullable|boolean',
|
|
'auto_increment_order_ids' => 'nullable|boolean',
|
|
'show_mark_as_paid' => 'nullable|boolean',
|
|
'display_crm_license_on_orders' => 'nullable|boolean',
|
|
'order_minimum' => 'nullable|numeric|min:0',
|
|
'default_shipping_charge' => 'nullable|numeric|min:0',
|
|
'free_shipping_minimum' => 'nullable|numeric|min:0',
|
|
'order_disclaimer' => 'nullable|string|max:2000',
|
|
'order_invoice_footer' => 'nullable|string|max:1000',
|
|
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
|
|
'az_require_patient_count' => 'nullable|boolean',
|
|
'az_require_allotment_verification' => 'nullable|boolean',
|
|
// Invoice settings
|
|
'invoice_payable_company_name' => 'nullable|string|max:255',
|
|
'invoice_payable_address' => 'nullable|string|max:255',
|
|
'invoice_payable_city' => 'nullable|string|max:100',
|
|
'invoice_payable_state' => 'nullable|string|max:2',
|
|
'invoice_payable_zipcode' => 'nullable|string|max:10',
|
|
]);
|
|
|
|
// Convert checkbox values (null means unchecked)
|
|
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
|
|
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
|
|
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
|
|
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
|
|
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
|
|
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
|
|
|
|
$business->update($validated);
|
|
|
|
// Return JSON for Precognition requests
|
|
if ($request->wantsJson()) {
|
|
return response()->json([
|
|
'message' => 'Sales configuration updated successfully!',
|
|
]);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.sales-config', $business->slug)
|
|
->with('success', 'Sales configuration updated successfully!');
|
|
}
|
|
|
|
/**
|
|
* Display the brand kit page (Cannabrands assets/branding settings).
|
|
*/
|
|
public function brandKit(Business $business)
|
|
{
|
|
return view('seller.settings.brand-kit', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Display the integrations page.
|
|
*/
|
|
public function integrations(Business $business)
|
|
{
|
|
return view('seller.settings.integrations', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Display the webhooks / API page.
|
|
*/
|
|
public function webhooks(Business $business)
|
|
{
|
|
return view('seller.settings.webhooks', compact('business'));
|
|
}
|
|
|
|
/**
|
|
* Display the audit logs page.
|
|
*/
|
|
public function auditLogs(Business $business, Request $request)
|
|
{
|
|
// CRITICAL: Only show audit logs for THIS business (multi-tenancy)
|
|
$query = \App\Models\AuditLog::forBusiness($business->id)
|
|
->with(['user', 'auditable']);
|
|
|
|
// Search filter
|
|
if ($request->filled('search')) {
|
|
$search = $request->search;
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('description', 'ilike', "%{$search}%")
|
|
->orWhere('event', 'ilike', "%{$search}%")
|
|
->orWhereHas('user', function ($userQuery) use ($search) {
|
|
$userQuery->where('name', 'ilike', "%{$search}%")
|
|
->orWhere('email', 'ilike', "%{$search}%");
|
|
});
|
|
});
|
|
}
|
|
|
|
// Filter by event type
|
|
if ($request->filled('event')) {
|
|
$query->byEvent($request->event);
|
|
}
|
|
|
|
// Filter by auditable type (resource type)
|
|
if ($request->filled('type')) {
|
|
$query->byType($request->type);
|
|
}
|
|
|
|
// Filter by user
|
|
if ($request->filled('user_id')) {
|
|
$query->forUser($request->user_id);
|
|
}
|
|
|
|
// Filter by date range
|
|
if ($request->filled('start_date')) {
|
|
$query->where('created_at', '>=', $request->start_date);
|
|
}
|
|
if ($request->filled('end_date')) {
|
|
$query->where('created_at', '<=', $request->end_date.' 23:59:59');
|
|
}
|
|
|
|
// Get paginated results, ordered by most recent first
|
|
$audits = $query->latest('created_at')->paginate(50);
|
|
|
|
// Get unique event types for filter dropdown
|
|
$eventTypes = \App\Models\AuditLog::forBusiness($business->id)
|
|
->select('event')
|
|
->distinct()
|
|
->pluck('event')
|
|
->sort();
|
|
|
|
// Get unique auditable types for filter dropdown
|
|
$auditableTypes = \App\Models\AuditLog::forBusiness($business->id)
|
|
->select('auditable_type')
|
|
->whereNotNull('auditable_type')
|
|
->distinct()
|
|
->get()
|
|
->map(function ($log) {
|
|
$parts = explode('\\', $log->auditable_type);
|
|
|
|
return end($parts);
|
|
})
|
|
->unique()
|
|
->sort();
|
|
|
|
// Get pruning settings for this business
|
|
$pruningSettings = \App\Models\AuditPruningSettings::getForBusiness($business->id);
|
|
|
|
return view('seller.settings.audit-logs', compact('business', 'audits', 'eventTypes', 'auditableTypes', 'pruningSettings'));
|
|
}
|
|
|
|
/**
|
|
* Restore a model to its previous state from an audit log
|
|
*/
|
|
public function restoreAudit(Business $business, Request $request, $auditId)
|
|
{
|
|
// Find the audit record
|
|
$audit = \App\Models\AuditLog::findOrFail($auditId);
|
|
|
|
// Validate that this audit can be restored
|
|
if ($audit->event !== 'updated') {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Only updated records can be restored. Created or deleted records cannot be reverted.',
|
|
], 422);
|
|
}
|
|
|
|
if (empty($audit->old_values)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'No previous values found for this audit entry.',
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
// Get the auditable model
|
|
$modelClass = $audit->auditable_type;
|
|
$model = $modelClass::find($audit->auditable_id);
|
|
|
|
if (! $model) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'The original record no longer exists and cannot be restored.',
|
|
], 404);
|
|
}
|
|
|
|
// CRITICAL: Verify business ownership for multi-tenancy
|
|
// Check if model has business_id and belongs to current business
|
|
if (isset($model->business_id) && $model->business_id !== $business->id) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'You do not have permission to restore this record.',
|
|
], 403);
|
|
}
|
|
|
|
// For models that belong to business through brand (like Product)
|
|
if (method_exists($model, 'brand') && $model->brand && $model->brand->business_id !== $business->id) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'You do not have permission to restore this record.',
|
|
], 403);
|
|
}
|
|
|
|
// Restore the old values
|
|
$restoredFields = [];
|
|
foreach ($audit->old_values as $field => $value) {
|
|
// Skip fields that shouldn't be restored
|
|
$skipFields = ['created_at', 'updated_at', 'deleted_at', 'id', 'password'];
|
|
if (in_array($field, $skipFields)) {
|
|
continue;
|
|
}
|
|
|
|
// Only restore if the field exists on the model
|
|
if (array_key_exists($field, $model->getAttributes())) {
|
|
$model->{$field} = $value;
|
|
$restoredFields[] = $field;
|
|
}
|
|
}
|
|
|
|
if (empty($restoredFields)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'No restorable fields found in this audit entry.',
|
|
], 422);
|
|
}
|
|
|
|
// Save the model (this will create a new audit entry automatically)
|
|
$model->save();
|
|
|
|
// Add custom audit tag to indicate this was a restore operation
|
|
if (method_exists($model, 'audits')) {
|
|
$latestAudit = $model->audits()->latest()->first();
|
|
if ($latestAudit) {
|
|
$latestAudit->tags = 'restored_from_audit_'.$audit->id;
|
|
$latestAudit->save();
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Record successfully restored to previous state.',
|
|
'restored_fields' => $restoredFields,
|
|
'model_type' => class_basename($modelClass),
|
|
'model_id' => $model->id,
|
|
]);
|
|
} catch (\Exception $e) {
|
|
\Log::error('Audit restore failed', [
|
|
'audit_id' => $auditId,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Failed to restore record: '.$e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update audit pruning settings
|
|
*/
|
|
public function updateAuditPruning(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'enabled' => 'required|boolean',
|
|
'strategy' => 'required|in:revisions,time,hybrid',
|
|
'keep_revisions' => 'nullable|integer|min:1|max:100',
|
|
'keep_days' => 'nullable|integer|min:1|max:90', // HARD LIMIT: 90 days max
|
|
]);
|
|
|
|
$settings = \App\Models\AuditPruningSettings::getForBusiness($business->id);
|
|
$settings->update($validated);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Audit pruning settings updated successfully.',
|
|
'settings' => $settings,
|
|
'description' => $settings->strategy_description,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Export audit logs to CSV with filters
|
|
*/
|
|
public function exportAuditLogs(Business $business, Request $request)
|
|
{
|
|
// CRITICAL: Only export audit logs for THIS business (multi-tenancy)
|
|
$query = \App\Models\AuditLog::forBusiness($business->id)
|
|
->with(['user', 'auditable']);
|
|
|
|
// Apply same filters as auditLogs method
|
|
if ($request->filled('search')) {
|
|
$search = $request->search;
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('description', 'ilike', "%{$search}%")
|
|
->orWhere('event', 'ilike', "%{$search}%")
|
|
->orWhereHas('user', function ($userQuery) use ($search) {
|
|
$userQuery->where('name', 'ilike', "%{$search}%")
|
|
->orWhere('email', 'ilike', "%{$search}%");
|
|
});
|
|
});
|
|
}
|
|
|
|
if ($request->filled('event')) {
|
|
$query->byEvent($request->event);
|
|
}
|
|
|
|
if ($request->filled('type')) {
|
|
$query->byType($request->type);
|
|
}
|
|
|
|
if ($request->filled('user_id')) {
|
|
$query->forUser($request->user_id);
|
|
}
|
|
|
|
if ($request->filled('start_date')) {
|
|
$query->where('created_at', '>=', $request->start_date);
|
|
}
|
|
|
|
if ($request->filled('end_date')) {
|
|
$query->where('created_at', '<=', $request->end_date.' 23:59:59');
|
|
}
|
|
|
|
// Get all matching audits (no pagination for export)
|
|
$audits = $query->latest('created_at')->get();
|
|
|
|
// Generate CSV filename with timestamp
|
|
$filename = 'audit-logs-'.now()->format('Y-m-d-His').'.csv';
|
|
|
|
// Create CSV in memory
|
|
$handle = fopen('php://temp', 'r+');
|
|
|
|
// Write CSV header
|
|
fputcsv($handle, [
|
|
'Timestamp',
|
|
'User',
|
|
'Email',
|
|
'Action',
|
|
'Resource Type',
|
|
'Resource ID',
|
|
'IP Address',
|
|
'Changes',
|
|
]);
|
|
|
|
// Write data rows
|
|
foreach ($audits as $audit) {
|
|
// Format changes
|
|
$changes = '';
|
|
if (! empty($audit->old_values) || ! empty($audit->new_values)) {
|
|
$changeDetails = [];
|
|
$allKeys = array_unique(array_merge(
|
|
array_keys($audit->old_values ?? []),
|
|
array_keys($audit->new_values ?? [])
|
|
));
|
|
|
|
foreach ($allKeys as $key) {
|
|
$oldValue = $audit->old_values[$key] ?? 'null';
|
|
$newValue = $audit->new_values[$key] ?? 'null';
|
|
|
|
// Truncate long values for CSV readability
|
|
if (is_string($oldValue) && strlen($oldValue) > 50) {
|
|
$oldValue = substr($oldValue, 0, 47).'...';
|
|
}
|
|
if (is_string($newValue) && strlen($newValue) > 50) {
|
|
$newValue = substr($newValue, 0, 47).'...';
|
|
}
|
|
|
|
$changeDetails[] = "{$key}: {$oldValue} → {$newValue}";
|
|
}
|
|
$changes = implode(' | ', $changeDetails);
|
|
}
|
|
|
|
fputcsv($handle, [
|
|
$audit->created_at->format('Y-m-d H:i:s'),
|
|
$audit->user?->name ?? 'System',
|
|
$audit->user?->email ?? 'N/A',
|
|
ucfirst($audit->event),
|
|
class_basename($audit->auditable_type ?? 'Unknown'),
|
|
$audit->auditable_id ?? 'N/A',
|
|
$audit->ip_address ?? 'N/A',
|
|
$changes,
|
|
]);
|
|
}
|
|
|
|
// Rewind the file pointer
|
|
rewind($handle);
|
|
|
|
// Get the CSV content
|
|
$csv = stream_get_contents($handle);
|
|
fclose($handle);
|
|
|
|
// Return CSV download response
|
|
return response($csv, 200, [
|
|
'Content-Type' => 'text/csv',
|
|
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
|
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
|
'Pragma' => 'no-cache',
|
|
'Expires' => '0',
|
|
]);
|
|
}
|
|
|
|
public function changePlan(Business $business, Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'plan_id' => 'required|in:standard,business,premium',
|
|
]);
|
|
|
|
$planId = $validated['plan_id'];
|
|
|
|
// Define available plans with pricing
|
|
$plans = [
|
|
'standard' => ['name' => 'Marketplace Standard', 'price' => 99.00],
|
|
'business' => ['name' => 'Marketplace Business', 'price' => 395.00],
|
|
'premium' => ['name' => 'Marketplace Premium', 'price' => 795.00],
|
|
];
|
|
|
|
$newPlan = $plans[$planId];
|
|
|
|
// Get or create subscription
|
|
$subscription = $business->subscription()->firstOrCreate(
|
|
['business_id' => $business->id],
|
|
[
|
|
'plan_id' => 'standard',
|
|
'plan_name' => 'Marketplace Standard',
|
|
'plan_price' => 99.00,
|
|
'status' => 'active',
|
|
'current_period_start' => now(),
|
|
'current_period_end' => now()->addMonth(),
|
|
]
|
|
);
|
|
|
|
// Check if same plan
|
|
if ($subscription->plan_id === $planId) {
|
|
return redirect()
|
|
->route('seller.business.settings.plans-and-billing', $business->slug)
|
|
->with('info', 'You are already on this plan.');
|
|
}
|
|
|
|
// Determine if upgrade or downgrade
|
|
$isUpgrade = $newPlan['price'] > $subscription->plan_price;
|
|
|
|
if ($isUpgrade) {
|
|
// UPGRADE: Calculate prorated charge and update immediately
|
|
$daysLeftInCycle = now()->diffInDays($subscription->current_period_end);
|
|
$proratedCredit = ($subscription->plan_price / 30) * $daysLeftInCycle;
|
|
$proratedCharge = ($newPlan['price'] / 30) * $daysLeftInCycle;
|
|
$amountToPay = $proratedCharge - $proratedCredit;
|
|
|
|
// Create invoice for the upgrade
|
|
$invoiceNumber = 'INV-'.now()->format('Y').'-'.str_pad(\App\Models\SubscriptionInvoice::count() + 1, 5, '0', STR_PAD_LEFT);
|
|
|
|
$invoice = \App\Models\SubscriptionInvoice::create([
|
|
'subscription_id' => $subscription->id,
|
|
'business_id' => $business->id,
|
|
'invoice_number' => $invoiceNumber,
|
|
'type' => 'upgrade',
|
|
'amount' => $amountToPay,
|
|
'status' => 'pending',
|
|
'invoice_date' => now(),
|
|
'due_date' => now()->addDays(7),
|
|
'line_items' => [
|
|
[
|
|
'description' => "{$newPlan['name']} (prorated for {$daysLeftInCycle} days)",
|
|
'amount' => $proratedCharge,
|
|
],
|
|
[
|
|
'description' => "Credit from {$subscription->plan_name}",
|
|
'amount' => -$proratedCredit,
|
|
],
|
|
],
|
|
'payment_method_id' => $subscription->default_payment_method_id,
|
|
]);
|
|
|
|
// Update subscription to new plan immediately
|
|
$subscription->update([
|
|
'plan_id' => $planId,
|
|
'plan_name' => $newPlan['name'],
|
|
'plan_price' => $newPlan['price'],
|
|
'scheduled_plan_id' => null,
|
|
'scheduled_plan_name' => null,
|
|
'scheduled_plan_price' => null,
|
|
'scheduled_change_date' => null,
|
|
]);
|
|
|
|
// TODO: Charge the payment method for $amountToPay
|
|
// TODO: Mark invoice as paid after successful charge
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.plans-and-billing', $business->slug)
|
|
->with('success', sprintf(
|
|
'Plan upgraded to %s! Invoice %s created for $%s (prorated). New features are active immediately.',
|
|
$newPlan['name'],
|
|
$invoiceNumber,
|
|
number_format($amountToPay, 2)
|
|
));
|
|
|
|
} else {
|
|
// DOWNGRADE: Schedule for next billing cycle
|
|
$subscription->update([
|
|
'scheduled_plan_id' => $planId,
|
|
'scheduled_plan_name' => $newPlan['name'],
|
|
'scheduled_plan_price' => $newPlan['price'],
|
|
'scheduled_change_date' => $subscription->current_period_end,
|
|
]);
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.plans-and-billing', $business->slug)
|
|
->with('info', sprintf(
|
|
'Plan will be downgraded to %s on %s. You\'ll continue to have access to %s features until then.',
|
|
$newPlan['name'],
|
|
$subscription->current_period_end->format('F j, Y'),
|
|
$subscription->plan_name
|
|
));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a scheduled plan downgrade.
|
|
*/
|
|
public function cancelDowngrade(Business $business)
|
|
{
|
|
$subscription = $business->subscription;
|
|
|
|
if (! $subscription || ! $subscription->hasScheduledDowngrade()) {
|
|
return redirect()
|
|
->route('seller.business.settings.plans-and-billing', $business->slug)
|
|
->with('error', 'No scheduled downgrade found.');
|
|
}
|
|
|
|
// Cancel the scheduled downgrade
|
|
$subscription->update([
|
|
'scheduled_plan_id' => null,
|
|
'scheduled_plan_name' => null,
|
|
'scheduled_plan_price' => null,
|
|
'scheduled_change_date' => null,
|
|
]);
|
|
|
|
return redirect()
|
|
->route('seller.business.settings.plans-and-billing', $business->slug)
|
|
->with('success', 'Scheduled plan downgrade has been cancelled. You will remain on your current plan.');
|
|
}
|
|
|
|
/**
|
|
* View an invoice.
|
|
*/
|
|
public function viewInvoice(Business $business, string $invoiceId)
|
|
{
|
|
// TODO: Fetch actual invoice from database
|
|
$invoice = [
|
|
'id' => $invoiceId,
|
|
'date' => now()->subDays(rand(1, 90)),
|
|
'amount' => 395.00,
|
|
'status' => 'paid',
|
|
'items' => [
|
|
['description' => 'Marketplace Business Plan', 'quantity' => 1, 'price' => 395.00],
|
|
],
|
|
];
|
|
|
|
return view('seller.settings.invoice-view', compact('business', 'invoice'));
|
|
}
|
|
|
|
/**
|
|
* Download an invoice as PDF.
|
|
*/
|
|
public function downloadInvoice(Business $business, string $invoiceId)
|
|
{
|
|
// TODO: Generate actual PDF from invoice data
|
|
// For now, return a mock PDF
|
|
|
|
$invoice = [
|
|
'id' => $invoiceId,
|
|
'date' => now()->subDays(rand(1, 90)),
|
|
'amount' => 395.00,
|
|
'status' => 'paid',
|
|
'business_name' => $business->name,
|
|
'business_address' => $business->physical_address,
|
|
];
|
|
|
|
// Generate a simple mock PDF content
|
|
$pdfContent = "INVOICE #{$invoice['id']}\n\n";
|
|
$pdfContent .= "Date: {$invoice['date']->format('m/d/Y')}\n";
|
|
$pdfContent .= "Business: {$invoice['business_name']}\n";
|
|
$pdfContent .= 'Amount: $'.number_format($invoice['amount'], 2)."\n";
|
|
$pdfContent .= 'Status: '.strtoupper($invoice['status'])."\n\n";
|
|
$pdfContent .= "This is a mock invoice for testing purposes.\n";
|
|
$pdfContent .= "In production, this would be a properly formatted PDF.\n";
|
|
|
|
return response($pdfContent, 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'attachment; filename="invoice-'.$invoiceId.'.pdf"',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Update the notification settings.
|
|
*/
|
|
public function switchView(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'view' => 'required|in:sales,manufacturing,compliance',
|
|
]);
|
|
|
|
session(['current_view' => $validated['view']]);
|
|
|
|
return redirect()->back()->with('success', 'View switched successfully');
|
|
}
|
|
}
|