Files
hub/app/Models/Invoice.php
Jon Leopard 7e5ea2bc10 checkpoint: marketplace and operational features complete, ready for Week 4 data migration
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
2025-10-15 11:17:15 -07:00

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);
}
}