Compare commits

...

2 Commits

Author SHA1 Message Date
kelly
fc715c6022 feat: add DBA (Doing Business As) entity system for sellers
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Implement complete DBA management allowing businesses to operate under
multiple trade names with separate addresses, licenses, banking info,
and invoice branding.

Database:
- Create business_dbas table with encrypted bank/tax fields
- Add dba_id foreign key to crm_invoices

Models:
- BusinessDba model with encrypted casts, auto-slug, single-default enforcement
- Business model: dbas(), activeDbas(), defaultDba(), getDbaForInvoice()
- CrmInvoice model: dba() relationship, getSellerDisplayInfo() method

Seller UI:
- DbaController with full CRUD + set-default + toggle-active
- Index/create/edit views using DaisyUI
- Trade Names card added to settings index

Admin:
- DbasRelationManager for BusinessResource in Filament

Migration:
- MigrateDbaData command to convert existing dba_name fields
2025-12-17 12:50:07 -07:00
kelly
32a00493f8 fix(ci): explicitly set DB_CONNECTION=pgsql in phpunit.xml
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
CI was falling back to sqlite because DB_CONNECTION wasn't set.
This caused all database-dependent tests to fail with 'no such table'.
2025-12-17 10:37:52 -07:00
16 changed files with 2144 additions and 6 deletions

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\BusinessDba;
use Illuminate\Console\Command;
/**
* Migrate existing business DBA data to the new business_dbas table.
*
* This command creates DBA records from existing business fields:
* - dba_name
* - invoice_payable_company_name, invoice_payable_address, etc.
* - ap_contact_* fields
* - primary_contact_* fields
*/
class MigrateDbaData extends Command
{
protected $signature = 'dba:migrate
{--dry-run : Show what would be created without actually creating records}
{--business= : Migrate only a specific business by ID or slug}
{--force : Skip confirmation prompt}';
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
public function handle(): int
{
$this->info('DBA Data Migration');
$this->line('==================');
$dryRun = $this->option('dry-run');
$specificBusiness = $this->option('business');
if ($dryRun) {
$this->warn('DRY RUN MODE - No records will be created');
}
// Build query
$query = Business::query()
->whereNotNull('dba_name')
->where('dba_name', '!=', '');
if ($specificBusiness) {
$query->where(function ($q) use ($specificBusiness) {
$q->where('id', $specificBusiness)
->orWhere('slug', $specificBusiness);
});
}
$businesses = $query->get();
$this->info("Found {$businesses->count()} businesses with dba_name set.");
if ($businesses->isEmpty()) {
$this->info('No businesses to migrate.');
return self::SUCCESS;
}
// Show preview
$this->newLine();
$this->table(
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
$businesses->map(fn ($b) => [
$b->id,
\Illuminate\Support\Str::limit($b->name, 30),
\Illuminate\Support\Str::limit($b->dba_name, 30),
$b->invoice_payable_address ? 'Yes' : 'No',
$b->dbas()->exists() ? 'Yes' : 'No',
])
);
if (! $dryRun && ! $this->option('force')) {
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
$this->info('Aborted.');
return self::SUCCESS;
}
}
$created = 0;
$skipped = 0;
foreach ($businesses as $business) {
// Skip if business already has DBAs
if ($business->dbas()->exists()) {
$this->line(" Skipping {$business->name} - already has DBAs");
$skipped++;
continue;
}
if ($dryRun) {
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
$created++;
continue;
}
// Create DBA from existing business fields
$dba = BusinessDba::create([
'business_id' => $business->id,
'trade_name' => $business->dba_name,
// Address - prefer invoice_payable fields, fall back to physical
'address' => $business->invoice_payable_address ?: $business->physical_address,
'city' => $business->invoice_payable_city ?: $business->physical_city,
'state' => $business->invoice_payable_state ?: $business->physical_state,
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
// License
'license_number' => $business->license_number,
'license_type' => $business->license_type,
// Contacts
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
'primary_contact_email' => $business->primary_contact_email,
'primary_contact_phone' => $business->primary_contact_phone,
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
'ap_contact_email' => $business->ap_contact_email,
'ap_contact_phone' => $business->ap_contact_phone,
// Invoice Settings
'invoice_footer' => $business->order_invoice_footer,
// Status
'is_default' => true,
'is_active' => true,
]);
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
$created++;
}
$this->newLine();
$this->info("Summary: {$created} created, {$skipped} skipped");
if ($dryRun) {
$this->warn('Run without --dry-run to actually create records.');
}
return self::SUCCESS;
}
}

View File

