mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 07:02:13 +00:00
Custom templates are a real feature with no test coverage at all, and several
rough edges that only show up once someone actually uses one.
make:template validated nothing. --type was checked only by the interactive
prompt, so `--type=payment` skipped the prompt and died on an uncaught
FileNotFoundException looking for payment1.blade.php: a stack trace instead of a
message. The name was not checked either, so `../escaped` wrote outside the
templates directory. Both are refused now.
Every custom template of a type shared one partials/table.blade.php. It was
written on first use and reused thereafter, so editing the items table for one
custom template silently changed it for all of them -- a file that looks
per-template and behaves globally. Each template now gets its own copy under
partials/{name}/, and its include is rewritten to match. Existing templates keep
including the old shared path, which still resolves.
A custom template sharing a built-in's name appeared twice in the picker with
the same label, and findFormattedTemplate() array_reverses and takes the first
match, so the custom one silently won. The listing is keyed by name now, so it
appears once, as the entry that will actually be used.
A custom template with no same-named .png rendered <img src=""> in the picker: a
blank tile, no error, no hint anything was missing. It falls back to the preview
of the template it was cloned from.
template_name was validated as `required` and nothing else, so any string was
accepted and stored. findFormattedTemplate() returns null for an unknown name,
the null reads as "not custom", and rendering falls through to
app.pdf.{type}.{name} -- a raw "view not found" 500 at PDF time, long after the
save that caused it. New PdfTemplateExists rule, scoped per document type.
make:template also copies a _header/_footer companion when the source template
has one, so a scaffolded template keeps the repeating page furniture.
Both getEstimateTemplateName/getInvoiceTemplateName asked for the template list
with the default image format, base64-encoding a preview of every template just
to read the names back.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
335 lines
9.4 KiB
PHP
335 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Services\Document\EstimateService;
|
|
use App\Support\Pdf\PdfHtmlSanitizer;
|
|
use App\Support\Pdf\PdfTemplateUtils;
|
|
use App\Support\SafeOrderBy;
|
|
use App\Traits\GeneratesPdfTrait;
|
|
use App\Traits\HasCustomFieldsTrait;
|
|
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\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\MediaLibrary\HasMedia;
|
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
|
|
|
class Estimate extends Model implements HasMedia
|
|
{
|
|
use GeneratesPdfTrait;
|
|
use HasCustomFieldsTrait;
|
|
use HasFactory;
|
|
use InteractsWithMedia;
|
|
|
|
public const STATUS_DRAFT = 'DRAFT';
|
|
|
|
public const STATUS_SENT = 'SENT';
|
|
|
|
public const STATUS_VIEWED = 'VIEWED';
|
|
|
|
public const STATUS_EXPIRED = 'EXPIRED';
|
|
|
|
public const STATUS_ACCEPTED = 'ACCEPTED';
|
|
|
|
public const STATUS_REJECTED = 'REJECTED';
|
|
|
|
protected $dates = [
|
|
'created_at',
|
|
'updated_at',
|
|
'deleted_at',
|
|
'estimate_date',
|
|
'expiry_date',
|
|
];
|
|
|
|
protected $appends = [
|
|
'formattedExpiryDate',
|
|
'formattedEstimateDate',
|
|
'estimatePdfUrl',
|
|
];
|
|
|
|
protected $guarded = ['id'];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'total' => 'integer',
|
|
'tax' => 'integer',
|
|
'sub_total' => 'integer',
|
|
'discount' => 'float',
|
|
'discount_val' => 'integer',
|
|
'exchange_rate' => 'float',
|
|
];
|
|
}
|
|
|
|
public function getEstimatePdfUrlAttribute()
|
|
{
|
|
return url('/estimates/pdf/'.$this->unique_hash);
|
|
}
|
|
|
|
public function emailLogs(): MorphMany
|
|
{
|
|
return $this->morphMany('App\Models\EmailLog', 'mailable');
|
|
}
|
|
|
|
public function items(): HasMany
|
|
{
|
|
return $this->hasMany(EstimateItem::class);
|
|
}
|
|
|
|
public function customer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Customer::class, 'customer_id');
|
|
}
|
|
|
|
public function creator(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'creator_id');
|
|
}
|
|
|
|
public function company(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Company::class);
|
|
}
|
|
|
|
public function currency(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Currency::class);
|
|
}
|
|
|
|
public function taxes(): HasMany
|
|
{
|
|
return $this->hasMany(Tax::class);
|
|
}
|
|
|
|
public function getFormattedExpiryDateAttribute($value)
|
|
{
|
|
$dateFormat = CompanySetting::getSetting('carbon_date_format', $this->company_id);
|
|
|
|
return Carbon::parse($this->expiry_date)->translatedFormat($dateFormat);
|
|
}
|
|
|
|
public function getFormattedEstimateDateAttribute($value)
|
|
{
|
|
$dateFormat = CompanySetting::getSetting('carbon_date_format', $this->company_id);
|
|
|
|
return Carbon::parse($this->estimate_date)->translatedFormat($dateFormat);
|
|
}
|
|
|
|
public function scopeEstimatesBetween($query, $start, $end)
|
|
{
|
|
return $query->whereBetween(
|
|
'estimates.estimate_date',
|
|
[$start->format('Y-m-d'), $end->format('Y-m-d')]
|
|
);
|
|
}
|
|
|
|
public function scopeWhereStatus($query, $status)
|
|
{
|
|
return $query->where('estimates.status', $status);
|
|
}
|
|
|
|
public function scopeWhereEstimateNumber($query, $estimateNumber)
|
|
{
|
|
return $query->where('estimates.estimate_number', 'LIKE', '%'.$estimateNumber.'%');
|
|
}
|
|
|
|
public function scopeWhereEstimate($query, $estimate_id)
|
|
{
|
|
$query->orWhere('id', $estimate_id);
|
|
}
|
|
|
|
public function scopeWhereSearch($query, $search)
|
|
{
|
|
foreach (explode(' ', $search) as $term) {
|
|
$query->whereHas('customer', function ($query) use ($term) {
|
|
$query->where('name', 'LIKE', '%'.$term.'%')
|
|
->orWhere('contact_name', 'LIKE', '%'.$term.'%')
|
|
->orWhere('company_name', 'LIKE', '%'.$term.'%');
|
|
});
|
|
}
|
|
}
|
|
|
|
public function scopeApplyFilters($query, array $filters)
|
|
{
|
|
$filters = collect($filters);
|
|
|
|
if ($filters->get('search')) {
|
|
$query->whereSearch($filters->get('search'));
|
|
}
|
|
|
|
if ($filters->get('estimate_number')) {
|
|
$query->whereEstimateNumber($filters->get('estimate_number'));
|
|
}
|
|
|
|
if ($filters->get('status')) {
|
|
$query->whereStatus($filters->get('status'));
|
|
}
|
|
|
|
if ($filters->get('estimate_id')) {
|
|
$query->whereEstimate($filters->get('estimate_id'));
|
|
}
|
|
|
|
if ($filters->get('from_date') && $filters->get('to_date')) {
|
|
$start = Carbon::createFromFormat('Y-m-d', $filters->get('from_date'));
|
|
$end = Carbon::createFromFormat('Y-m-d', $filters->get('to_date'));
|
|
$query->estimatesBetween($start, $end);
|
|
}
|
|
|
|
if ($filters->get('customer_id')) {
|
|
$query->whereCustomer($filters->get('customer_id'));
|
|
}
|
|
|
|
if ($filters->get('orderByField') || $filters->get('orderBy')) {
|
|
$field = $filters->get('orderByField') ? $filters->get('orderByField') : 'sequence_number';
|
|
$orderBy = $filters->get('orderBy') ? $filters->get('orderBy') : 'desc';
|
|
$query->whereOrder($field, $orderBy);
|
|
}
|
|
}
|
|
|
|
public function scopeWhereOrder($query, $orderByField, $orderBy)
|
|
{
|
|
SafeOrderBy::apply($query, $orderByField, $orderBy);
|
|
}
|
|
|
|
public function scopeWhereCompany($query)
|
|
{
|
|
$query->where('estimates.company_id', request()->header('company'));
|
|
}
|
|
|
|
public function scopeWhereCustomer($query, $customer_id)
|
|
{
|
|
$query->where('estimates.customer_id', $customer_id);
|
|
}
|
|
|
|
public function scopePaginateData($query, $limit)
|
|
{
|
|
if ($limit == 'all') {
|
|
return $query->get();
|
|
}
|
|
|
|
return $query->paginate($limit);
|
|
}
|
|
|
|
public function getPDFData(): mixed
|
|
{
|
|
return app(EstimateService::class)->getPdfData($this);
|
|
}
|
|
|
|
public function getCompanyAddress(): string|false
|
|
{
|
|
if ($this->company && (! $this->company->address()->exists())) {
|
|
return false;
|
|
}
|
|
|
|
$format = CompanySetting::getSetting('estimate_company_address_format', $this->company_id);
|
|
|
|
return $this->getFormattedString($format);
|
|
}
|
|
|
|
public function getCustomerShippingAddress(): string|false
|
|
{
|
|
if ($this->customer && (! $this->customer->shippingAddress()->exists())) {
|
|
return false;
|
|
}
|
|
|
|
$format = CompanySetting::getSetting('estimate_shipping_address_format', $this->company_id);
|
|
|
|
return $this->getFormattedString($format);
|
|
}
|
|
|
|
public function getCustomerBillingAddress(): string|false
|
|
{
|
|
if ($this->customer && (! $this->customer->billingAddress()->exists())) {
|
|
return false;
|
|
}
|
|
|
|
$format = CompanySetting::getSetting('estimate_billing_address_format', $this->company_id);
|
|
|
|
return $this->getFormattedString($format);
|
|
}
|
|
|
|
public function getNotes(): string
|
|
{
|
|
return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
|
|
}
|
|
|
|
public function getEmailAttachmentSetting(): bool
|
|
{
|
|
$estimateAsAttachment = CompanySetting::getSetting('estimate_email_attachment', $this->company_id);
|
|
|
|
if ($estimateAsAttachment == 'NO') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function getEmailBody(string $body): string
|
|
{
|
|
$values = array_merge($this->getFieldsArray(), $this->getExtraFields());
|
|
|
|
$body = strtr($body, $values);
|
|
|
|
return preg_replace('/{(.*?)}/', '', $body);
|
|
}
|
|
|
|
public function getExtraFields(): array
|
|
{
|
|
return [
|
|
'{ESTIMATE_DATE}' => $this->formattedEstimateDate,
|
|
'{ESTIMATE_EXPIRY_DATE}' => $this->formattedExpiryDate,
|
|
'{ESTIMATE_NUMBER}' => $this->estimate_number,
|
|
'{ESTIMATE_REF_NUMBER}' => $this->reference_number,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Map the estimate's template name to the corresponding invoice template name.
|
|
*
|
|
* Falls back to 'invoice1' if the mapped name does not exist in available templates.
|
|
*/
|
|
public function getInvoiceTemplateName(): string
|
|
{
|
|
$templateName = Str::replace('estimate', 'invoice', $this->template_name);
|
|
|
|
// Empty image format: only the names are wanted here, and the default
|
|
// builds a base64 preview for every template to answer that.
|
|
$name = array_column(PdfTemplateUtils::getFormattedTemplates('invoice', ''), 'name');
|
|
|
|
if (in_array($templateName, $name) == false) {
|
|
$templateName = 'invoice1';
|
|
}
|
|
|
|
return $templateName;
|
|
}
|
|
|
|
/**
|
|
* Handle the post-conversion action for this estimate based on company settings.
|
|
*
|
|
* Either deletes the estimate or marks it as accepted, depending on the
|
|
* 'estimate_convert_action' company setting.
|
|
*/
|
|
public function checkForEstimateConvertAction(): bool
|
|
{
|
|
$convertEstimateAction = CompanySetting::getSetting(
|
|
'estimate_convert_action',
|
|
$this->company_id
|
|
);
|
|
|
|
if ($convertEstimateAction === 'delete_estimate') {
|
|
$this->delete();
|
|
}
|
|
|
|
if ($convertEstimateAction === 'mark_estimate_as_accepted') {
|
|
$this->status = self::STATUS_ACCEPTED;
|
|
$this->save();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|