update(...) or $storeDetails->save() directly — a * write bypassing this service leaves current()'s forever-cache stale. */ class StoreDetailsService { public const CACHE_KEY = 'store-details'; /** * Forever-cached — read on every storefront request that shows store * details (e.g. the checkout confirmation page's bank transfer * instructions), so this should never re-query the database on a normal * request. Only ever invalidated by update() below, via * FlushStoreDetailsCache reacting to StoreDetailsUpdated. */ public function current(): StoreDetails { return Cache::rememberForever( self::CACHE_KEY, fn () => $this->firstOrCreate(), ); } /** * Null for any non-bank-transfer order — the confirmation email and page * only show this block when there's actually a wire to send (see * BankTransferPaymentDriver's own docblock for why a bank transfer * order stays at 'awaiting_payment' until staff confirm the wire * arrived). Null also when the store hasn't filled the field in for * $locale, so the caller's own @if($bankTransferInstructions) guard * covers both cases identically. * * bank_transfer_instructions is a TranslatedRichEditor field (see * ManageStoreDetails), so translate() returns Filament's Tiptap JSON * document structure for that locale, not a plain string — * RichContentRenderer::make() is Filament's own converter from that * structure to sanitized HTML (the same one the admin panel itself * uses to render a RichEditor's content read-only). */ public function bankTransferInstructionsFor(Order $order, string $locale): ?string { if (! app(OrderStatusFlow::class)->isBankTransfer($order)) { return null; } $content = $this->current()->translate('bank_transfer_instructions', $locale); if (blank($content)) { return null; } return $this->fillOrderReference( RichContentRenderer::make($content)->toHtml(), $order, ); } /** * Replaces a `{order_reference}` (or `{{ order_reference }}`) the shop * owner typed into the instructions with the order's display reference * (OrderReferenceDisplay — same form as the email subject). * * A plain text replace rather than RichContentRenderer::mergeTags(): * that only fills genuine Tiptap mergeTag nodes, which this editor never * creates — Lunar's TranslatedText can't pass mergeTags() through to its * per-locale RichEditors, so the placeholder is always stored as * ordinary typed text. */ private function fillOrderReference(string $html, Order $order): string { return preg_replace( '/\{\{?\s*order_reference\s*\}\}?/', e(OrderReferenceDisplay::resolve($order)), $html, ); } public function update(array $attributes): StoreDetails { $storeDetails = $this->firstOrCreate(); $storeDetails->update($attributes); Event::dispatch(new StoreDetailsUpdated($storeDetails)); return $storeDetails; } /** * A freshly-created row must never leave a translatable column * genuinely NULL — Lunar's own TranslatedText component (Modules\Core\ * Store\Filament\Pages\ManageStoreDetails's `name`/`address`/ * `bank_transfer_instructions` fields) silently drops every keystroke * on re-render when the field it's editing starts out NULL rather than * an empty per-locale array. Real-world precedent (PaymentMethod's own * translatable `name` column) never hits this, because every * PaymentMethod row is created THROUGH the same Filament form that * immediately fills `name` — this singleton is instead created blank * and opened for editing in the same visit, which is exactly the gap * that surfaces the bug. Caught and fixed after the fact, verified via * tinker: seeding a real (non-null) array made typing into the field * persist correctly, confirming NULL was the trigger. */ private function firstOrCreate(): StoreDetails { return StoreDetails::query()->firstOrCreate([], [ 'name' => $this->emptyPerLocale(), 'address' => $this->emptyPerLocale(), 'bank_transfer_instructions' => $this->emptyPerLocale(), 'legal_name' => $this->emptyPerLocale(), ]); } private function emptyPerLocale(): array { return Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all(); } }