@@ -2082,6 +2082,7 @@ class BusinessResource extends Resource
public static function getRelations(): array
{
return [
BusinessResource\RelationManagers\DbasRelationManager::class,
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
];
}

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Filament\Resources\BusinessResource\RelationManagers;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class DbasRelationManager extends RelationManager
{
protected static string $relationship = 'dbas';
protected static ?string $title = 'Trade Names (DBAs)';
protected static ?string $recordTitleAttribute = 'trade_name';
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->schema([
TextInput::make('trade_name')
->label('Trade Name')
->required()
->maxLength(255),
TextInput::make('slug')
->label('Slug')
->disabled()
->dehydrated(false)
->helperText('Auto-generated from trade name'),
Toggle::make('is_default')
->label('Default DBA')
->helperText('Use for new invoices by default'),
Toggle::make('is_active')
->label('Active')
->default(true),
])
->columns(2),
Section::make('Address')
->schema([
TextInput::make('address')
->label('Street Address')
->maxLength(255),
TextInput::make('address_line_2')
->label('Address Line 2')
->maxLength(255),
Grid::make(3)
->schema([
TextInput::make('city')
->maxLength(255),
TextInput::make('state')
->maxLength(2)
->extraAttributes(['class' => 'uppercase']),
TextInput::make('zip')
->label('ZIP Code')
->maxLength(10),
]),
])
->collapsible(),
Section::make('License Information')
->schema([
TextInput::make('license_number')
->maxLength(255),
TextInput::make('license_type')
->maxLength(255),
DatePicker::make('license_expiration')
->label('Expiration Date'),
])
->columns(3)
->collapsible(),
Section::make('Banking Information')
->description('Sensitive data is encrypted at rest.')
->schema([
TextInput::make('bank_name')
->maxLength(255),
TextInput::make('bank_account_name')
->maxLength(255),
TextInput::make('bank_routing_number')
->maxLength(50)
->password()
->revealable(),
TextInput::make('bank_account_number')
->maxLength(50)
->password()
->revealable(),
Select::make('bank_account_type')
->options([
'checking' => 'Checking',
'savings' => 'Savings',
]),
])
->columns(2)
->collapsible()
->collapsed(),
Section::make('Tax Information')
->description('Sensitive data is encrypted at rest.')
->schema([
TextInput::make('tax_id')
->label('Tax ID')
->maxLength(50)
->password()
->revealable(),
Select::make('tax_id_type')
->label('Tax ID Type')
->options([
'ein' => 'EIN',
'ssn' => 'SSN',
]),
])
->columns(2)
->collapsible()
->collapsed(),
Section::make('Contacts')
->schema([
Grid::make(2)
->schema([
Section::make('Primary Contact')
->schema([
TextInput::make('primary_contact_name')
->label('Name')
->maxLength(255),
TextInput::make('primary_contact_email')
->label('Email')
->email()
->maxLength(255),
TextInput::make('primary_contact_phone')
->label('Phone')
->tel()
->maxLength(50),
]),
Section::make('AP Contact')
->schema([
TextInput::make('ap_contact_name')
->label('Name')
->maxLength(255),
TextInput::make('ap_contact_email')
->label('Email')
->email()
->maxLength(255),
TextInput::make('ap_contact_phone')
->label('Phone')
->tel()
->maxLength(50),
]),
]),
])
->collapsible()
->collapsed(),
Section::make('Invoice Settings')
->schema([
TextInput::make('payment_terms')
->maxLength(50)
->placeholder('Net 30'),
TextInput::make('invoice_prefix')
->maxLength(10)
->placeholder('INV-'),
Textarea::make('payment_instructions')
->rows(2)
->columnSpanFull(),
Textarea::make('invoice_footer')
->rows(2)
->columnSpanFull(),
])
->columns(2)
->collapsible()
->collapsed(),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('trade_name')
->label('Trade Name')
->searchable()
->sortable(),
TextColumn::make('city')
->label('Location')
->formatStateUsing(fn ($record) => $record->city && $record->state
? "{$record->city}, {$record->state}"
: ($record->city ?? $record->state ?? '-'))
->sortable(),
TextColumn::make('license_number')
->label('License')
->limit(15)
->tooltip(fn ($state) => $state),
IconColumn::make('is_default')
->label('Default')
->boolean()
->trueIcon('heroicon-o-star')
->falseIcon('heroicon-o-minus')
->trueColor('warning'),
IconColumn::make('is_active')
->label('Active')
->boolean(),
TextColumn::make('created_at')
->label('Created')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('is_default', 'desc')
->headerActions([
CreateAction::make(),
])
->actions([
EditAction::make(),
DeleteAction::make()
->requiresConfirmation(),
])
->emptyStateHeading('No Trade Names')
->emptyStateDescription('Add a DBA to manage different trade names for invoices and licenses.')
->emptyStateIcon('heroicon-o-building-office-2');
}
}

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Http\Controllers\Seller\Settings;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\BusinessDba;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DbaController extends Controller
{
/**
* Display a listing of all DBAs for the business.
*/
public function index(Business $business): View
{
$dbas = $business->dbas()
->orderByDesc('is_default')
->orderBy('trade_name')
->get();
return view('seller.settings.dbas.index', compact('business', 'dbas'));
}
/**
* Show the form for creating a new DBA.
*/
public function create(Business $business): View
{
return view('seller.settings.dbas.create', compact('business'));
}
/**
* Store a newly created DBA in storage.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
// Identity
'trade_name' => 'required|string|max:255',
// Address
'address' => 'nullable|string|max:255',
'address_line_2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'state' => 'nullable|string|max:2',
'zip' => 'nullable|string|max:10',
// License
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string|max:255',
'license_expiration' => 'nullable|date',
// Bank Info
'bank_name' => 'nullable|string|max:255',
'bank_account_name' => 'nullable|string|max:255',
'bank_routing_number' => 'nullable|string|max:50',
'bank_account_number' => 'nullable|string|max:50',
'bank_account_type' => 'nullable|string|in:checking,savings',
// Tax
'tax_id' => 'nullable|string|max:50',
'tax_id_type' => 'nullable|string|in:ein,ssn',
// Contacts
'primary_contact_name' => 'nullable|string|max:255',
'primary_contact_email' => 'nullable|email|max:255',
'primary_contact_phone' => 'nullable|string|max:50',
'ap_contact_name' => 'nullable|string|max:255',
'ap_contact_email' => 'nullable|email|max:255',
'ap_contact_phone' => 'nullable|string|max:50',
// Invoice Settings
'payment_terms' => 'nullable|string|max:50',
'payment_instructions' => 'nullable|string|max:2000',
'invoice_footer' => 'nullable|string|max:2000',
'invoice_prefix' => 'nullable|string|max:10',
// Branding
'logo_path' => 'nullable|string|max:255',
'brand_colors' => 'nullable|array',
// Status
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_default'] = $request->boolean('is_default');
$validated['is_active'] = $request->boolean('is_active', true);
$dba = BusinessDba::create($validated);
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" created successfully.");
}
/**
* Show the form for editing the specified DBA.
*/
public function edit(Business $business, BusinessDba $dba): View
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
return view('seller.settings.dbas.edit', compact('business', 'dba'));
}
/**
* Update the specified DBA in storage.
*/
public function update(Request $request, Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
$validated = $request->validate([
// Identity
'trade_name' => 'required|string|max:255',
// Address
'address' => 'nullable|string|max:255',
'address_line_2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'state' => 'nullable|string|max:2',
'zip' => 'nullable|string|max:10',
// License
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string|max:255',
'license_expiration' => 'nullable|date',
// Bank Info
'bank_name' => 'nullable|string|max:255',
'bank_account_name' => 'nullable|string|max:255',
'bank_routing_number' => 'nullable|string|max:50',
'bank_account_number' => 'nullable|string|max:50',
'bank_account_type' => 'nullable|string|in:checking,savings',
// Tax
'tax_id' => 'nullable|string|max:50',
'tax_id_type' => 'nullable|string|in:ein,ssn',
// Contacts
'primary_contact_name' => 'nullable|string|max:255',
'primary_contact_email' => 'nullable|email|max:255',
'primary_contact_phone' => 'nullable|string|max:50',
'ap_contact_name' => 'nullable|string|max:255',
'ap_contact_email' => 'nullable|email|max:255',
'ap_contact_phone' => 'nullable|string|max:50',
// Invoice Settings
'payment_terms' => 'nullable|string|max:50',
'payment_instructions' => 'nullable|string|max:2000',
'invoice_footer' => 'nullable|string|max:2000',
'invoice_prefix' => 'nullable|string|max:10',
// Branding
'logo_path' => 'nullable|string|max:255',
'brand_colors' => 'nullable|array',
// Status
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
$validated['is_default'] = $request->boolean('is_default');
$validated['is_active'] = $request->boolean('is_active', true);
// Don't overwrite encrypted fields if left blank (preserve existing values)
$encryptedFields = ['bank_routing_number', 'bank_account_number', 'tax_id'];
foreach ($encryptedFields as $field) {
if (empty($validated[$field])) {
unset($validated[$field]);
}
}
$dba->update($validated);
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" updated successfully.");
}
/**
* Remove the specified DBA from storage.
*/
public function destroy(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
// Check if this is the only active DBA
$activeCount = $business->dbas()->where('is_active', true)->count();
if ($activeCount <= 1 && $dba->is_active) {
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('error', 'You cannot delete the only active DBA. Create another DBA first or deactivate this one.');
}
$tradeName = $dba->trade_name;
$dba->delete();
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$tradeName}\" deleted successfully.");
}
/**
* Set the specified DBA as the default for the business.
*/
public function setDefault(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
$dba->markAsDefault();
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "\"{$dba->trade_name}\" is now your default DBA for invoices.");
}
/**
* Toggle the active status of a DBA.
*/
public function toggleActive(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
// Prevent deactivating if it's the only active DBA
if ($dba->is_active) {
$activeCount = $business->dbas()->where('is_active', true)->count();
if ($activeCount <= 1) {
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('error', 'You cannot deactivate the only active DBA.');
}
}
$dba->update(['is_active' => ! $dba->is_active]);
$status = $dba->is_active ? 'activated' : 'deactivated';
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" has been {$status}.");
}
}

