mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-02 05:10:59 +00:00
531 lines
15 KiB
PHP
531 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Receivables\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\Receivables\Contracts\PaymentPdfDataProvider;
|
|
use App\Domains\Receivables\Jobs\GeneratePaymentPdfJob;
|
|
use App\Domains\Sales\Models\Invoice;
|
|
use App\Platform\Mail\Models\EmailLog;
|
|
use App\Platform\Pdf\Concerns\GeneratesPdf;
|
|
use App\Platform\Pdf\Rendering\PdfHtmlSanitizer;
|
|
use App\Support\SafeOrderBy;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Spatie\MediaLibrary\HasMedia;
|
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
|
|
|
/**
|
|
* Money received from a contact.
|
|
*
|
|
* A receipt stands on its own: it records what arrived, in the contact's
|
|
* currency, and how much that was worth in the company's books. Which
|
|
* documents it settles is a separate, replaceable list of slices held in
|
|
* `payment_allocations`, so nothing here points at an invoice. Whatever is
|
|
* left unallocated is the contact's credit.
|
|
*
|
|
* Saving one queues a fresh rendering of its receipt PDF, and the shareable
|
|
* link to that file is derived from the row's unique hash.
|
|
*/
|
|
class Payment extends Model implements HasMedia
|
|
{
|
|
use GeneratesPdf;
|
|
use HasCustomFields;
|
|
use HasFactory;
|
|
use InteractsWithMedia;
|
|
|
|
protected $table = 'payments';
|
|
|
|
/**
|
|
* Everything but the primary key may be mass assigned.
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $guarded = [
|
|
'id',
|
|
];
|
|
|
|
/**
|
|
* Columns the pre-cast date handling used to hydrate as instances.
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $dates = [
|
|
'created_at',
|
|
'updated_at',
|
|
'payment_date',
|
|
];
|
|
|
|
/**
|
|
* Computed attributes, listed in the order they are serialized.
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $appends = [
|
|
'formattedCreatedAt',
|
|
'formattedPaymentDate',
|
|
'paymentPdfUrl',
|
|
];
|
|
|
|
/**
|
|
* Attribute casts.
|
|
*
|
|
* The amounts are left alone: they are whole minor units already, and the
|
|
* driver hands them back in the shape the rest of the domain expects. Only
|
|
* the free-text note and the fractional exchange rate are pinned.
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'notes' => 'string',
|
|
'exchange_rate' => 'float',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Keep the stored receipt PDF in step with the row.
|
|
*
|
|
* A brand new receipt only has to be rendered; a saved one has to replace
|
|
* the file already on disk, because the number the file is named for may
|
|
* have moved.
|
|
*/
|
|
protected static function booted()
|
|
{
|
|
static::created(function ($receipt) {
|
|
self::queueRender($receipt, false);
|
|
});
|
|
|
|
static::updated(function ($receipt) {
|
|
self::queueRender($receipt, true);
|
|
});
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Relationships
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* The online-payment attempt that produced this receipt, when a gateway
|
|
* was involved.
|
|
*/
|
|
public function transaction(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Transaction::class, 'transaction_id');
|
|
}
|
|
|
|
/**
|
|
* Mail sent about this receipt.
|
|
*/
|
|
public function emailLogs(): MorphMany
|
|
{
|
|
return $this->morphMany(EmailLog::class, 'mailable');
|
|
}
|
|
|
|
/**
|
|
* Contact the money came from.
|
|
*/
|
|
public function customer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Customer::class, 'customer_id');
|
|
}
|
|
|
|
/**
|
|
* Company the receipt was booked under.
|
|
*/
|
|
public function company(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Company::class, 'company_id');
|
|
}
|
|
|
|
/**
|
|
* The slices this receipt has been split into across documents.
|
|
*/
|
|
public function allocations(): HasMany
|
|
{
|
|
return $this->hasMany(PaymentAllocation::class, 'payment_id');
|
|
}
|
|
|
|
/**
|
|
* Documents this receipt settles, with the allocated amounts carried on
|
|
* the pivot.
|
|
*/
|
|
public function invoices(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Invoice::class, 'payment_allocations', 'payment_id', 'invoice_id')
|
|
->withPivot(['amount', 'base_amount'])
|
|
->withTimestamps();
|
|
}
|
|
|
|
/**
|
|
* Staff account that recorded the receipt.
|
|
*/
|
|
public function creator(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'creator_id');
|
|
}
|
|
|
|
/**
|
|
* Currency the money arrived in, always the contact's own.
|
|
*/
|
|
public function currency(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Currency::class, 'currency_id');
|
|
}
|
|
|
|
/**
|
|
* How the money was taken, when it was recorded.
|
|
*/
|
|
public function paymentMethod(): BelongsTo
|
|
{
|
|
return $this->belongsTo(PaymentMethod::class, 'payment_method_id');
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Accessors and mutators
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Store gateway settings as JSON.
|
|
*
|
|
* An empty value is skipped rather than written, so saving a receipt that
|
|
* carries none leaves whatever was already on file untouched.
|
|
*
|
|
* @param mixed $value
|
|
*/
|
|
public function setSettingsAttribute($value)
|
|
{
|
|
if (! $value) {
|
|
return;
|
|
}
|
|
|
|
$encoded = json_encode($value);
|
|
|
|
$this->attributes['settings'] = $encoded;
|
|
}
|
|
|
|
/**
|
|
* Creation timestamp in the company's configured date format, written in
|
|
* the language the application is running in.
|
|
*
|
|
* @param mixed $value
|
|
*/
|
|
public function getFormattedCreatedAtAttribute($value)
|
|
{
|
|
$moment = Carbon::parse($this->created_at);
|
|
|
|
return $moment->translatedFormat($this->receiptDateFormat());
|
|
}
|
|
|
|
/**
|
|
* Date the money arrived, in the company's configured date format and in
|
|
* the language the application is running in.
|
|
*
|
|
* @param mixed $value
|
|
*/
|
|
public function getFormattedPaymentDateAttribute($value)
|
|
{
|
|
$moment = Carbon::parse($this->payment_date);
|
|
|
|
return $moment->translatedFormat($this->receiptDateFormat());
|
|
}
|
|
|
|
/**
|
|
* Shareable link to the rendered receipt. Possession of the hash is the
|
|
* only credential the link needs.
|
|
*/
|
|
public function getPaymentPdfUrlAttribute()
|
|
{
|
|
$hash = $this->unique_hash;
|
|
|
|
return url('/payments/pdf/'.$hash);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Query scopes
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Run every listed filter that carries a value.
|
|
*
|
|
* Order is load-bearing: the clauses land in the query in the order
|
|
* written here, and the receipt-id filter contributes an OR, which makes
|
|
* everything queued before it part of that alternative. A filter sent as
|
|
* an empty string, a zero or a null counts as not sent at all.
|
|
*/
|
|
public function scopeApplyFilters($query, array $filters)
|
|
{
|
|
$clauses = [
|
|
'search' => fn ($wanted) => $query->whereSearch($wanted),
|
|
'payment_number' => fn ($wanted) => $query->paymentNumber($wanted),
|
|
'payment_id' => fn ($wanted) => $query->wherePayment($wanted),
|
|
'payment_method_id' => fn ($wanted) => $query->paymentMethod($wanted),
|
|
'customer_id' => fn ($wanted) => $query->whereCustomer($wanted),
|
|
];
|
|
|
|
foreach ($clauses as $filter => $clause) {
|
|
$wanted = $filters[$filter] ?? null;
|
|
|
|
if ($wanted) {
|
|
$clause($wanted);
|
|
}
|
|
}
|
|
|
|
$from = $filters['from_date'] ?? null;
|
|
$to = $filters['to_date'] ?? null;
|
|
|
|
if ($from && $to) {
|
|
$query->paymentsBetween(
|
|
Carbon::createFromFormat('Y-m-d', $from),
|
|
Carbon::createFromFormat('Y-m-d', $to)
|
|
);
|
|
}
|
|
|
|
$sortField = $filters['orderByField'] ?? null;
|
|
$sortDirection = $filters['orderBy'] ?? null;
|
|
|
|
if ($sortField || $sortDirection) {
|
|
$query->whereOrder($sortField ?: 'sequence_number', $sortDirection ?: 'desc');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Keep only receipts whose contact matches every whitespace-separated
|
|
* term, a term counting as matched when it turns up in the display name,
|
|
* the contact person or the company name.
|
|
*/
|
|
public function scopeWhereSearch($query, $search)
|
|
{
|
|
$terms = explode(' ', $search);
|
|
|
|
foreach ($terms as $term) {
|
|
$needle = '%'.$term.'%';
|
|
|
|
$query->whereHas('customer', function ($payer) use ($needle) {
|
|
$payer->where('name', 'LIKE', $needle)
|
|
->orWhere('contact_name', 'LIKE', $needle)
|
|
->orWhere('company_name', 'LIKE', $needle);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Partial match on the receipt number.
|
|
*/
|
|
public function scopePaymentNumber($query, $paymentNumber)
|
|
{
|
|
return $query->where($this->qualifyColumn('payment_number'), 'LIKE', '%'.$paymentNumber.'%');
|
|
}
|
|
|
|
/**
|
|
* Narrow to receipts taken one particular way.
|
|
*/
|
|
public function scopePaymentMethod($query, $paymentMethodId)
|
|
{
|
|
return $query->where($this->qualifyColumn('payment_method_id'), $paymentMethodId);
|
|
}
|
|
|
|
/**
|
|
* Restrict to receipts dated inside the inclusive range.
|
|
*/
|
|
public function scopePaymentsBetween($query, $start, $end)
|
|
{
|
|
return $query->whereBetween($this->qualifyColumn('payment_date'), [
|
|
$start->format('Y-m-d'),
|
|
$end->format('Y-m-d'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Sort by a caller-supplied column, sanitised before it reaches SQL and
|
|
* falling back to the creation timestamp.
|
|
*/
|
|
public function scopeWhereOrder($query, $orderByField, $orderBy)
|
|
{
|
|
SafeOrderBy::apply($query, $orderByField, $orderBy, 'created_at');
|
|
}
|
|
|
|
/**
|
|
* Widen a listing to also take in one specific receipt.
|
|
*
|
|
* The column is deliberately left unqualified: the listing query joins the
|
|
* contacts table, so this is the clause that decides whether an id filter
|
|
* is answerable at all.
|
|
*/
|
|
public function scopeWherePayment($query, $payment_id)
|
|
{
|
|
$query->orWhere('id', $payment_id);
|
|
}
|
|
|
|
/**
|
|
* Narrow to the company the current request is acting on.
|
|
*/
|
|
public function scopeWhereCompany($query)
|
|
{
|
|
$company = request()->header('company');
|
|
|
|
$query->where($this->qualifyColumn('company_id'), $company);
|
|
}
|
|
|
|
/**
|
|
* Narrow to one contact.
|
|
*/
|
|
public function scopeWhereCustomer($query, $customer_id)
|
|
{
|
|
$query->where($this->qualifyColumn('customer_id'), $customer_id);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Rendering and correspondence
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* View data for the PDF renderer.
|
|
*/
|
|
public function getPDFData(): mixed
|
|
{
|
|
$provider = app(PaymentPdfDataProvider::class);
|
|
|
|
return $provider->getPdfData($this);
|
|
}
|
|
|
|
/**
|
|
* The company's address block for print, or false when the company has no
|
|
* address on file.
|
|
*/
|
|
public function getCompanyAddress(): string|false
|
|
{
|
|
return $this->addressBlock(
|
|
$this->company && (! $this->company->address()->exists()),
|
|
'payment_company_address_format'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The contact's billing address block for print, or false when the contact
|
|
* has no billing address on file.
|
|
*/
|
|
public function getCustomerBillingAddress(): string|false
|
|
{
|
|
return $this->addressBlock(
|
|
$this->customer && (! $this->customer->billingAddress()->exists()),
|
|
'payment_from_customer_address_format'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether outgoing mail should carry the receipt PDF. Anything other than
|
|
* an explicit refusal counts as consent.
|
|
*/
|
|
public function getEmailAttachmentSetting(): bool
|
|
{
|
|
return CompanySetting::getSetting('payment_email_attachment', $this->company_id) != 'NO';
|
|
}
|
|
|
|
/**
|
|
* The note field with its placeholders resolved and its markup sanitised.
|
|
*/
|
|
public function getNotes(): string
|
|
{
|
|
$resolved = $this->getFormattedString($this->notes);
|
|
|
|
return PdfHtmlSanitizer::sanitize($resolved);
|
|
}
|
|
|
|
/**
|
|
* Resolve the placeholders in a mail body, dropping any that named
|
|
* something this receipt cannot supply.
|
|
*/
|
|
public function getEmailBody(string $body): string
|
|
{
|
|
$placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields());
|
|
|
|
return preg_replace('/{(.*?)}/', '', strtr($body, $placeholders));
|
|
}
|
|
|
|
/**
|
|
* The placeholders this receipt contributes on top of the shared contact
|
|
* and company set.
|
|
*/
|
|
public function getExtraFields(): array
|
|
{
|
|
$method = $this->paymentMethod;
|
|
$money = format_money_pdf($this->amount, $this->customer->currency);
|
|
|
|
return [
|
|
'{PAYMENT_DATE}' => $this->formattedPaymentDate,
|
|
'{PAYMENT_MODE}' => $method ? $method->name : null,
|
|
'{PAYMENT_NUMBER}' => $this->payment_number,
|
|
'{PAYMENT_AMOUNT}' => $money,
|
|
];
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Internals
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Hand a receipt's PDF rendering to the queue once the surrounding
|
|
* transaction has actually landed, and keep the job itself from starting
|
|
* any earlier than that.
|
|
*/
|
|
private static function queueRender(self $payment, bool $replaceExisting): void
|
|
{
|
|
DB::afterCommit(function () use ($payment, $replaceExisting) {
|
|
GeneratePaymentPdfJob::dispatch($payment, $replaceExisting)->afterCommit();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* One of the printable address blocks: the company's or the contact's.
|
|
*
|
|
* The named format is only looked up once the address is known to be
|
|
* there, so a missing one costs nothing.
|
|
*/
|
|
private function addressBlock(bool $missing, string $setting): string|false
|
|
{
|
|
if ($missing) {
|
|
return false;
|
|
}
|
|
|
|
return $this->getFormattedString(CompanySetting::getSetting($setting, $this->company_id));
|
|
}
|
|
|
|
/**
|
|
* The date format this company writes its receipts in.
|
|
*/
|
|
private function receiptDateFormat()
|
|
{
|
|
return CompanySetting::getSetting('carbon_date_format', $this->company_id);
|
|
}
|
|
}
|