CluPilotCloud/app/Services/Mail/MailTemplateRenderer.php

144 lines
5.7 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\Services\Billing\TaxTreatment;
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),
// What was actually charged, because that is the figure the customer
// knows: it is what their bank statement says.
'amount' => $subscription === null ? '' : Number::currency(
$this->chargedCents($subscription, $customer) / 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 as this customer is charged it.
*
* Through TaxTreatment, which is the one place the amount taken is formed, and
* therefore the same figure the Stripe Price for this contract carries. It used
* to be a multiplication at the domestic rate written out here, with the
* reasoning that reverse charge was a matter for documents and not for
* sentences — and that stopped being true the moment a reverse-charge business
* began to be charged the bare net. A mail naming a fifth more than the bank
* statement is a mail the customer writes back about.
*/
private function chargedCents(Subscription $subscription, Customer $customer): int
{
return TaxTreatment::for($customer)->chargeCents((int) $subscription->price_cents);
}
}