View File

@@ -531,6 +531,47 @@ class Business extends Model implements AuditableContract
return $this->hasMany(Brand::class);
}
// =========================================================================
// DBA (Doing Business As) Relationships
// =========================================================================
/**
* Get all DBAs for this business.
*/
public function dbas(): HasMany
{
return $this->hasMany(BusinessDba::class);
}
/**
* Get active DBAs for this business.
*/
public function activeDbas(): HasMany
{
return $this->hasMany(BusinessDba::class)->where('is_active', true);
}
/**
* Get the default DBA for this business.
*/
public function defaultDba(): HasOne
{
return $this->hasOne(BusinessDba::class)->where('is_default', true);
}
/**
* Get DBA for invoice generation.
* Priority: explicit dba_id > default DBA > first active DBA > null
*/
public function getDbaForInvoice(?int $dbaId = null): ?BusinessDba
{
if ($dbaId) {
return $this->dbas()->find($dbaId);
}
return $this->defaultDba ?? $this->activeDbas()->first();
}
public function brandAiProfiles(): HasMany
{
return $this->hasMany(BrandAiProfile::class);

250
app/Models/BusinessDba.php Normal file
View File

@@ -0,0 +1,250 @@
<?php
namespace App\Models;
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 Illuminate\Support\Str;
use OwenIt\Auditing\Contracts\Auditable;
class BusinessDba extends Model implements Auditable
{
use BelongsToBusinessDirectly;
use HasFactory;
use \OwenIt\Auditing\Auditable;
use SoftDeletes;
protected $table = 'business_dbas';
protected $fillable = [
'business_id',
'trade_name',
'slug',
// Address
'address',
'address_line_2',
'city',
'state',
'zip',
// License
'license_number',
'license_type',
'license_expiration',
// Bank Info
'bank_name',
'bank_account_name',
'bank_routing_number',
'bank_account_number',
'bank_account_type',
// Tax
'tax_id',
'tax_id_type',
// Contacts
'primary_contact_name',
'primary_contact_email',
'primary_contact_phone',
'ap_contact_name',
'ap_contact_email',
'ap_contact_phone',
// Invoice Settings
'payment_terms',
'payment_instructions',
'invoice_footer',
'invoice_prefix',
// Branding
'logo_path',
'brand_colors',
// Status
'is_default',
'is_active',
];
protected $casts = [
'brand_colors' => 'array',
'is_default' => 'boolean',
'is_active' => 'boolean',
'license_expiration' => 'date',
// Encrypted fields
'bank_routing_number' => 'encrypted',
'bank_account_number' => 'encrypted',
'tax_id' => 'encrypted',
];
/**
* Fields to exclude from audit logging (sensitive data)
*/
protected array $auditExclude = [
'bank_routing_number',
'bank_account_number',
'tax_id',
];
// =========================================================================
// Relationships
// =========================================================================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function invoices(): HasMany
{
return $this->hasMany(\App\Models\Crm\CrmInvoice::class, 'dba_id');
}
public function orders(): HasMany
{
return $this->hasMany(Order::class, 'seller_dba_id');
}
// =========================================================================
// Scopes
// =========================================================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
// =========================================================================
// Accessors
// =========================================================================
/**
* Get the full formatted address.
*/
public function getFullAddressAttribute(): string
{
$parts = array_filter([
$this->address,
$this->address_line_2,
]);
$cityStateZip = trim(
($this->city ?? '').
($this->city && $this->state ? ', ' : '').
($this->state ?? '').' '.
($this->zip ?? '')
);
if ($cityStateZip) {
$parts[] = $cityStateZip;
}
return implode("\n", $parts);
}
/**
* Get masked bank account number (last 4 digits).
*/
public function getMaskedAccountNumberAttribute(): ?string
{
if (! $this->bank_account_number) {
return null;
}
return '****'.substr($this->bank_account_number, -4);
}
/**
* Get masked tax ID (last 4 digits).
*/
public function getMaskedTaxIdAttribute(): ?string
{
if (! $this->tax_id) {
return null;
}
return '***-**-'.substr($this->tax_id, -4);
}
// =========================================================================
// Methods
// =========================================================================
/**
* Mark this DBA as the default for the business.
*/
public function markAsDefault(): void
{
// Clear default from other DBAs for this business
static::where('business_id', $this->business_id)
->where('id', '!=', $this->id)
->update(['is_default' => false]);
$this->update(['is_default' => true]);
}
/**
* Get display info for invoices/orders.
*/
public function getDisplayInfo(): array
{
return [
'name' => $this->trade_name,
'address' => $this->full_address,
'license' => $this->license_number,
'logo' => $this->logo_path,
'payment_terms' => $this->payment_terms,
'payment_instructions' => $this->payment_instructions,
'invoice_footer' => $this->invoice_footer,
'primary_contact' => [
'name' => $this->primary_contact_name,
'email' => $this->primary_contact_email,
'phone' => $this->primary_contact_phone,
],
'ap_contact' => [
'name' => $this->ap_contact_name,
'email' => $this->ap_contact_email,
'phone' => $this->ap_contact_phone,
],
];
}
// =========================================================================
// Boot
// =========================================================================
protected static function boot()
{
parent::boot();
// Auto-generate slug on creation
static::creating(function ($dba) {
if (empty($dba->slug)) {
$dba->slug = Str::slug($dba->trade_name);
// Ensure unique
$original = $dba->slug;
$counter = 1;
while (static::withTrashed()->where('slug', $dba->slug)->exists()) {
$dba->slug = $original.'-'.$counter++;
}
}
});
// Ensure only one default per business
static::saving(function ($dba) {
if ($dba->is_default && $dba->isDirty('is_default')) {
static::where('business_id', $dba->business_id)
->where('id', '!=', $dba->id ?? 0)
->update(['is_default' => false]);
}
});
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models\Crm;
use App\Models\Accounting\ArInvoice;
use App\Models\Activity;
use App\Models\Business;
use App\Models\BusinessDba;
use App\Models\BusinessLocation;
use App\Models\Contact;
use App\Models\Order;
@@ -55,6 +56,7 @@ class CrmInvoice extends Model
protected $fillable = [
'business_id',
'dba_id',
'account_id',
'location_id',
'contact_id',
@@ -110,6 +112,14 @@ class CrmInvoice extends Model
return $this->belongsTo(Business::class);
}
/**
* The DBA (trade name) used for this invoice.
*/
public function dba(): BelongsTo
{
return $this->belongsTo(BusinessDba::class, 'dba_id');
}
public function account(): BelongsTo
{
return $this->belongsTo(Business::class, 'account_id');
@@ -400,4 +410,45 @@ class CrmInvoice extends Model
return $prefix.str_pad($number, 5, '0', STR_PAD_LEFT);
}
/**
* Get seller display information for the invoice.
* Prioritizes DBA if set, otherwise falls back to business defaults.
*/
public function getSellerDisplayInfo(): array
{
if ($this->dba_id && $this->dba) {
return $this->dba->getDisplayInfo();
}
// Fall back to business info
$business = $this->business;
return [
'name' => $business->dba_name ?: $business->name,
'address' => implode("\n", array_filter([
$business->invoice_payable_address ?? $business->physical_address,
trim(
($business->invoice_payable_city ?? $business->physical_city ?? '').
($business->invoice_payable_state ?? $business->physical_state ? ', '.($business->invoice_payable_state ?? $business->physical_state) : '').' '.
($business->invoice_payable_zipcode ?? $business->physical_zipcode ?? '')
),
])),
'license' => $business->license_number,
'logo' => null,
'payment_terms' => null,
'payment_instructions' => $business->order_invoice_footer,
'invoice_footer' => $business->order_invoice_footer,
'primary_contact' => [
'name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')),
'email' => $business->primary_contact_email ?? $business->business_email,
'phone' => $business->primary_contact_phone ?? $business->business_phone,
],
'ap_contact' => [
'name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')),
'email' => $business->ap_contact_email,
'phone' => $business->ap_contact_phone,
],
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('business_dbas', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained('businesses')->onDelete('cascade');
// Identity
$table->string('trade_name');
$table->string('slug')->unique();
// Address
$table->string('address')->nullable();
$table->string('address_line_2')->nullable();
$table->string('city')->nullable();
$table->string('state', 2)->nullable();
$table->string('zip', 10)->nullable();
// License
$table->string('license_number')->nullable();
$table->string('license_type')->nullable();
$table->date('license_expiration')->nullable();
// Bank Info (encrypted at model level)
$table->string('bank_name')->nullable();
$table->string('bank_account_name')->nullable();
$table->text('bank_routing_number')->nullable();
$table->text('bank_account_number')->nullable();
$table->string('bank_account_type', 50)->nullable();
// Tax
$table->text('tax_id')->nullable();
$table->string('tax_id_type', 50)->nullable();
// Contacts
$table->string('primary_contact_name')->nullable();
$table->string('primary_contact_email')->nullable();
$table->string('primary_contact_phone', 50)->nullable();
$table->string('ap_contact_name')->nullable();
$table->string('ap_contact_email')->nullable();
$table->string('ap_contact_phone', 50)->nullable();
// Invoice Settings
$table->string('payment_terms', 50)->nullable();
$table->text('payment_instructions')->nullable();
$table->text('invoice_footer')->nullable();
$table->string('invoice_prefix', 10)->nullable();
// Branding
$table->string('logo_path')->nullable();
$table->jsonb('brand_colors')->nullable();
// Status
$table->boolean('is_default')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('business_id');
$table->index(['business_id', 'is_default']);
$table->index('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('business_dbas');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('crm_invoices', function (Blueprint $table) {
$table->foreignId('dba_id')
->nullable()
->after('business_id')
->constrained('business_dbas')
->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('crm_invoices', function (Blueprint $table) {
$table->dropForeign(['dba_id']);
$table->dropColumn('dba_id');
});
}
};

View File

@@ -23,6 +23,7 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_HOST" value="pgsql"/>
<env name="DB_PORT" value="5432"/>
<env name="DB_DATABASE" value="testing"/>

View File

@@ -24,12 +24,12 @@
@else
<span class="badge badge-ghost text-neutral border-base-300 badge-sm">Inactive</span>
@endif
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-primary btn-sm gap-1">
<div class="dropdown dropdown-end" x-data>
<div tabindex="0" role="button" class="btn btn-primary btn-sm gap-1">
<span class="icon-[heroicons--bolt] size-4"></span>
Actions
<span class="icon-[heroicons--chevron-down] size-3"></span>
</label>
</div>
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-xl w-48 border border-base-200">
<li>
<a href="{{ route('seller.business.crm.accounts.edit', [$business->slug, $account->slug]) }}" class="gap-2 text-sm">
@@ -44,7 +44,7 @@
</a>
</li>
<li>
<button @click="$dispatch('open-create-opportunity-modal', { account_id: {{ $account->id }}, account_locked: true })" class="gap-2 text-sm">
<button type="button" @click="$dispatch('open-create-opportunity-modal', { account_id: {{ $account->id }}, account_locked: true })" class="gap-2 text-sm w-full text-left">
<span class="icon-[heroicons--sparkles] size-4 text-warning"></span>
New Opportunity
</button>
@@ -56,13 +56,13 @@
</a>
</li>
<li>
<button @click="document.getElementById('note-section').scrollIntoView({behavior:'smooth'})" class="gap-2 text-sm">
<button type="button" @click="document.getElementById('note-section').scrollIntoView({behavior:'smooth'})" class="gap-2 text-sm w-full text-left">
<span class="icon-[heroicons--pencil] size-4 text-base-content/60"></span>
Add Note
</button>
</li>
<li>
<button @click="$dispatch('open-send-menu-modal', { customer_id: {{ $account->id }}, customer_locked: true })" class="gap-2 text-sm">
<button type="button" @click="$dispatch('open-send-menu-modal', { customer_id: {{ $account->id }}, customer_locked: true })" class="gap-2 text-sm w-full text-left">
<span class="icon-[heroicons--paper-airplane] size-4 text-primary"></span>
Send Menu
</button>

View File

@@ -0,0 +1,346 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Add Trade Name</h1>
<p class="text-sm text-base-content/60 mt-1">Create a new DBA for your business.</p>
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.settings.index', $business->slug) }}">Settings</a></li>
<li><a href="{{ route('seller.business.settings.dbas.index', $business->slug) }}">Trade Names</a></li>
<li class="opacity-60">Add</li>
</ul>
</div>
</div>
<form method="POST" action="{{ route('seller.business.settings.dbas.store', $business->slug) }}" class="space-y-6">
@csrf
<!-- Basic Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--building-office-2] size-5 text-primary"></span>
Basic Information
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Trade Name <span class="text-error">*</span></span>
</label>
<input type="text" name="trade_name" value="{{ old('trade_name') }}" required
class="input input-bordered w-full @error('trade_name') input-error @enderror"
placeholder="e.g., Acme Cannabis LLC" />
@error('trade_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_default" value="1" {{ old('is_default') ? 'checked' : '' }} class="checkbox checkbox-primary" />
<div>
<span class="label-text font-medium">Set as Default</span>
<p class="text-xs text-base-content/60">Use this DBA for new invoices by default</p>
</div>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', true) ? 'checked' : '' }} class="checkbox checkbox-primary" />
<div>
<span class="label-text font-medium">Active</span>
<p class="text-xs text-base-content/60">Available for use on invoices and orders</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Address -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--map-pin] size-5 text-primary"></span>
Business Address
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Street Address</span></label>
<input type="text" name="address" value="{{ old('address') }}"
class="input input-bordered w-full @error('address') input-error @enderror"
placeholder="123 Main Street" />
@error('address')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Address Line 2</span></label>
<input type="text" name="address_line_2" value="{{ old('address_line_2') }}"
class="input input-bordered w-full"
placeholder="Suite 100" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">City</span></label>
<input type="text" name="city" value="{{ old('city') }}"
class="input input-bordered w-full"
placeholder="Phoenix" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">State</span></label>
<input type="text" name="state" value="{{ old('state') }}" maxlength="2"
class="input input-bordered w-full uppercase"
placeholder="AZ" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">ZIP Code</span></label>
<input type="text" name="zip" value="{{ old('zip') }}" maxlength="10"
class="input input-bordered w-full"
placeholder="85001" />
</div>
</div>
</div>
</div>
</div>
<!-- License Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--identification] size-5 text-primary"></span>
License Information
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">License Number</span></label>
<input type="text" name="license_number" value="{{ old('license_number') }}"
class="input input-bordered w-full"
placeholder="00000123DCUP00730088" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">License Type</span></label>
<input type="text" name="license_type" value="{{ old('license_type') }}"
class="input input-bordered w-full"
placeholder="Dual (Medical + Recreational)" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Expiration Date</span></label>
<input type="date" name="license_expiration" value="{{ old('license_expiration') }}"
class="input input-bordered w-full" />
</div>
</div>
</div>
</div>
<!-- Banking Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--banknotes] size-5 text-primary"></span>
Banking Information
<span class="badge badge-ghost badge-sm">Encrypted</span>
</h2>
<p class="text-sm text-base-content/60 mb-4">Bank account details for payment processing. This information is encrypted at rest.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Bank Name</span></label>
<input type="text" name="bank_name" value="{{ old('bank_name') }}"
class="input input-bordered w-full"
placeholder="Partner Colorado Credit Union" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Account Name</span></label>
<input type="text" name="bank_account_name" value="{{ old('bank_account_name') }}"
class="input input-bordered w-full"
placeholder="Acme Cannabis LLC" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Routing Number</span></label>
<input type="text" name="bank_routing_number" value="{{ old('bank_routing_number') }}"
class="input input-bordered w-full font-mono"
placeholder="102001017" maxlength="9" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Account Number</span></label>
<input type="text" name="bank_account_number" value="{{ old('bank_account_number') }}"
class="input input-bordered w-full font-mono"
placeholder="1234567890" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Account Type</span></label>
<select name="bank_account_type" class="select select-bordered w-full">
<option value="">Select type...</option>
<option value="checking" {{ old('bank_account_type') === 'checking' ? 'selected' : '' }}>Checking</option>
<option value="savings" {{ old('bank_account_type') === 'savings' ? 'selected' : '' }}>Savings</option>
</select>
</div>
</div>
</div>
</div>
<!-- Tax Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--document-text] size-5 text-primary"></span>
Tax Information
<span class="badge badge-ghost badge-sm">Encrypted</span>
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Tax ID (EIN/SSN)</span></label>
<input type="text" name="tax_id" value="{{ old('tax_id') }}"
class="input input-bordered w-full font-mono"
placeholder="XX-XXXXXXX" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Tax ID Type</span></label>
<select name="tax_id_type" class="select select-bordered w-full">
<option value="">Select type...</option>
<option value="ein" {{ old('tax_id_type') === 'ein' ? 'selected' : '' }}>EIN (Employer Identification Number)</option>
<option value="ssn" {{ old('tax_id_type') === 'ssn' ? 'selected' : '' }}>SSN (Social Security Number)</option>
</select>
</div>
</div>
</div>
</div>
<!-- Contacts -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--users] size-5 text-primary"></span>
Contacts
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Primary Contact -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-base-content/70 border-b border-base-200 pb-2">Primary Contact</h3>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Name</span></label>
<input type="text" name="primary_contact_name" value="{{ old('primary_contact_name') }}"
class="input input-bordered w-full"
placeholder="John Smith" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Email</span></label>
<input type="email" name="primary_contact_email" value="{{ old('primary_contact_email') }}"
class="input input-bordered w-full"
placeholder="john@example.com" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Phone</span></label>
<input type="tel" name="primary_contact_phone" value="{{ old('primary_contact_phone') }}"
class="input input-bordered w-full"
placeholder="(555) 123-4567" />
</div>
</div>
<!-- AP Contact -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-base-content/70 border-b border-base-200 pb-2">Accounts Payable Contact</h3>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Name</span></label>
<input type="text" name="ap_contact_name" value="{{ old('ap_contact_name') }}"
class="input input-bordered w-full"
placeholder="Jane Doe" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Email</span></label>
<input type="email" name="ap_contact_email" value="{{ old('ap_contact_email') }}"
class="input input-bordered w-full"
placeholder="ap@example.com" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Phone</span></label>
<input type="tel" name="ap_contact_phone" value="{{ old('ap_contact_phone') }}"
class="input input-bordered w-full"
placeholder="(555) 123-4567" />
</div>
</div>
</div>
</div>
</div>
<!-- Invoice Settings -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--document-currency-dollar] size-5 text-primary"></span>
Invoice Settings
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Payment Terms</span></label>
<input type="text" name="payment_terms" value="{{ old('payment_terms') }}"
class="input input-bordered w-full"
placeholder="Net 30" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Invoice Prefix</span></label>
<input type="text" name="invoice_prefix" value="{{ old('invoice_prefix') }}" maxlength="10"
class="input input-bordered w-full font-mono"
placeholder="INV-" />
<label class="label"><span class="label-text-alt">Prefix for invoice numbers (e.g., INV-, ACME-)</span></label>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Payment Instructions</span></label>
<textarea name="payment_instructions" rows="3"
class="textarea textarea-bordered w-full"
placeholder="Wire instructions, payment portal URL, or other payment details...">{{ old('payment_instructions') }}</textarea>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Invoice Footer</span></label>
<textarea name="invoice_footer" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Thank you for your business!">{{ old('invoice_footer') }}</textarea>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.settings.dbas.index', $business->slug) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[heroicons--check] size-4"></span>
Create Trade Name
</button>
</div>
</form>
@endsection

