'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); } }