feat(billing): renewals, coupons, VAT numbering, billing cycles and invoice payment tracking

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-19 16:37:56 +07:00
co-authored by Claude Sonnet 5.5
parent ea9a53f742
commit 25f23d541e
134 changed files with 4926 additions and 185 deletions
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Coupon extends Model
{
public const TYPE_PERCENT = 'percent';
public const TYPE_FIXED = 'fixed';
public const TYPES = [self::TYPE_PERCENT, self::TYPE_FIXED];
public const APPLIES_NEW = 'new';
public const APPLIES_RENEWAL = 'renewal';
public const APPLIES_ALL = 'all';
public const APPLIES_TO = [self::APPLIES_NEW, self::APPLIES_RENEWAL, self::APPLIES_ALL];
protected $guarded = [];
protected $casts = [
'value' => 'integer',
'max_discount' => 'integer',
'min_order' => 'integer',
'usage_limit' => 'integer',
'per_customer_limit' => 'integer',
'product_types' => 'array',
'product_ids' => 'array',
'billing_cycles' => 'array',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'is_active' => 'boolean',
];
public static function normalizeCode(string $code): string
{
return strtoupper(trim($code));
}
protected function code(): Attribute
{
return Attribute::set(fn (string $value) => self::normalizeCode($value));
}
public function redemptions(): HasMany
{
return $this->hasMany(CouponRedemption::class);
}
public function activeRedemptions(): HasMany
{
return $this->redemptions()->whereNull('released_at');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CouponRedemption extends Model
{
protected $guarded = [];
protected $casts = [
'amount' => 'integer',
'released_at' => 'datetime',
];
public function coupon(): BelongsTo
{
return $this->belongsTo(Coupon::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}
+4
View File
@@ -81,6 +81,10 @@ class Customer extends Authenticatable implements JWTSubject
'birthday',
'province_code',
'ward_code',
'tax_code',
'invoice_company',
'invoice_address',
'invoice_email',
];
/**
+21 -1
View File
@@ -4,11 +4,13 @@ namespace App\Models;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Concerns\Markable;
use App\Support\VatNumbering;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Invoice extends Model
{
@@ -23,12 +25,26 @@ class Invoice extends Model
'due_date' => 'date',
'paid_at' => 'datetime',
'auto_renew' => 'boolean',
'vat_issued_at' => 'date',
'buyer_snapshot' => 'array',
];
public static function newLookupCode(): string
{
return Str::upper(Str::random(10));
}
protected static function booted(): void
{
static::creating(function (Invoice $invoice) {
$invoice->date ??= now()->toDateString();
$invoice->lookup_code ??= static::newLookupCode();
});
static::updated(function (Invoice $invoice) {
if ($invoice->wasChanged('status')) {
VatNumbering::issueIfAuto($invoice);
}
});
}
@@ -40,7 +56,11 @@ class Invoice extends Model
/** The order that originated this invoice. */
public function clientPath(): string
{
return '/hoa-don/' . ($this->order?->order_num ?? $this->invoice_number);
$path = '/hoa-don/' . ($this->order?->order_num ?? $this->invoice_number);
return $this->order && (int) $this->invoice_type !== 1
? $path . '?invoice=' . $this->invoice_number
: $path;
}
/** @param iterable<Service> $services */
+1
View File
@@ -9,6 +9,7 @@ class InvoiceAttachment extends Model
{
public const TYPE_PAYMENT_PROOF = 'payment_proof';
public const TYPE_DEPLOYMENT = 'deployment';
public const TYPE_CONTRACT = 'contract';
public const UPLOAD_RULE = 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx|max:51200';
protected $table = 'invoice_attachments';
+7
View File
@@ -22,6 +22,13 @@ class Order extends Model
'paid_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (Order $order) {
$order->date ??= now()->toDateString();
});
}
public function customer()
{
return $this->belongsTo(Customer::class);
+7
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\Markable;
use App\Support\ServiceExpirySync;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -25,6 +26,12 @@ class Service extends Model
$service->type = Product::query()->where('id', $service->product_id)->value('product_type');
}
});
static::saved(function (Service $service) {
if ($service->wasRecentlyCreated || $service->wasChanged(['expires_at', 'vm_id'])) {
ServiceExpirySync::toVmware($service);
}
});
}
protected $table = 'services';