'integer', 'tax' => 'integer', 'sub_total' => 'integer', 'discount' => 'float', 'discount_val' => 'integer', 'exchange_rate' => 'float', ]; } /* |-------------------------------------------------------------------------- | Relationships |-------------------------------------------------------------------------- */ /** * Line items making up the offer. */ public function items(): HasMany { return $this->hasMany(EstimateItem::class, 'estimate_id', 'id'); } /** * Document-level tax rows, as opposed to the per-item ones hanging off the * line items. */ public function taxes(): HasMany { return $this->hasMany(Tax::class, 'estimate_id', 'id'); } /** * Contact the offer was made to. */ public function customer(): BelongsTo { return $this->belongsTo(Customer::class, 'customer_id', 'id'); } /** * Staff account that raised the offer. */ public function creator(): BelongsTo { return $this->belongsTo(User::class, 'creator_id', 'id'); } /** * Company the offer was issued under. */ public function company(): BelongsTo { return $this->belongsTo(Company::class, 'company_id', 'id'); } /** * Currency the stored amounts are denominated in. */ public function currency(): BelongsTo { return $this->belongsTo(Currency::class, 'currency_id', 'id'); } /** * Mail sent out for this offer, recorded through the polymorphic log. */ public function emailLogs(): MorphMany { return $this->morphMany(EmailLog::class, 'mailable', 'mailable_type', 'mailable_id', 'id'); } /* |-------------------------------------------------------------------------- | Accessors |-------------------------------------------------------------------------- */ /** * Shareable PDF address. Possession of the hash is the credential, so the * link carries no company or customer context of its own. */ public function getEstimatePdfUrlAttribute() { $path = '/estimates/pdf/'.$this->unique_hash; return url($path); } /** * Expiry written in the company's configured date format and in the * language the application is running in. * * @param mixed $value */ public function getFormattedExpiryDateAttribute($value) { $format = CompanySetting::getSetting('carbon_date_format', $this->company_id); return Carbon::parse($this->expiry_date)->translatedFormat($format); } /** * Issue date written the same way as the expiry above. * * @param mixed $value */ public function getFormattedEstimateDateAttribute($value) { $format = CompanySetting::getSetting('carbon_date_format', $this->company_id); return Carbon::parse($this->estimate_date)->translatedFormat($format); } /* |-------------------------------------------------------------------------- | Scopes |-------------------------------------------------------------------------- */ /** * Run every listed filter that carries a value. * * The order the filters are applied in is load-bearing: `estimate_id` is an * OR (see whereEstimate), so it widens whatever has been narrowed down to * that point and nothing added afterwards. Keep the sequence as it stands. */ public function scopeApplyFilters($query, array $filters) { $scopes = [ 'search' => 'whereSearch', 'estimate_number' => 'whereEstimateNumber', 'status' => 'whereStatus', 'estimate_id' => 'whereEstimate', ]; foreach ($scopes as $filter => $scope) { $value = $filters[$filter] ?? null; if ($value) { $query->{$scope}($value); } } $from = $filters['from_date'] ?? null; $to = $filters['to_date'] ?? null; if ($from && $to) { $query->estimatesBetween( Carbon::createFromFormat('Y-m-d', $from), Carbon::createFromFormat('Y-m-d', $to) ); } $contact = $filters['customer_id'] ?? null; if ($contact) { $query->whereCustomer($contact); } $sortField = $filters['orderByField'] ?? null; $sortDirection = $filters['orderBy'] ?? null; if ($sortField || $sortDirection) { $query->whereOrder($sortField ?: 'sequence_number', $sortDirection ?: 'desc'); } } /** * Restrict to offers issued inside the inclusive range. */ public function scopeEstimatesBetween($query, $start, $end) { $range = [$start->format('Y-m-d'), $end->format('Y-m-d')]; return $query->whereBetween($this->qualifyColumn('estimate_date'), $range); } /** * Exact match on the lifecycle status. */ public function scopeWhereStatus($query, $status) { return $query->where($this->qualifyColumn('status'), $status); } /** * Partial match on the document number. */ public function scopeWhereEstimateNumber($query, $estimateNumber) { return $query->where($this->qualifyColumn('estimate_number'), 'LIKE', '%'.$estimateNumber.'%'); } /** * Pull one specific offer back into the result set. * * This is an OR against an unqualified `id`, not a narrowing filter — it * adds the row to whatever the other filters matched rather than * intersecting with them. Preserved deliberately. */ public function scopeWhereEstimate($query, $estimate_id) { return $query->orWhere('id', $estimate_id); } /** * Keep only offers whose customer matches every whitespace-separated term, * a term counting as matched when it appears 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 ($contact) use ($needle) { $contact->where('name', 'LIKE', $needle) ->orWhere('contact_name', 'LIKE', $needle) ->orWhere('company_name', 'LIKE', $needle); }); } } /** * Sort by a caller-supplied column, sanitised before it reaches SQL. */ public function scopeWhereOrder($query, $orderByField, $orderBy) { return SafeOrderBy::apply($query, $orderByField, $orderBy); } /** * Narrow to the company the current request is acting on. */ public function scopeWhereCompany($query) { $active = request()->header('company'); return $query->where($this->qualifyColumn('company_id'), $active); } /** * Narrow to one contact's offers. */ public function scopeWhereCustomer($query, $customer_id) { return $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); } /* |-------------------------------------------------------------------------- | PDF and mail |-------------------------------------------------------------------------- */ /** * View data for the PDF template, assembled by the Sales domain. */ public function getPDFData(): mixed { $provider = app(EstimatePdfDataProvider::class); return $provider->getPdfData($this); } /** * Issuer's postal address as the PDF wants it, or false when the company * has no address on file at all. */ public function getCompanyAddress(): string|false { if ($this->company && ! $this->company->address()->exists()) { return false; } return $this->renderAddress('estimate_company_address_format'); } /** * Where the goods would ship, or false when the contact keeps no shipping * address. */ public function getCustomerShippingAddress(): string|false { if ($this->customer && ! $this->customer->shippingAddress()->exists()) { return false; } return $this->renderAddress('estimate_shipping_address_format'); } /** * Where the offer would be billed, or false when the contact keeps no * billing address. */ public function getCustomerBillingAddress(): string|false { if ($this->customer && ! $this->customer->billingAddress()->exists()) { return false; } return $this->renderAddress('estimate_billing_address_format'); } /** * The notes field with its placeholders filled in and the resulting markup * scrubbed before it reaches the renderer. */ public function getNotes(): string { $rendered = $this->getFormattedString($this->notes); return PdfHtmlSanitizer::sanitize($rendered); } /** * Whether the PDF should ride along with the mail. Anything other than the * explicit opt-out means yes. */ public function getEmailAttachmentSetting(): bool { $setting = CompanySetting::getSetting('estimate_email_attachment', $this->company_id); return $setting != 'NO'; } /** * Fill the placeholders in a mail body, then drop any brace token that was * left standing because nothing answered to it. */ public function getEmailBody(string $body): string { $placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields()); $filled = strtr($body, $placeholders); return preg_replace('/{(.*?)}/', '', $filled); } /** * The placeholders this document type contributes on top of the shared * company/contact set. */ public function getExtraFields(): array { $tokens = [ 'ESTIMATE_DATE' => $this->formattedEstimateDate, 'ESTIMATE_EXPIRY_DATE' => $this->formattedExpiryDate, 'ESTIMATE_NUMBER' => $this->estimate_number, 'ESTIMATE_REF_NUMBER' => $this->reference_number, ]; $fields = []; foreach ($tokens as $token => $value) { $fields['{'.$token.'}'] = $value; } return $fields; } /** * The invoice template that corresponds to this offer's own template. * * The two families are named in parallel, so the mapping is a word swap. * When the swapped name is not among the installed invoice templates the * first one takes over. */ public function getInvoiceTemplateName(): string { $mapped = Str::replace('estimate', 'invoice', $this->template_name); // The second argument is the preview image format. Leaving it empty // stops the helper from rendering a base64 thumbnail of every template // when all that is wanted here are the names. $available = array_column(PdfTemplateUtils::getFormattedTemplates('invoice', ''), 'name'); return in_array($mapped, $available) ? $mapped : 'invoice1'; } /** * Apply whatever the company wants done with an offer once it has been * turned into an invoice: drop it, or mark it as accepted. Any other * setting leaves the record alone. */ public function checkForEstimateConvertAction(): bool { $action = CompanySetting::getSetting('estimate_convert_action', $this->company_id); if ($action === 'delete_estimate') { $this->delete(); } if ($action === 'mark_estimate_as_accepted') { $this->fill(['status' => self::STATUS_ACCEPTED])->save(); } return true; } /** * Render one of the company's address layouts against this document. */ private function renderAddress(string $setting): string { $layout = CompanySetting::getSetting($setting, $this->company_id); return $this->getFormattedString($layout); } }