Summary of completed work: - Complete buyer portal (browse, cart, checkout, orders, invoices) - Complete seller portal (orders, manifests, fleet, picking) - Business onboarding wizards (buyer 4-step, seller 5-step) - Email verification and registration flows - Notification system for buyers and sellers - Payment term surcharges and pickup/delivery workflows - Filament admin resources (Business, Brand, Product, Order, Invoice, User) - 51 migrations executed successfully Renamed Company -> Business throughout codebase for consistency. Unified authentication flows with password reset. Added audit trail and telescope for debugging. Next: Week 4 Data Migration (Days 22-28) - Product migration (883 products from cannabrands_crm) - Company migration (81 buyer companies) - User migration (preserve password hashes) - Order history migration
637 lines
18 KiB
PHP
637 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
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\Relations\MorphMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|
use OwenIt\Auditing\Auditable;
|
|
|
|
class Business extends Model implements AuditableContract
|
|
{
|
|
use HasFactory, SoftDeletes, Auditable;
|
|
|
|
// User type (buyer/seller/both)
|
|
public const TYPES = [
|
|
'buyer' => 'Buyer (Dispensary/Retailer)',
|
|
'seller' => 'Seller (Brand/Manufacturer)',
|
|
'both' => 'Both (Vertically Integrated)',
|
|
];
|
|
|
|
// Business types aligned with cannabis industry (stored in business_type column)
|
|
public const BUSINESS_TYPES = [
|
|
'brand' => 'Brand/Manufacturer',
|
|
'retailer' => 'Retailer/Dispensary',
|
|
'distributor' => 'Distributor',
|
|
'cultivator' => 'Cultivator',
|
|
'processor' => 'Processor/Manufacturer',
|
|
'testing_lab' => 'Testing Laboratory',
|
|
'both' => 'Vertically Integrated', // Brand + Retailer
|
|
];
|
|
|
|
// Setup Status constants
|
|
// Services that can be offered
|
|
public const SERVICES = [
|
|
'cultivation',
|
|
'manufacturing',
|
|
'processing',
|
|
'distribution',
|
|
'retail',
|
|
'delivery',
|
|
'testing',
|
|
'consulting',
|
|
];
|
|
|
|
protected $fillable = [
|
|
// Account Ownership
|
|
'owner_user_id',
|
|
|
|
// Core Company Identity (LeafLink Company equivalent)
|
|
'name',
|
|
'slug',
|
|
'dba_name', // Doing Business As
|
|
'description',
|
|
'logo',
|
|
'website',
|
|
|
|
// Company Classification
|
|
'type', // buyer, seller, or both
|
|
'industry_type', // Primary industry type
|
|
'business_type', // Business type from onboarding (same as industry_type)
|
|
'services', // JSON: Array of services offered
|
|
|
|
// Corporate Structure
|
|
'business_group', // For multi-location operators (corporate grouping)
|
|
'parent_business_id', // For subsidiaries
|
|
|
|
// Tax & Legal Information (LICENSE HOLDER DETAILS)
|
|
'tin_ein', // Tax Identification Number
|
|
'license_number', // Cannabis retail license number
|
|
'legal_name', // Official legal entity name
|
|
'legal_structure', // LLC, Corporation, Partnership, etc.
|
|
|
|
// Compliance Documents (LeafLink requirement)
|
|
'business_license_path', // State business license
|
|
'cannabis_license_path', // Cannabis operation license
|
|
'w9_form_path', // W-9 tax form
|
|
'insurance_certificate_path', // General liability insurance
|
|
'bond_certificate_path', // Surety bond if required
|
|
'ato_document_path', // Arizona Transaction Privilege Tax
|
|
'resale_certificate_path', // Resale certificate
|
|
'compliance_documents_updated_at', // When docs were last updated
|
|
|
|
// Corporate Address (headquarters/main office)
|
|
'corporate_address',
|
|
'corporate_city',
|
|
'corporate_state',
|
|
'corporate_zipcode',
|
|
'corporate_unit',
|
|
|
|
// Physical Address (business location)
|
|
'physical_address',
|
|
'physical_city',
|
|
'physical_state',
|
|
'physical_zipcode',
|
|
|
|
// Location Contact
|
|
'business_phone',
|
|
'business_email',
|
|
|
|
// Billing Address
|
|
'billing_address',
|
|
'billing_city',
|
|
'billing_state',
|
|
'billing_zipcode',
|
|
|
|
// Shipping/Delivery Address
|
|
'shipping_address',
|
|
'shipping_city',
|
|
'shipping_state',
|
|
'shipping_zipcode',
|
|
|
|
// AP Contact
|
|
'ap_contact_first_name',
|
|
'ap_contact_last_name',
|
|
'ap_contact_phone',
|
|
'ap_contact_email',
|
|
|
|
// Delivery Contact
|
|
'delivery_contact_first_name',
|
|
'delivery_contact_last_name',
|
|
'delivery_contact_phone',
|
|
|
|
// Financial & Payment Settings
|
|
'payment_terms', // net_30, net_15, etc.
|
|
'commission_rate',
|
|
'credit_limit',
|
|
'payment_method_preference',
|
|
|
|
// Onboarding & Application
|
|
'referral_source',
|
|
'referral_code',
|
|
'status', // not_started, in_progress, submitted, approved, rejected
|
|
'rejected_at',
|
|
'rejection_reason',
|
|
'application_submitted_at',
|
|
'onboarding_completed',
|
|
|
|
// Status & Settings (LeafLink archival pattern)
|
|
'is_active', // Active vs Archived
|
|
'approved_at',
|
|
'approved_by',
|
|
'archived_at',
|
|
'archived_reason',
|
|
'settings', // JSON for additional settings
|
|
'notes',
|
|
|
|
// Business Setup Form Fields (from CRM migration)
|
|
'payment_method',
|
|
'delivery_unit',
|
|
'delivery_directions',
|
|
'delivery_preferences',
|
|
'delivery_schedule',
|
|
'buyer_name',
|
|
'buyer_phone',
|
|
'buyer_email',
|
|
'preferred_contact_method',
|
|
'tpt_document_path',
|
|
'form_5000a_path',
|
|
'setup_completed_at',
|
|
'setup_progress',
|
|
|
|
// Additional contact fields from comprehensive CRM form
|
|
'physical_address',
|
|
'physical_city',
|
|
'physical_state',
|
|
'physical_zipcode',
|
|
'business_phone',
|
|
'business_email',
|
|
'license_number',
|
|
'billing_address',
|
|
'billing_city',
|
|
'billing_state',
|
|
'billing_zipcode',
|
|
'shipping_address',
|
|
'shipping_city',
|
|
'shipping_state',
|
|
'shipping_zipcode',
|
|
'ap_contact_first_name',
|
|
'ap_contact_last_name',
|
|
'ap_contact_phone',
|
|
'ap_contact_email',
|
|
'ap_contact_sms',
|
|
'ap_preferred_contact_method',
|
|
'delivery_contact_first_name',
|
|
'delivery_contact_last_name',
|
|
'delivery_contact_phone',
|
|
'delivery_contact_email',
|
|
'delivery_contact_sms',
|
|
];
|
|
|
|
protected $casts = [
|
|
'services' => 'array',
|
|
'settings' => 'array',
|
|
'setup_progress' => 'array',
|
|
'setup_completed_at' => 'datetime',
|
|
'is_active' => 'boolean',
|
|
'onboarding_completed' => 'boolean',
|
|
'approved_at' => 'datetime',
|
|
'rejected_at' => 'datetime',
|
|
'application_submitted_at' => 'datetime',
|
|
'archived_at' => 'datetime',
|
|
'compliance_documents_updated_at' => 'datetime',
|
|
'commission_rate' => 'decimal:2',
|
|
'credit_limit' => 'decimal:2',
|
|
];
|
|
|
|
// LeafLink-aligned Relationships
|
|
public function users(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(User::class, 'business_user')
|
|
->withPivot('contact_type', 'is_primary', 'permissions')
|
|
->withTimestamps();
|
|
}
|
|
|
|
public function owner(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'owner_user_id');
|
|
}
|
|
|
|
public function locations(): HasMany
|
|
{
|
|
// TODO: Locations not yet implemented - table is empty placeholder
|
|
// When implemented, should use 'business_id' foreign key for consistency
|
|
return $this->hasMany(Location::class, 'business_id');
|
|
}
|
|
|
|
public function licenses(): HasMany
|
|
{
|
|
return $this->hasMany(License::class);
|
|
}
|
|
|
|
public function contacts(): HasMany
|
|
{
|
|
return $this->hasMany(Contact::class);
|
|
}
|
|
|
|
public function addresses(): MorphMany
|
|
{
|
|
return $this->morphMany(Address::class, 'addressable');
|
|
}
|
|
|
|
public function products(): HasMany
|
|
{
|
|
return $this->hasMany(Product::class);
|
|
}
|
|
|
|
public function orders(): HasMany
|
|
{
|
|
return $this->hasMany(Order::class);
|
|
}
|
|
|
|
public function brands(): HasMany
|
|
{
|
|
return $this->hasMany(Brand::class);
|
|
}
|
|
|
|
// Corporate Structure
|
|
public function parentBusiness()
|
|
{
|
|
return $this->belongsTo(Business::class, 'parent_business_id');
|
|
}
|
|
|
|
public function subsidiaries(): HasMany
|
|
{
|
|
return $this->hasMany(Business::class, 'parent_business_id');
|
|
}
|
|
|
|
public function approver()
|
|
{
|
|
return $this->belongsTo(User::class, 'approved_by');
|
|
}
|
|
|
|
// Scopes
|
|
public function scopeActive($query)
|
|
{
|
|
return $query->where('is_active', true);
|
|
}
|
|
|
|
public function scopeApproved($query)
|
|
{
|
|
return $query->where('is_approved', true);
|
|
}
|
|
|
|
public function scopeSellers($query)
|
|
{
|
|
return $query->whereIn('type', ['seller', 'both']);
|
|
}
|
|
|
|
public function scopeBuyers($query)
|
|
{
|
|
return $query->whereIn('type', ['buyer', 'both']);
|
|
}
|
|
|
|
// Helper methods (LeafLink-aligned)
|
|
public function isSeller(): bool
|
|
{
|
|
return in_array($this->type, ['seller', 'both']);
|
|
}
|
|
|
|
public function isBuyer(): bool
|
|
{
|
|
return in_array($this->type, ['buyer', 'both']);
|
|
}
|
|
|
|
public function isVerticallyIntegrated(): bool
|
|
{
|
|
return $this->type === 'both';
|
|
}
|
|
|
|
public function hasService(string $service): bool
|
|
{
|
|
return in_array($service, $this->services ?? []);
|
|
}
|
|
|
|
public function needsOnboarding(): bool
|
|
{
|
|
return !$this->onboarding_completed;
|
|
}
|
|
|
|
public function isArchived(): bool
|
|
{
|
|
return !is_null($this->archived_at);
|
|
}
|
|
|
|
public function isPending(): bool
|
|
{
|
|
return $this->status === 'submitted';
|
|
}
|
|
|
|
public function isApproved(): bool
|
|
{
|
|
return $this->status === 'approved';
|
|
}
|
|
|
|
public function isRejected(): bool
|
|
{
|
|
return $this->status === 'rejected';
|
|
}
|
|
|
|
// Corporate Structure Helpers
|
|
public function getGroupedCompanies()
|
|
{
|
|
if (!$this->business_group) {
|
|
return collect([$this]);
|
|
}
|
|
|
|
return static::where('business_group', $this->business_group)
|
|
->where('is_active', true)
|
|
->get();
|
|
}
|
|
|
|
public function getPrimaryLocation()
|
|
{
|
|
return $this->locations()->where('is_primary', true)->first();
|
|
}
|
|
|
|
public function getCorporateAddress()
|
|
{
|
|
return $this->addresses()->where('type', 'corporate')->first();
|
|
}
|
|
|
|
public function getActiveLicenses()
|
|
{
|
|
return $this->licenses()->where('status', 'active')->get();
|
|
}
|
|
|
|
public function getPrimaryContact()
|
|
{
|
|
return $this->contacts()->where('is_primary', true)->first();
|
|
}
|
|
|
|
public function getAccountOwner()
|
|
{
|
|
return $this->owner;
|
|
}
|
|
|
|
// COMPLIANCE & LICENSE HOLDER METHODS
|
|
|
|
/**
|
|
* Check if business has all required compliance documents
|
|
* (Required for LeafLink-style platform operation)
|
|
*/
|
|
public function hasRequiredComplianceDocs(): bool
|
|
{
|
|
$requiredDocs = [
|
|
'business_license_path',
|
|
'cannabis_license_path',
|
|
'w9_form_path',
|
|
'insurance_certificate_path',
|
|
];
|
|
|
|
foreach ($requiredDocs as $doc) {
|
|
if (empty($this->$doc)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get missing compliance documents
|
|
*/
|
|
public function getMissingComplianceDocs(): array
|
|
{
|
|
$requiredDocs = [
|
|
'business_license_path' => 'Business License',
|
|
'cannabis_license_path' => 'Cannabis License',
|
|
'w9_form_path' => 'W-9 Tax Form',
|
|
'insurance_certificate_path' => 'Insurance Certificate',
|
|
];
|
|
|
|
$missing = [];
|
|
foreach ($requiredDocs as $field => $name) {
|
|
if (empty($this->$field)) {
|
|
$missing[] = $name;
|
|
}
|
|
}
|
|
|
|
return $missing;
|
|
}
|
|
|
|
/**
|
|
* Check if company can legally sell cannabis products
|
|
* (Has active licenses and compliance docs)
|
|
*/
|
|
public function canSellCannabis(): bool
|
|
{
|
|
return $this->isApproved() &&
|
|
$this->hasRequiredComplianceDocs() &&
|
|
$this->getActiveLicenses()->isNotEmpty() &&
|
|
$this->is_active;
|
|
}
|
|
|
|
/**
|
|
* Check if compliance documents are up to date
|
|
*/
|
|
public function hasCurrentComplianceDocs(): bool
|
|
{
|
|
if (!$this->compliance_documents_updated_at) {
|
|
return false;
|
|
}
|
|
|
|
// Documents should be updated within last year
|
|
return $this->compliance_documents_updated_at->isAfter(now()->subYear());
|
|
}
|
|
|
|
/**
|
|
* Get legal entity information for contracts/invoices
|
|
*/
|
|
public function getLegalEntityInfo(): array
|
|
{
|
|
return [
|
|
'legal_name' => $this->legal_name ?: $this->name,
|
|
'dba_name' => $this->dba_name,
|
|
'legal_structure' => $this->legal_structure,
|
|
'tin_ein' => $this->tin_ein,
|
|
'corporate_address' => $this->getCorporateAddress(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Update compliance documents timestamp
|
|
*/
|
|
public function updateComplianceDocuments()
|
|
{
|
|
$this->update(['compliance_documents_updated_at' => now()]);
|
|
}
|
|
|
|
// Archive/Restore (LeafLink pattern)
|
|
public function archive(string $reason = null)
|
|
{
|
|
$this->update([
|
|
'is_active' => false,
|
|
'archived_at' => now(),
|
|
'archived_reason' => $reason,
|
|
]);
|
|
|
|
// Archive related entities
|
|
$this->locations()->update(['is_active' => false]);
|
|
$this->contacts()->update(['is_active' => false]);
|
|
}
|
|
|
|
public function restore()
|
|
{
|
|
$this->update([
|
|
'is_active' => true,
|
|
'archived_at' => null,
|
|
'archived_reason' => null,
|
|
]);
|
|
}
|
|
|
|
// Company Setup Progress Tracking (Based on CRM Form Analysis)
|
|
|
|
/**
|
|
* Calculate company setup progress percentage and step details
|
|
* Based on the 5-step form from the old CRM: Company → Billing → Delivery → Documents → Review
|
|
*/
|
|
public function getSetupProgress(): array
|
|
{
|
|
$steps = [
|
|
'company_profile' => [
|
|
'name' => 'Company Profile',
|
|
'weight' => 20,
|
|
'fields' => ['name', 'dba_name', 'license_number', 'tin_ein', 'business_type', 'physical_address', 'physical_city', 'physical_state']
|
|
],
|
|
'contact_billing' => [
|
|
'name' => 'Contact & Billing',
|
|
'weight' => 25,
|
|
'fields' => ['business_phone', 'business_email', 'billing_address', 'billing_city', 'billing_state', 'ap_contact_first_name', 'ap_contact_last_name', 'ap_contact_email']
|
|
],
|
|
'delivery_info' => [
|
|
'name' => 'Delivery Information',
|
|
'weight' => 15,
|
|
'fields' => ['shipping_address', 'shipping_city', 'delivery_preferences', 'delivery_contact_name', 'delivery_contact_phone']
|
|
],
|
|
'compliance_docs' => [
|
|
'name' => 'Compliance Documents',
|
|
'weight' => 30,
|
|
'fields' => ['cannabis_license_path', 'w9_form_path', 'insurance_certificate_path', 'business_license_path']
|
|
],
|
|
'final_review' => [
|
|
'name' => 'Final Review',
|
|
'weight' => 10,
|
|
'fields' => ['buyer_name', 'buyer_email', 'preferred_contact_method']
|
|
]
|
|
];
|
|
|
|
$stepResults = [];
|
|
$totalProgress = 0;
|
|
|
|
foreach ($steps as $stepKey => $stepConfig) {
|
|
$requiredFields = collect($stepConfig['fields']);
|
|
$filledFields = $requiredFields->filter(function($field) {
|
|
return !empty($this->$field);
|
|
});
|
|
|
|
$stepPercentage = $requiredFields->count() > 0
|
|
? ($filledFields->count() / $requiredFields->count()) * 100
|
|
: 0;
|
|
|
|
$stepResults[$stepKey] = [
|
|
'name' => $stepConfig['name'],
|
|
'completed' => $stepPercentage >= 100,
|
|
'percentage' => $stepPercentage,
|
|
'weight' => $stepConfig['weight'],
|
|
'filled_fields' => $filledFields->count(),
|
|
'total_fields' => $requiredFields->count(),
|
|
];
|
|
|
|
$totalProgress += ($stepPercentage / 100) * $stepConfig['weight'];
|
|
}
|
|
|
|
return [
|
|
'percentage' => round($totalProgress, 1),
|
|
'steps' => $stepResults,
|
|
'is_complete' => $totalProgress >= 95, // 95% threshold for completion
|
|
'completed_at' => $this->setup_completed_at,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if company setup is complete enough to enable purchasing
|
|
*/
|
|
public function canPurchase(): bool
|
|
{
|
|
$progress = $this->getSetupProgress();
|
|
|
|
// Must have minimum required fields
|
|
$requiredForPurchase = [
|
|
'name', 'license_number', 'business_phone', 'business_email',
|
|
'billing_address', 'payment_method',
|
|
'cannabis_license_path', 'w9_form_path', 'business_license_path'
|
|
];
|
|
|
|
foreach ($requiredForPurchase as $field) {
|
|
if (empty($this->$field)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $progress['percentage'] >= 75; // 75% minimum for purchasing
|
|
}
|
|
|
|
/**
|
|
* Mark company setup as complete and submit for approval
|
|
*/
|
|
public function markSetupComplete(): void
|
|
{
|
|
$this->update([
|
|
'setup_completed_at' => now(),
|
|
'onboarding_completed' => true,
|
|
'status' => 'submitted', // Submit for admin approval
|
|
'application_submitted_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get setup status for dashboard display
|
|
*/
|
|
public function getSetupStatus(): array
|
|
{
|
|
$progress = $this->getSetupProgress();
|
|
|
|
if ($progress['is_complete']) {
|
|
return [
|
|
'status' => 'complete',
|
|
'message' => 'Company setup complete! You can now purchase cannabis products.',
|
|
'color' => 'success'
|
|
];
|
|
} elseif ($progress['percentage'] >= 75) {
|
|
return [
|
|
'status' => 'ready',
|
|
'message' => 'Setup nearly complete. You can start purchasing!',
|
|
'color' => 'primary'
|
|
];
|
|
} elseif ($progress['percentage'] >= 50) {
|
|
return [
|
|
'status' => 'in_progress',
|
|
'message' => 'Good progress! Complete your setup to start purchasing.',
|
|
'color' => 'warning'
|
|
];
|
|
} else {
|
|
return [
|
|
'status' => 'started',
|
|
'message' => 'Complete your company setup to start purchasing cannabis products.',
|
|
'color' => 'info'
|
|
];
|
|
}
|
|
}
|
|
}
|