142 lines
5.4 KiB
PHP
142 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Mail;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Instance;
|
|
use App\Models\MailTemplate;
|
|
use App\Models\Operator;
|
|
use App\Models\Subscription;
|
|
use App\Support\CompanyProfile;
|
|
use Illuminate\Support\Number;
|
|
|
|
/**
|
|
* A template with the customer's own details put in.
|
|
*
|
|
* ## Why the placeholders are named in English
|
|
*
|
|
* They are identifiers, not prose. A template written in German uses
|
|
* `{{customer}}` in a German sentence for the same reason a database column is
|
|
* called `customer_id` — the name has to survive somebody switching the
|
|
* console's language, and a placeholder that only works in one locale is a
|
|
* template that breaks the day the interface is translated. What the OPERATOR
|
|
* reads beside the field is translated; the token is not.
|
|
*
|
|
* ## Why an unknown placeholder is left standing
|
|
*
|
|
* A typo comes out as `{{kunde}}` in the compose field, where the operator sees
|
|
* it before anything is sent and fixes it. Replacing it with an empty string
|
|
* would produce "Guten Tag ," — a mail that looks sent by a machine, which is
|
|
* exactly the impression templates exist to avoid.
|
|
*
|
|
* ## Why nothing is invented
|
|
*
|
|
* A customer with no contract gets an empty `{{plan}}`, not "Start" and not
|
|
* "kein Paket". The operator is looking at the text and can write what belongs
|
|
* there; a guess in a mail to a paying customer is worse than a gap.
|
|
*/
|
|
final class MailTemplateRenderer
|
|
{
|
|
/**
|
|
* Every placeholder, with the key of the sentence that explains it.
|
|
*
|
|
* The list IS the documentation: the template page renders it, and a token
|
|
* added here appears there without anybody remembering to add it twice.
|
|
*/
|
|
public const PLACEHOLDERS = [
|
|
'customer' => 'templates.token.customer',
|
|
'contact' => 'templates.token.contact',
|
|
'email' => 'templates.token.email',
|
|
'plan' => 'templates.token.plan',
|
|
'amount' => 'templates.token.amount',
|
|
'instance' => 'templates.token.instance',
|
|
'operator' => 'templates.token.operator',
|
|
'company' => 'templates.token.company',
|
|
];
|
|
|
|
/**
|
|
* Subject and body, filled in.
|
|
*
|
|
* @return array{subject: string, body: string}
|
|
*/
|
|
public function render(MailTemplate $template, Customer $customer, ?Operator $operator = null): array
|
|
{
|
|
$values = $this->values($customer, $operator);
|
|
|
|
return [
|
|
'subject' => $this->fill($template->subject, $values),
|
|
'body' => $this->fill($template->body, $values),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* What each placeholder stands for, for this customer.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
public function values(Customer $customer, ?Operator $operator = null): array
|
|
{
|
|
$subscription = Subscription::query()
|
|
->where('customer_id', $customer->id)
|
|
->whereIn('status', ['active', 'past_due'])
|
|
->latest('id')
|
|
->first();
|
|
|
|
$instance = Instance::query()
|
|
->where('customer_id', $customer->id)
|
|
->whereNotIn('status', ['ended', 'failed'])
|
|
->latest('id')
|
|
->first();
|
|
|
|
return [
|
|
'customer' => (string) $customer->name,
|
|
// The person, where one is on record; the company otherwise. A
|
|
// salutation is written to somebody, and "Guten Tag Beispiel GmbH"
|
|
// is how a mail announces that it came out of a database.
|
|
'contact' => (string) ($customer->contact_name ?: $customer->name),
|
|
'email' => (string) $customer->email,
|
|
'plan' => $subscription === null ? '' : __('billing.plan.'.$subscription->plan),
|
|
// Gross, because that is the figure the customer knows: it is what
|
|
// the price sheet quoted them and what their bank statement says.
|
|
'amount' => $subscription === null ? '' : Number::currency(
|
|
$this->grossCents($subscription) / 100,
|
|
in: (string) ($subscription->currency ?: 'EUR'),
|
|
locale: app()->getLocale(),
|
|
),
|
|
'instance' => (string) ($instance?->subdomain ?? ''),
|
|
'operator' => (string) ($operator?->name ?? ''),
|
|
'company' => (string) CompanyProfile::get('name', ''),
|
|
];
|
|
}
|
|
|
|
/** @param array<string, string> $values */
|
|
private function fill(string $text, array $values): string
|
|
{
|
|
return preg_replace_callback(
|
|
// Whitespace inside the braces is tolerated: an operator typing
|
|
// "{{ customer }}" means the same thing as "{{customer}}", and
|
|
// failing on a space would be a rule nobody can see.
|
|
'/\{\{\s*([a-z_]+)\s*\}\}/i',
|
|
// Unknown tokens are left exactly as they were — see the class
|
|
// docblock. The operator reads this before anything is sent.
|
|
fn (array $m) => $values[strtolower($m[1])] ?? $m[0],
|
|
$text,
|
|
) ?? $text;
|
|
}
|
|
|
|
/**
|
|
* The contract's monthly figure with VAT on it.
|
|
*
|
|
* The domestic rate, from the same place an invoice reads it. Reverse charge
|
|
* is deliberately not applied: this figure goes into a sentence a person
|
|
* reads, and a business quoting itself a net price in a mail is a different
|
|
* problem from a document that has to be lawful.
|
|
*/
|
|
private function grossCents(Subscription $subscription): int
|
|
{
|
|
$net = (int) $subscription->price_cents;
|
|
|
|
return (int) round($net * (1 + CompanyProfile::taxRate() / 100));
|
|
}
|
|
}
|