View File

@@ -0,0 +1,407 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Edit Trade Name</h1>
<p class="text-sm text-base-content/60 mt-1">Update DBA information for {{ $dba->trade_name }}.</p>
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.settings.index', $business->slug) }}">Settings</a></li>
<li><a href="{{ route('seller.business.settings.dbas.index', $business->slug) }}">Trade Names</a></li>
<li class="opacity-60">Edit</li>
</ul>
</div>
</div>
<form method="POST" action="{{ route('seller.business.settings.dbas.update', [$business->slug, $dba->id]) }}" class="space-y-6">
@csrf
@method('PUT')
<!-- Basic Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--building-office-2] size-5 text-primary"></span>
Basic Information
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Trade Name <span class="text-error">*</span></span>
</label>
<input type="text" name="trade_name" value="{{ old('trade_name', $dba->trade_name) }}" required
class="input input-bordered w-full @error('trade_name') input-error @enderror"
placeholder="e.g., Acme Cannabis LLC" />
@error('trade_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_default" value="1" {{ old('is_default', $dba->is_default) ? 'checked' : '' }} class="checkbox checkbox-primary" />
<div>
<span class="label-text font-medium">Set as Default</span>
<p class="text-xs text-base-content/60">Use this DBA for new invoices by default</p>
</div>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', $dba->is_active) ? 'checked' : '' }} class="checkbox checkbox-primary" />
<div>
<span class="label-text font-medium">Active</span>
<p class="text-xs text-base-content/60">Available for use on invoices and orders</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Address -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--map-pin] size-5 text-primary"></span>
Business Address
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Street Address</span></label>
<input type="text" name="address" value="{{ old('address', $dba->address) }}"
class="input input-bordered w-full @error('address') input-error @enderror"
placeholder="123 Main Street" />
@error('address')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Address Line 2</span></label>
<input type="text" name="address_line_2" value="{{ old('address_line_2', $dba->address_line_2) }}"
class="input input-bordered w-full"
placeholder="Suite 100" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">City</span></label>
<input type="text" name="city" value="{{ old('city', $dba->city) }}"
class="input input-bordered w-full"
placeholder="Phoenix" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">State</span></label>
<input type="text" name="state" value="{{ old('state', $dba->state) }}" maxlength="2"
class="input input-bordered w-full uppercase"
placeholder="AZ" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">ZIP Code</span></label>
<input type="text" name="zip" value="{{ old('zip', $dba->zip) }}" maxlength="10"
class="input input-bordered w-full"
placeholder="85001" />
</div>
</div>
</div>
</div>
</div>
<!-- License Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--identification] size-5 text-primary"></span>
License Information
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">License Number</span></label>
<input type="text" name="license_number" value="{{ old('license_number', $dba->license_number) }}"
class="input input-bordered w-full"
placeholder="00000123DCUP00730088" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">License Type</span></label>
<input type="text" name="license_type" value="{{ old('license_type', $dba->license_type) }}"
class="input input-bordered w-full"
placeholder="Dual (Medical + Recreational)" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Expiration Date</span></label>
<input type="date" name="license_expiration" value="{{ old('license_expiration', $dba->license_expiration?->format('Y-m-d')) }}"
class="input input-bordered w-full" />
</div>
</div>
</div>
</div>
<!-- Banking Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--banknotes] size-5 text-primary"></span>
Banking Information
<span class="badge badge-ghost badge-sm">Encrypted</span>
</h2>
<p class="text-sm text-base-content/60 mb-4">Bank account details for payment processing. This information is encrypted at rest.</p>
@if($dba->bank_account_number)
<div class="alert bg-info/10 border-info/20 mb-4">
<span class="icon-[heroicons--information-circle] size-5 text-info"></span>
<span class="text-sm">Current account: <span class="font-mono">{{ $dba->masked_account_number }}</span> at {{ $dba->bank_name }}. Leave fields blank to keep current values.</span>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Bank Name</span></label>
<input type="text" name="bank_name" value="{{ old('bank_name', $dba->bank_name) }}"
class="input input-bordered w-full"
placeholder="Partner Colorado Credit Union" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Account Name</span></label>
<input type="text" name="bank_account_name" value="{{ old('bank_account_name', $dba->bank_account_name) }}"
class="input input-bordered w-full"
placeholder="Acme Cannabis LLC" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Routing Number</span></label>
<input type="text" name="bank_routing_number" value="{{ old('bank_routing_number') }}"
class="input input-bordered w-full font-mono"
placeholder="{{ $dba->bank_routing_number ? '(current value hidden)' : '102001017' }}" maxlength="9" />
<label class="label"><span class="label-text-alt">Leave blank to keep current value</span></label>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Account Number</span></label>
<input type="text" name="bank_account_number" value="{{ old('bank_account_number') }}"
class="input input-bordered w-full font-mono"
placeholder="{{ $dba->bank_account_number ? '(current value hidden)' : '1234567890' }}" />
<label class="label"><span class="label-text-alt">Leave blank to keep current value</span></label>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Account Type</span></label>
<select name="bank_account_type" class="select select-bordered w-full">
<option value="">Select type...</option>
<option value="checking" {{ old('bank_account_type', $dba->bank_account_type) === 'checking' ? 'selected' : '' }}>Checking</option>
<option value="savings" {{ old('bank_account_type', $dba->bank_account_type) === 'savings' ? 'selected' : '' }}>Savings</option>
</select>
</div>
</div>
</div>
</div>
<!-- Tax Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--document-text] size-5 text-primary"></span>
Tax Information
<span class="badge badge-ghost badge-sm">Encrypted</span>
</h2>
@if($dba->tax_id)
<div class="alert bg-info/10 border-info/20 mb-4">
<span class="icon-[heroicons--information-circle] size-5 text-info"></span>
<span class="text-sm">Current Tax ID: <span class="font-mono">{{ $dba->masked_tax_id }}</span>. Leave blank to keep current value.</span>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Tax ID (EIN/SSN)</span></label>
<input type="text" name="tax_id" value="{{ old('tax_id') }}"
class="input input-bordered w-full font-mono"
placeholder="{{ $dba->tax_id ? '(current value hidden)' : 'XX-XXXXXXX' }}" />
<label class="label"><span class="label-text-alt">Leave blank to keep current value</span></label>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Tax ID Type</span></label>
<select name="tax_id_type" class="select select-bordered w-full">
<option value="">Select type...</option>
<option value="ein" {{ old('tax_id_type', $dba->tax_id_type) === 'ein' ? 'selected' : '' }}>EIN (Employer Identification Number)</option>
<option value="ssn" {{ old('tax_id_type', $dba->tax_id_type) === 'ssn' ? 'selected' : '' }}>SSN (Social Security Number)</option>
</select>
</div>
</div>
</div>
</div>
<!-- Contacts -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--users] size-5 text-primary"></span>
Contacts
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Primary Contact -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-base-content/70 border-b border-base-200 pb-2">Primary Contact</h3>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Name</span></label>
<input type="text" name="primary_contact_name" value="{{ old('primary_contact_name', $dba->primary_contact_name) }}"
class="input input-bordered w-full"
placeholder="John Smith" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Email</span></label>
<input type="email" name="primary_contact_email" value="{{ old('primary_contact_email', $dba->primary_contact_email) }}"
class="input input-bordered w-full"
placeholder="john@example.com" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Phone</span></label>
<input type="tel" name="primary_contact_phone" value="{{ old('primary_contact_phone', $dba->primary_contact_phone) }}"
class="input input-bordered w-full"
placeholder="(555) 123-4567" />
</div>
</div>
<!-- AP Contact -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-base-content/70 border-b border-base-200 pb-2">Accounts Payable Contact</h3>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Name</span></label>
<input type="text" name="ap_contact_name" value="{{ old('ap_contact_name', $dba->ap_contact_name) }}"
class="input input-bordered w-full"
placeholder="Jane Doe" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Email</span></label>
<input type="email" name="ap_contact_email" value="{{ old('ap_contact_email', $dba->ap_contact_email) }}"
class="input input-bordered w-full"
placeholder="ap@example.com" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Phone</span></label>
<input type="tel" name="ap_contact_phone" value="{{ old('ap_contact_phone', $dba->ap_contact_phone) }}"
class="input input-bordered w-full"
placeholder="(555) 123-4567" />
</div>
</div>
</div>
</div>
</div>
<!-- Invoice Settings -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title text-base flex items-center gap-2 mb-4">
<span class="icon-[heroicons--document-currency-dollar] size-5 text-primary"></span>
Invoice Settings
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Payment Terms</span></label>
<input type="text" name="payment_terms" value="{{ old('payment_terms', $dba->payment_terms) }}"
class="input input-bordered w-full"
placeholder="Net 30" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Invoice Prefix</span></label>
<input type="text" name="invoice_prefix" value="{{ old('invoice_prefix', $dba->invoice_prefix) }}" maxlength="10"
class="input input-bordered w-full font-mono"
placeholder="INV-" />
<label class="label"><span class="label-text-alt">Prefix for invoice numbers (e.g., INV-, ACME-)</span></label>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Payment Instructions</span></label>
<textarea name="payment_instructions" rows="3"
class="textarea textarea-bordered w-full"
placeholder="Wire instructions, payment portal URL, or other payment details...">{{ old('payment_instructions', $dba->payment_instructions) }}</textarea>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">Invoice Footer</span></label>
<textarea name="invoice_footer" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Thank you for your business!">{{ old('invoice_footer', $dba->invoice_footer) }}</textarea>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-between">
<div>
@if(!$dba->is_default)
<button type="button" onclick="confirmDelete()" class="btn btn-ghost text-error gap-2">
<span class="icon-[heroicons--trash] size-4"></span>
Delete Trade Name
</button>
@endif
</div>
<div class="flex items-center gap-4">
<a href="{{ route('seller.business.settings.dbas.index', $business->slug) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[heroicons--check] size-4"></span>
Save Changes
</button>
</div>
</div>
</form>
<!-- Delete Confirmation Modal -->
@if(!$dba->is_default)
<dialog id="delete_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Delete Trade Name</h3>
<p class="text-base-content/70">Are you sure you want to delete <strong>{{ $dba->trade_name }}</strong>?</p>
<p class="text-sm text-warning mt-2">This action cannot be undone. Any invoices using this DBA will retain the historical information.</p>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-ghost">Cancel</button>
</form>
<form method="POST" action="{{ route('seller.business.settings.dbas.destroy', [$business->slug, $dba->id]) }}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-error gap-2">
<span class="icon-[heroicons--trash] size-4"></span>
Delete
</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
function confirmDelete() {
delete_modal.showModal();
}
</script>
@endif
@endsection

