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
407 lines
11 KiB
PHP
407 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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\HasMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
class Invoice extends Model
|
|
{
|
|
use HasFactory, SoftDeletes;
|
|
|
|
protected $fillable = [
|
|
'invoice_number',
|
|
'order_id',
|
|
'company_id',
|
|
'subtotal',
|
|
'tax',
|
|
'total',
|
|
'payment_status',
|
|
'amount_paid',
|
|
'amount_due',
|
|
'invoice_date',
|
|
'due_date',
|
|
'pdf_path',
|
|
'notes',
|
|
'approval_status',
|
|
'approved_at',
|
|
'approved_by',
|
|
'rejected_at',
|
|
'rejection_reason',
|
|
'current_negotiation_round',
|
|
'modification_deadline',
|
|
];
|
|
|
|
protected $casts = [
|
|
'subtotal' => 'decimal:2',
|
|
'tax' => 'decimal:2',
|
|
'total' => 'decimal:2',
|
|
'amount_paid' => 'decimal:2',
|
|
'amount_due' => 'decimal:2',
|
|
'invoice_date' => 'date',
|
|
'due_date' => 'date',
|
|
'approved_at' => 'datetime',
|
|
'rejected_at' => 'datetime',
|
|
'modification_deadline' => 'datetime',
|
|
];
|
|
|
|
/**
|
|
* Get the route key for the model.
|
|
*/
|
|
public function getRouteKeyName(): string
|
|
{
|
|
return 'invoice_number';
|
|
}
|
|
|
|
/**
|
|
* Get the order associated with the invoice.
|
|
*/
|
|
public function order(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Order::class);
|
|
}
|
|
|
|
/**
|
|
* Get the business (buyer) for the invoice.
|
|
*/
|
|
public function business(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Business::class);
|
|
}
|
|
|
|
/**
|
|
* Get the user who approved the invoice.
|
|
*/
|
|
public function approvedBy(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'approved_by');
|
|
}
|
|
|
|
/**
|
|
* Get all change records for this invoice's order.
|
|
*/
|
|
public function changes(): HasMany
|
|
{
|
|
return $this->hasMany(OrderChange::class, 'order_id', 'order_id');
|
|
}
|
|
|
|
/**
|
|
* Scope: Get unpaid invoices.
|
|
*/
|
|
public function scopeUnpaid($query)
|
|
{
|
|
return $query->where('payment_status', 'unpaid');
|
|
}
|
|
|
|
/**
|
|
* Scope: Get partially paid invoices.
|
|
*/
|
|
public function scopePartiallyPaid($query)
|
|
{
|
|
return $query->where('payment_status', 'partially_paid');
|
|
}
|
|
|
|
/**
|
|
* Scope: Get paid invoices.
|
|
*/
|
|
public function scopePaid($query)
|
|
{
|
|
return $query->where('payment_status', 'paid');
|
|
}
|
|
|
|
/**
|
|
* Scope: Get overdue invoices.
|
|
*/
|
|
public function scopeOverdue($query)
|
|
{
|
|
return $query->where('payment_status', '!=', 'paid')
|
|
->where('due_date', '<', now());
|
|
}
|
|
|
|
/**
|
|
* Check if invoice is overdue.
|
|
*/
|
|
public function isOverdue(): bool
|
|
{
|
|
return $this->payment_status !== 'paid' && $this->due_date->isPast();
|
|
}
|
|
|
|
/**
|
|
* Check if invoice is paid.
|
|
*/
|
|
public function isPaid(): bool
|
|
{
|
|
return $this->payment_status === 'paid';
|
|
}
|
|
|
|
/**
|
|
* Mark invoice as paid.
|
|
*/
|
|
public function markPaid(float $amount = null): bool
|
|
{
|
|
$amount = $amount ?? $this->amount_due;
|
|
|
|
$this->amount_paid = bcadd((string) $this->amount_paid, (string) $amount, 2);
|
|
$this->amount_due = bcsub((string) $this->total, (string) $this->amount_paid, 2);
|
|
|
|
if (bccomp((string) $this->amount_due, '0', 2) <= 0) {
|
|
$this->payment_status = 'paid';
|
|
$this->amount_due = 0;
|
|
} elseif (bccomp((string) $this->amount_paid, '0', 2) > 0) {
|
|
$this->payment_status = 'partially_paid';
|
|
}
|
|
|
|
$saved = $this->save();
|
|
|
|
if ($saved) {
|
|
// TODO: Notify buyer when notification service is implemented
|
|
// $invoiceNotificationService = app(\App\Services\InvoiceNotificationService::class);
|
|
// $invoiceNotificationService->paymentReceived($this, $amount);
|
|
|
|
// TODO: Notify sellers when notification service is implemented
|
|
// $sellerNotificationService = app(\App\Services\SellerNotificationService::class);
|
|
// $sellerNotificationService->paymentReceived($this, $amount);
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
/**
|
|
* Record a payment against this invoice.
|
|
*/
|
|
public function recordPayment(float $amount): bool
|
|
{
|
|
if ($amount <= 0) {
|
|
return false;
|
|
}
|
|
|
|
return $this->markPaid($amount);
|
|
}
|
|
|
|
/**
|
|
* Get the payment status badge color.
|
|
*/
|
|
public function getStatusColor(): string
|
|
{
|
|
return match($this->payment_status) {
|
|
'paid' => 'success',
|
|
'partially_paid' => 'warning',
|
|
'unpaid' => 'danger',
|
|
'overdue' => 'danger',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
// ========================================
|
|
// Invoice Approval Workflow Methods
|
|
// ========================================
|
|
|
|
/**
|
|
* Check if invoice is pending buyer approval.
|
|
*/
|
|
public function isPendingBuyerApproval(): bool
|
|
{
|
|
return $this->approval_status === 'pending_buyer_approval';
|
|
}
|
|
|
|
/**
|
|
* Check if invoice was approved by buyer.
|
|
*/
|
|
public function isApproved(): bool
|
|
{
|
|
return in_array($this->approval_status, ['buyer_approved', 'approved']);
|
|
}
|
|
|
|
/**
|
|
* Check if invoice was rejected.
|
|
*/
|
|
public function isRejected(): bool
|
|
{
|
|
return $this->approval_status === 'rejected';
|
|
}
|
|
|
|
/**
|
|
* Check if invoice has been modified by buyer.
|
|
*/
|
|
public function isBuyerModified(): bool
|
|
{
|
|
return $this->approval_status === 'buyer_modified';
|
|
}
|
|
|
|
/**
|
|
* Check if invoice has been modified by seller.
|
|
*/
|
|
public function isSellerModified(): bool
|
|
{
|
|
return $this->approval_status === 'seller_modified';
|
|
}
|
|
|
|
/**
|
|
* Check if buyer can edit this invoice.
|
|
*/
|
|
public function canBeEditedByBuyer(): bool
|
|
{
|
|
return in_array($this->approval_status, [
|
|
'pending_buyer_approval',
|
|
'seller_modified',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check if seller can review/modify this invoice.
|
|
*/
|
|
public function canBeReviewedBySeller(): bool
|
|
{
|
|
return $this->approval_status === 'buyer_modified';
|
|
}
|
|
|
|
/**
|
|
* Buyer approves the invoice without modifications.
|
|
*/
|
|
public function buyerApprove(User $buyer): bool
|
|
{
|
|
$this->approval_status = 'buyer_approved';
|
|
$this->approved_at = now();
|
|
$this->approved_by = $buyer->id;
|
|
|
|
$saved = $this->save();
|
|
|
|
if ($saved) {
|
|
// Proceed to manifest creation
|
|
$this->order->update([
|
|
'status' => 'ready_for_manifest',
|
|
'manifest_created_at' => now(),
|
|
]);
|
|
|
|
// TODO: Notify seller when notification service is implemented
|
|
// $invoiceNotificationService = app(\App\Services\InvoiceNotificationService::class);
|
|
// $invoiceNotificationService->invoiceApproved($this);
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
/**
|
|
* Buyer rejects the invoice.
|
|
*/
|
|
public function buyerReject(User $buyer, string $reason): bool
|
|
{
|
|
$this->approval_status = 'rejected';
|
|
$this->rejected_at = now();
|
|
$this->rejection_reason = $reason;
|
|
|
|
$saved = $this->save();
|
|
|
|
if ($saved) {
|
|
// Update order status to rejected
|
|
$this->order->update([
|
|
'status' => 'rejected',
|
|
'rejected_at' => now(),
|
|
'rejected_reason' => $reason,
|
|
]);
|
|
|
|
// TODO: Notify seller when notification service is implemented
|
|
// $invoiceNotificationService = app(\App\Services\InvoiceNotificationService::class);
|
|
// $invoiceNotificationService->invoiceRejected($this);
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
/**
|
|
* Buyer modifies the invoice (creates pending changes).
|
|
* Changes are recorded separately in OrderChange model.
|
|
*/
|
|
public function buyerModify(): bool
|
|
{
|
|
$this->approval_status = 'buyer_modified';
|
|
$saved = $this->save();
|
|
|
|
if ($saved) {
|
|
// TODO: Notify seller when notification service is implemented
|
|
// $invoiceNotificationService = app(\App\Services\InvoiceNotificationService::class);
|
|
// $invoiceNotificationService->invoiceModifiedByBuyer($this);
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
/**
|
|
* Seller approves buyer's modifications.
|
|
* This will apply the changes and move order to amendment_in_progress status.
|
|
*/
|
|
public function sellerApproveModifications(User $seller): bool
|
|
{
|
|
// Apply all approved changes via the modification service
|
|
$modificationService = app(\App\Services\OrderModificationService::class);
|
|
$modificationService->applyApprovedChanges($this);
|
|
|
|
$this->approval_status = 'approved';
|
|
$this->approved_at = now();
|
|
$this->approved_by = $seller->id;
|
|
$saved = $this->save();
|
|
|
|
if ($saved) {
|
|
// Move order to amendment_in_progress status
|
|
// Lab team needs to physically adjust the package
|
|
$this->order->update([
|
|
'status' => 'amendment_in_progress',
|
|
'amendment_in_progress_at' => now(),
|
|
]);
|
|
|
|
// TODO: Notify buyer when notification service is implemented
|
|
// $invoiceNotificationService = app(\App\Services\InvoiceNotificationService::class);
|
|
// $invoiceNotificationService->modificationsApproved($this);
|
|
|
|
// TODO: Notify lab team to make physical adjustments when notification service is implemented
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
/**
|
|
* Seller counter-modifies the invoice (seller makes their own changes).
|
|
*/
|
|
public function sellerCounterModify(): bool
|
|
{
|
|
$this->approval_status = 'seller_modified';
|
|
|
|
// Increment negotiation round
|
|
$modificationService = app(\App\Services\OrderModificationService::class);
|
|
$modificationService->incrementNegotiationRound($this);
|
|
|
|
$saved = $this->save();
|
|
|
|
if ($saved) {
|
|
// TODO: Notify buyer when notification service is implemented
|
|
// $invoiceNotificationService = app(\App\Services\InvoiceNotificationService::class);
|
|
// $invoiceNotificationService->invoiceModifiedBySeller($this);
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
/**
|
|
* Seller rejects buyer's modifications outright.
|
|
*/
|
|
public function sellerRejectModifications(User $seller, string $reason): bool
|
|
{
|
|
// Mark all pending changes as rejected
|
|
OrderChange::where('order_id', $this->order_id)
|
|
->where('negotiation_round', $this->current_negotiation_round)
|
|
->where('status', 'pending')
|
|
->update([
|
|
'status' => 'rejected',
|
|
'reviewed_at' => now(),
|
|
'reviewed_by' => $seller->id,
|
|
]);
|
|
|
|
return $this->buyerReject($seller, $reason);
|
|
}
|
|
}
|