File: /home/xedaptot/ai.naniguide.com/app/Model/Invoice.php
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
use App\Model\PaymentIntent;
use App\Library\Traits\HasUid;
use Dompdf\Dompdf;
use App\Library\Facades\SubscriptionFacade;
use Exception;
use Closure;
use League\Pipeline\PipelineBuilder;
use App\Library\Contracts\TagResolverInterface;
use App\Library\HtmlHandler\DecodeHtmlSpecialChars;
use App\Library\HtmlHandler\TransformTag;
use App\Library\HtmlHandler\TransformUrl;
use Illuminate\Support\Facades\DB;
/**
* Logically abstract, must be mapped to a specific type in order to execute.
*
* @property int|null $processing_intent_id In-flight PaymentIntent lock (see PaymentIntentFactory)
* @property-read Customer|null $customer
* @property-read Order|null $order
*/
class Invoice extends Model implements TagResolverInterface
{
use \Illuminate\Database\Eloquent\Factories\HasFactory;
use HasUid;
protected static function newFactory()
{
return \Database\Factories\InvoiceFactory::new();
}
// statuses
public const STATUS_NEW = 'new'; // unpaid
public const STATUS_PAID = 'paid';
protected $fillable = [
'billing_first_name',
'billing_last_name',
'billing_address',
'billing_email',
'billing_phone',
'billing_country_id',
'processing_intent_id',
];
public function scopeNew($query)
{
$query->whereIn('status', [
self::STATUS_NEW,
]);
}
public function scopeUnpaid($query)
{
$query->whereIn('status', [
self::STATUS_NEW,
]);
}
public function scopePaid($query)
{
$query->whereIn('status', [
self::STATUS_PAID,
]);
}
public function scopePending($query)
{
$query->whereHas('paymentIntents', function ($q) {
$q->where('status', PaymentIntent::STATUS_PENDING);
});
}
public function scopeNotPending($query)
{
$query->orWhereDoesntHave('paymentIntents', function ($q) {
$q->where('status', PaymentIntent::STATUS_PENDING);
});
}
/**
* Invoice currency.
*/
public function currency()
{
return $this->belongsTo('App\Model\Currency');
}
/**
* Invoice customer.
*/
public function customer()
{
return $this->belongsTo('App\Model\Customer');
}
public function billingCountry()
{
return $this->belongsTo('App\Model\Country', 'billing_country_id');
}
public function order()
{
return $this->belongsTo(Order::class, 'order_id');
}
public function getPayerUid(): string
{
return $this->customer->uid;
}
public function getPayerName(): string
{
return $this->customer->getName();
}
public function getPlan()
{
try {
$item = $this->order->orderItems->first();
return $item && $item->subscription ? $item->subscription->plan : null;
} catch (\Throwable $e) {
return null;
}
}
public function getPlanName()
{
$plan = $this->getPlan();
return $plan ? $plan->name : '';
}
/** All PaymentIntents on this invoice (history of attempts). */
public function paymentIntents()
{
return $this->hasMany(PaymentIntent::class, 'invoice_id');
}
/** The active in-flight PaymentIntent (if any) — points to processing_intent_id. */
public function activePaymentIntent(): ?PaymentIntent
{
return $this->processing_intent_id
? PaymentIntent::find($this->processing_intent_id)
: null;
}
/** Active intent if non-terminal (pending / requires_action / awaiting_admin_approval), else null. */
public function getPendingPaymentIntent(): ?PaymentIntent
{
$intent = $this->activePaymentIntent();
return ($intent && !$intent->isTerminal()) ? $intent : null;
}
/** Most recent attempt failed — was: lastTransactionIsFailed(). */
public function lastPaymentIntentIsFailed(): bool
{
$intent = $this->paymentIntents()->orderByDesc('created_at')->orderByDesc('id')->first();
return $intent && $intent->isFailed();
}
public function getTax()
{
return $this->order->getTax();
}
public function subTotal()
{
return $this->order->subTotal();
}
public function total()
{
return $this->order->total(); // tạm thời là vầy, sau này invoice phải amount riêng, trả nhiều đợt chẳng hạn
}
public function isNew()
{
return $this->status == self::STATUS_NEW;
}
public function isPaid()
{
return $this->status == self::STATUS_PAID;
}
public function isUnpaid()
{
return in_array($this->status, [
self::STATUS_NEW,
]);
}
public function isFree()
{
return $this->order->total() == 0;
}
public function updateBillingInformation($params)
{
$this->fill($params);
$validator = \Validator::make($params, [
'billing_first_name' => 'required',
'billing_last_name' => 'required',
'billing_address' => 'required',
'billing_country_id' => 'required',
'billing_email' => 'required|email',
'billing_phone' => 'required',
]);
if ($validator->fails()) {
return $validator;
}
$this->save();
return $validator;
}
public function fillBillingAddressNotReplace($billingAddress)
{
if (!$this->billing_first_name) {
$this->billing_first_name = $billingAddress->first_name;
}
if (!$this->billing_last_name) {
$this->billing_last_name = $billingAddress->last_name;
}
if (!$this->billing_address) {
$this->billing_address = $billingAddress->address;
}
if (!$this->billing_email) {
$this->billing_email = $billingAddress->email;
}
if (!$this->billing_phone) {
$this->billing_phone = $billingAddress->phone;
}
if (!$this->billing_country_id) {
$this->billing_country_id = $billingAddress->country_id;
}
}
public function getBillingName()
{
$lastNameFirst = get_localization_config('show_last_name_first', $this->customer->getLanguageCode());
if ($lastNameFirst) {
return htmlspecialchars(trim($this->billing_last_name . ' ' . $this->billing_first_name));
} else {
return htmlspecialchars(trim($this->billing_first_name . ' ' . $this->billing_last_name));
}
}
public static function newDefault()
{
$invoice = new self();
$invoice->status = self::STATUS_NEW;
return $invoice;
}
public function hasBillingInformation()
{
if (empty($this->billing_first_name) ||
empty($this->billing_last_name) ||
empty($this->billing_phone) ||
empty($this->billing_address) ||
empty($this->billing_country_id) ||
empty($this->billing_email)
) {
return false;
}
return true;
}
public static function getTemplateContent($languageCode = 'en')
{
if (Setting::get('invoice.custom_template')) {
return Setting::get('invoice.custom_template');
}
$sysTemplate = \App\Model\SystemTemplate::getInvoiceTemplate();
if ($sysTemplate && $sysTemplate->template) {
return $sysTemplate->template->content;
}
return view('invoices.template', [
'languageCode' => $languageCode,
]);
}
public function getResolvedTags(): array
{
return [
'FIRST_NAME' => $this->billing_first_name,
'LAST_NAME' => $this->billing_last_name,
'ADDRESS' => $this->billing_address,
'COUNTRY' => $this->billingCountry ? $this->billingCountry->name : '',
'EMAIL' => $this->billing_email,
'PHONE' => $this->billing_phone,
'INVOICE_NUMBER' => $this->number,
'CURRENT_DATETIME' => $this->customer->formatCurrentDateTime('datetime_full'),
'INVOICE_DUE_DATE' => $this->customer->formatDateTime($this->created_at, 'datetime_full'),
'ITEMS' => view('invoices._template_items', ['invoice' => $this])->render(),
];
}
public function getInvoiceHtml()
{
$content = (string) self::getTemplateContent($this->customer->getLanguageCode());
return (new PipelineBuilder())
->add(new TransformTag($this))
->add(new TransformUrl($msgId = null, $domain = null, $trackLink = false))
->add(new DecodeHtmlSpecialChars())
->build()
->process($content);
}
public function exportToPdf()
{
// instantiate and use the dompdf class
$dompdf = new Dompdf(array('enable_remote' => true));
$content = mb_convert_encoding($this->getInvoiceHtml(), 'HTML-ENTITIES', 'UTF-8');
$dompdf->loadHtml($content);
// (Optional) Setup the paper size and orientation
$dompdf->setPaper('A4');
// Render the HTML as PDF
$dompdf->render();
return $dompdf->output();
}
public static function getTags()
{
$tags = [
['name' => '{{ FIRST_NAME }}', 'required' => false],
['name' => '{{ LAST_NAME }}', 'required' => false],
['name' => '{{ ADDRESS }}', 'required' => false],
['name' => '{{ COUNTRY }}', 'required' => false],
['name' => '{{ EMAIL }}', 'required' => false],
['name' => '{{ PHONE }}', 'required' => false],
['name' => '{{ INVOICE_NUMBER }}', 'required' => false],
['name' => '{{ CURRENT_DATETIME }}', 'required' => false],
['name' => '{{ INVOICE_DUE_DATE }}', 'required' => false],
['name' => '{{ ITEMS }}', 'required' => false],
['name' => '{{ CUSTOMER_ADDRESS }}', 'required' => false],
];
return $tags;
}
public function createInvoiceNumber()
{
$format = Setting::get('invoice.format') ?: '%08d';
$current = (int) (Setting::get('invoice.current') ?: 1);
$candidate = sprintf($format, $current);
if (!preg_match('/^\d+$/', $candidate)) {
throw new \RuntimeException(sprintf(
'Invalid invoice number format "%s" (rendered: "%s"). Must produce a numeric value (digits only). Please update setting "invoice.format".',
$format,
$candidate
));
}
// Audit + snap counter to MAX(number)+1 if it drifted underneath: DB
// restore, manual SQL, plugin-inserted invoices, or a prior save failure
// leaving the counter stuck below MAX. Notify admin so the reset is
// visible — never silent.
$max = $this->getMaxExistingInvoiceNumber();
if ($current <= $max) {
$newCurrent = $max + 1;
Setting::set('invoice.current', $newCurrent);
app(\App\Services\Notifications\Notifier::class)->dispatch(
new \App\Notifications\System\InvoiceCounterReset($current, $newCurrent, $max)
);
$current = $newCurrent;
$candidate = sprintf($format, $current);
}
$this->number = $candidate;
$this->save();
Setting::set('invoice.current', $current + 1);
}
/**
* Highest numeric value already used in `invoices.number`, interpreted as
* an unsigned integer (non-numeric historical values cast to 0 and are
* effectively ignored). Extracted from createInvoiceNumber() so tests can
* stub it without seeding rows.
*/
protected function getMaxExistingInvoiceNumber(): int
{
return (int) static::query()
->selectRaw('MAX(CAST(`number` AS UNSIGNED)) AS m')
->value('m');
}
public function getCurrencyCode()
{
return $this->order->currency->code;
}
public function getBillingCountryCode()
{
return ($this->billingCountry ? $this->billingCountry->code : '');
}
public function getPaymentIntents()
{
return $this->paymentIntents()
->orderBy('created_at', 'desc')
->get();
}
public function formattedTotal()
{
return format_price($this->order->total(), $this->order->currency->format);
}
public function getAmountInCurrencyCode($currencyCode)
{
if ($currencyCode == $this->order->currency->code) {
return $this->order->total();
}
// convert amount
return \App\Helpers\convertPrice($this->order->total(), $this->order->currency->code, $currencyCode);
}
}