View File

@@ -0,0 +1,254 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Trade Names (DBAs)</h1>
<p class="text-sm text-base-content/60 mt-1">Manage "Doing Business As" names for invoicing, licensing, and customer-facing documents.</p>
</div>
<div class="flex items-center gap-4">
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.settings.index', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Trade Names</li>
</ul>
</div>
<a href="{{ route('seller.business.settings.dbas.create', $business->slug) }}" class="btn btn-primary gap-2">
<span class="icon-[heroicons--plus] size-4"></span>
Add Trade Name
</a>
</div>
</div>
<!-- Info Alert -->
<div class="alert bg-info/10 border-info/20 mb-6">
<span class="icon-[heroicons--information-circle] size-5 text-info"></span>
<div class="text-sm">
<p class="font-semibold">What are Trade Names (DBAs)?</p>
<p class="text-base-content/70">
A DBA (Doing Business As) allows your company to operate under different trade names.
Each DBA can have its own address, license, bank account, and invoice branding.
The <span class="badge badge-success badge-xs">Default</span> DBA is used automatically for new invoices.
</p>
</div>
</div>
<!-- DBAs List -->
@if($dbas->count() > 0)
<div class="space-y-4">
@foreach($dbas as $dba)
<div class="card bg-base-100 border border-base-300 {{ !$dba->is_active ? 'opacity-60' : '' }}">
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<!-- Logo or Placeholder -->
@if($dba->logo_path)
<div class="avatar">
<div class="w-14 h-14 rounded-lg bg-base-200">
<img src="{{ Storage::url($dba->logo_path) }}" alt="{{ $dba->trade_name }}" class="object-contain" />
</div>
</div>
@else
<div class="p-3 bg-base-200 rounded-lg">
<span class="icon-[heroicons--building-office-2] size-8 text-base-content/50"></span>
</div>
@endif
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-lg">{{ $dba->trade_name }}</h3>
@if($dba->is_default)
<span class="badge badge-success badge-sm gap-1">
<span class="icon-[heroicons--check-circle] size-3"></span>
Default
</span>
@endif
@if(!$dba->is_active)
<span class="badge badge-ghost badge-sm">Inactive</span>
@endif
</div>
<!-- DBA Details Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-3 text-sm">
<!-- Address -->
<div>
<div class="flex items-center gap-1 text-base-content/60 mb-1">
<span class="icon-[heroicons--map-pin] size-4"></span>
Address
</div>
@if($dba->address)
<div class="text-base-content">
{{ $dba->address }}<br>
@if($dba->address_line_2){{ $dba->address_line_2 }}<br>@endif
@if($dba->city){{ $dba->city }}, @endif{{ $dba->state }} {{ $dba->zip }}
</div>
@else
<span class="text-base-content/40">Not set</span>
@endif
</div>
<!-- License -->
<div>
<div class="flex items-center gap-1 text-base-content/60 mb-1">
<span class="icon-[heroicons--identification] size-4"></span>
License
</div>
@if($dba->license_number)
<div class="text-base-content">
{{ $dba->license_number }}
@if($dba->license_type)
<br><span class="text-base-content/60">{{ $dba->license_type }}</span>
@endif
@if($dba->license_expiration)
<br><span class="text-xs {{ $dba->license_expiration->isPast() ? 'text-error' : ($dba->license_expiration->diffInDays(now()) <= 30 ? 'text-warning' : 'text-base-content/60') }}">
Exp: {{ $dba->license_expiration->format('M j, Y') }}
</span>
@endif
</div>
@else
<span class="text-base-content/40">Not set</span>
@endif
</div>
<!-- Banking -->
<div>
<div class="flex items-center gap-1 text-base-content/60 mb-1">
<span class="icon-[heroicons--banknotes] size-4"></span>
Banking
</div>
@if($dba->bank_name)
<div class="text-base-content">
{{ $dba->bank_name }}
@if($dba->masked_account_number)
<br><span class="text-base-content/60 font-mono">{{ $dba->masked_account_number }}</span>
@endif
</div>
@else
<span class="text-base-content/40">Not set</span>
@endif
</div>
</div>
<!-- Contacts Row -->
@if($dba->primary_contact_name || $dba->ap_contact_name)
<div class="flex flex-wrap gap-4 mt-3 pt-3 border-t border-base-200 text-sm">
@if($dba->primary_contact_name)
<div class="flex items-center gap-2 text-base-content/70">
<span class="icon-[heroicons--user] size-4"></span>
<span>Primary: {{ $dba->primary_contact_name }}</span>
</div>
@endif
@if($dba->ap_contact_name)
<div class="flex items-center gap-2 text-base-content/70">
<span class="icon-[heroicons--currency-dollar] size-4"></span>
<span>AP: {{ $dba->ap_contact_name }}</span>
</div>
@endif
</div>
@endif
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
@if(!$dba->is_default && $dba->is_active)
<form method="POST" action="{{ route('seller.business.settings.dbas.set-default', [$business->slug, $dba->id]) }}" class="inline">
@csrf
<button type="submit" class="btn btn-sm btn-ghost gap-2 tooltip" data-tip="Set as Default">
<span class="icon-[heroicons--star] size-4"></span>
</button>
</form>
@endif
<a href="{{ route('seller.business.settings.dbas.edit', [$business->slug, $dba->id]) }}"
class="btn btn-sm btn-ghost gap-2">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-ghost">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow border border-base-300">
<li>
<form method="POST" action="{{ route('seller.business.settings.dbas.toggle-active', [$business->slug, $dba->id]) }}">
@csrf
<button type="submit" class="w-full flex items-center gap-2">
@if($dba->is_active)
<span class="icon-[heroicons--pause-circle] size-4"></span>
Deactivate
@else
<span class="icon-[heroicons--play-circle] size-4"></span>
Activate
@endif
</button>
</form>
</li>
@if(!$dba->is_default)
<li>
<button type="button"
onclick="confirmDelete({{ $dba->id }}, '{{ addslashes($dba->trade_name) }}')"
class="text-error">
<span class="icon-[heroicons--trash] size-4"></span>
Delete
</button>
</li>
@endif
</ul>
</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
@else
<!-- Empty State -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<div class="text-center py-12 text-base-content/60">
<span class="icon-[heroicons--building-office-2] size-16 mx-auto mb-4 opacity-30"></span>
<h3 class="font-semibold text-lg mb-2">No Trade Names Yet</h3>
<p class="text-sm mb-4">Add a DBA to customize your invoices and operate under different trade names.</p>
<a href="{{ route('seller.business.settings.dbas.create', $business->slug) }}" class="btn btn-primary gap-2">
<span class="icon-[heroicons--plus] size-4"></span>
Add First Trade Name
</a>
</div>
</div>
</div>
@endif
<!-- Delete Confirmation Modal -->
<dialog id="delete_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Delete Trade Name</h3>
<p class="text-base-content/70">Are you sure you want to delete <strong id="delete_dba_name"></strong>?</p>
<p class="text-sm text-warning mt-2">This action cannot be undone. Any invoices using this DBA will retain the historical information.</p>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-ghost">Cancel</button>
</form>
<form id="delete_form" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-error gap-2">
<span class="icon-[heroicons--trash] size-4"></span>
Delete
</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
function confirmDelete(dbaId, dbaName) {
document.getElementById('delete_dba_name').textContent = dbaName;
document.getElementById('delete_form').action = "{{ url('/s/' . $business->slug . '/settings/dbas') }}/" + dbaId;
delete_modal.showModal();
}
</script>
@endsection

