144 lines
5.7 KiB
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);
|
|
}
|
|
}
|