Files
InvoiceShelf/app/Domains/Sales/Models/RecurringInvoice.php
T

337 lines
9.2 KiB
PHP

<?php
namespace App\Domains\Sales\Models;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Accounts\Models\User;
use App\Domains\Contacts\Models\Customer;
use App\Domains\Metadata\Concerns\HasCustomFields;
use App\Domains\Money\Models\Currency;
use App\Domains\Taxation\Models\Tax;
use App\Support\SafeOrderBy;
use Carbon\Carbon;
use Cron\CronExpression;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* A standing order that mints invoices on a timetable.
*
* The row carries a whole invoice in template form — amounts, items and taxes,
* kept in the same shape a real document uses — together with the cron
* expression that decides when the next copy falls due and the limit, if any,
* that eventually retires the schedule.
*/
class RecurringInvoice extends Model
{
use HasCustomFields;
use HasFactory;
/**
* Limit mode: keep issuing invoices indefinitely.
*/
public const NONE = 'NONE';
/**
* Limit mode: stop after a set number of invoices has been issued.
*/
public const COUNT = 'COUNT';
/**
* Limit mode: stop once the schedule passes its end date.
*/
public const DATE = 'DATE';
/**
* The schedule has run its course and issues nothing further.
*/
public const COMPLETED = 'COMPLETED';
/**
* The schedule is paused by hand.
*/
public const ON_HOLD = 'ON_HOLD';
/**
* The schedule is live and due to fire on its cron expression.
*/
public const ACTIVE = 'ACTIVE';
protected $table = 'recurring_invoices';
protected $guarded = [
'id',
];
/**
* Kept from an older Eloquent generation, which read this list to cast the
* named columns to dates. Current Eloquent ignores the property, so
* starts_at is handed around as the plain string the driver returns.
*/
protected $dates = [
'starts_at',
];
protected $appends = [
'formattedCreatedAt',
'formattedStartsAt',
'formattedNextInvoiceAt',
'formattedLimitDate',
];
/**
* Attribute casts.
*/
protected function casts(): array
{
return [
'exchange_rate' => 'float',
'send_automatically' => 'boolean',
];
}
/**
* Invoices this schedule has produced so far.
*/
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class, 'recurring_invoice_id');
}
/**
* Taxes carried by the template, at document level.
*/
public function taxes(): HasMany
{
return $this->hasMany(Tax::class, 'recurring_invoice_id');
}
/**
* Line items the generated invoices are built from.
*/
public function items(): HasMany
{
return $this->hasMany(InvoiceItem::class, 'recurring_invoice_id');
}
/**
* Contact every generated invoice is billed to.
*/
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class, 'customer_id');
}
/**
* Company the schedule was set up under.
*/
public function company(): BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
/**
* Author of the schedule, linked through the creator_id column.
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'creator_id');
}
/**
* Currency the template amounts are stated in.
*/
public function currency(): BelongsTo
{
return $this->belongsTo(Currency::class, 'currency_id');
}
/**
* Start of the schedule, written in the company's date format and in the
* language the application is running in.
*/
public function getFormattedStartsAtAttribute()
{
return Carbon::parse($this->starts_at)->translatedFormat($this->companyDateFormat());
}
/**
* The moment the next invoice is due, written in the company's date format
* and in the language the application is running in.
*/
public function getFormattedNextInvoiceAtAttribute()
{
return Carbon::parse($this->next_invoice_at)->translatedFormat($this->companyDateFormat());
}
/**
* End date of a date-limited schedule, written in the company's date
* format. Unlike the two above it is not translated.
*/
public function getFormattedLimitDateAttribute()
{
return Carbon::parse($this->limit_date)->format($this->companyDateFormat());
}
/**
* Creation date, written in the company's date format and untranslated.
*/
public function getFormattedCreatedAtAttribute()
{
return Carbon::parse($this->created_at)->format($this->companyDateFormat());
}
/**
* Narrow to the company the current request is acting on.
*/
public function scopeWhereCompany($query)
{
$company = request()->header('company');
return $query->where($this->qualifyColumn('company_id'), $company);
}
/**
* Return the whole result set for the sentinel limit "all", otherwise a
* page of the requested size.
*/
public function scopePaginateData($query, $limit)
{
return $limit == 'all' ? $query->get() : $query->paginate($limit);
}
/**
* Sort by a caller-supplied column, sanitised before it reaches SQL.
*/
public function scopeWhereOrder($query, $orderByField, $orderBy)
{
return SafeOrderBy::apply($query, $orderByField, $orderBy);
}
/**
* Keep only schedules sitting in one lifecycle state.
*/
public function scopeWhereStatus($query, $status)
{
return $query->where($this->qualifyColumn('status'), $status);
}
/**
* Keep only the schedules billed to one contact.
*/
public function scopeWhereCustomer($query, $customer_id)
{
return $query->where('customer_id', $customer_id);
}
/**
* Keep only schedules whose start date falls inside the inclusive range.
*/
public function scopeRecurringInvoicesStartBetween($query, $start, $end)
{
return $query->whereBetween('starts_at', [
$start->format('Y-m-d'),
$end->format('Y-m-d'),
]);
}
/**
* Keep only schedules whose contact matches every whitespace-separated
* term, a term counting as matched when it appears in the contact's name,
* the contact person or the company name.
*/
public function scopeWhereSearch($query, $search)
{
$terms = explode(' ', $search);
foreach ($terms as $term) {
$query->whereHas('customer', function ($customer) use ($term) {
$needle = '%'.$term.'%';
$customer->where('name', 'LIKE', $needle)
->orWhere('contact_name', 'LIKE', $needle)
->orWhere('company_name', 'LIKE', $needle);
});
}
}
/**
* Run every listed filter that carries a value.
*/
public function scopeApplyFilters($query, array $filters)
{
$status = $filters['status'] ?? null;
$search = $filters['search'] ?? null;
$from = $filters['from_date'] ?? null;
$to = $filters['to_date'] ?? null;
$customer = $filters['customer_id'] ?? null;
if ($status && $status !== 'ALL') {
$query->whereStatus($status);
}
if ($search) {
$query->whereSearch($search);
}
if ($from && $to) {
$query->recurringInvoicesStartBetween(
Carbon::createFromFormat('Y-m-d', $from),
Carbon::createFromFormat('Y-m-d', $to)
);
}
if ($customer) {
$query->whereCustomer($customer);
}
$sortField = $filters['orderByField'] ?? null;
$sortDirection = $filters['orderBy'] ?? null;
if ($sortField || $sortDirection) {
$query->whereOrder($sortField ?: 'created_at', $sortDirection ?: 'asc');
}
}
/**
* Retire the schedule, so that no further invoice is generated from it.
*/
public function markStatusAsCompleted(): void
{
$this->status = static::COMPLETED;
$this->save();
}
/**
* The moment a cron expression next fires, counted from the given start
* date rather than from now, in the application's own time zone.
*/
public static function getNextInvoiceDate(string $frequency, string $starts_at): string
{
$schedule = new CronExpression($frequency);
$zone = config('app.timezone', 'UTC');
return $schedule->getNextRunDate($starts_at, 0, false, $zone)->format('Y-m-d H:i:s');
}
/**
* Recompute and store the date the next invoice falls due.
*/
public function updateNextInvoiceDate(): void
{
$this->next_invoice_at = self::getNextInvoiceDate($this->frequency, $this->starts_at);
$this->save();
}
/**
* The date format the owning company writes dates in.
*/
private function companyDateFormat()
{
return CompanySetting::getSetting('carbon_date_format', $this->company_id);
}
}