View File

@@ -98,6 +98,22 @@
</div>
</a>
<!-- Trade Names (DBAs) -->
<a href="{{ route('seller.business.settings.dbas.index', $business->slug) }}"
class="card bg-base-100 border border-base-300 hover:border-primary hover:shadow-md transition-all">
<div class="card-body">
<div class="flex items-start gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<span class="icon-[heroicons--building-office-2] size-6 text-primary"></span>
</div>
<div>
<h2 class="card-title text-base">Trade Names (DBAs)</h2>
<p class="text-sm text-base-content/60">Manage "Doing Business As" names for invoices and licenses</p>
</div>
</div>
</div>
</a>
{{-- ═══════════════════════════════════════════════════════════════
BILLING & PAYMENTS
═══════════════════════════════════════════════════════════════ --}}

View File

@@ -1360,6 +1360,18 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::delete('/{user}', [\App\Http\Controllers\Seller\BrandManagerSettingsController::class, 'destroy'])->name('destroy');
});
// DBA (Doing Business As) Management
Route::prefix('dbas')->name('dbas.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'store'])->name('store');
Route::get('/{dba}/edit', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'edit'])->name('edit');
Route::put('/{dba}', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'update'])->name('update');
Route::delete('/{dba}', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'destroy'])->name('destroy');
Route::post('/{dba}/set-default', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'setDefault'])->name('set-default');
Route::post('/{dba}/toggle-active', [\App\Http\Controllers\Seller\Settings\DbaController::class, 'toggleActive'])->name('toggle-active');
});
// Category Management (under settings)
Route::prefix('categories')->name('categories.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\CategoryController::class, 'index'])->name('index');