Compare commits

...
6 Commits
16 changed files with 494 additions and 51 deletions
+44
View File
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.26.0] - 2026-09-28
### Added
- `Modules\Core\Store\` — a "Store Details" Filament settings page (under Settings) for a
shop's own contact/legal details: store name, address (both translatable), phone, tax
identifier (ΑΦΜ), company registration number (ΓΕΜΗ), and rich-text bank transfer
instructions (IBANs, formatted as a table if needed — `TranslatedText::optionRichtext()`,
which ships table insert/edit in its default toolbar). Backed by a single-row
`StoreDetails` model, read via `StoreDetailsService::current()` (forever-cached,
invalidated by the new `StoreDetailsUpdated` event whenever `StoreDetailsService::update()`
is the one write path used — never write to the model directly).
### Fixed
- `StoreDetailsService`'s singleton row was created with every translatable column
(`name`/`address`/`bank_transfer_instructions`) left `NULL`. Lunar's own `TranslatedText`
Filament component silently discards every keystroke on re-render when the field it edits
starts out `NULL` rather than an empty per-locale array — invisible for `PaymentMethod`'s
own translatable `name` (always created already-filled, through the same form), but exactly
the gap this brand-new singleton hits, since it's created blank and opened for editing in
the same visit. The row is now seeded with an empty string per configured language from
creation, so every translatable field is editable from the very first save.
## [0.25.2] - 2026-09-28
### Fixed
- `BankTransferPaymentDriver::pay()` returned `Succeeded` and dispatched `PaymentCaptured`
immediately — treating a bank transfer like an instant-success gateway (Stripe), when in
reality no money has moved yet. Now returns `Pending` with no event dispatched, so the order
stays at `awaiting_payment` with `Order::paid` false, exactly like it should — checkout still
completes normally (`CheckoutController` already treats a `Pending` result with no
continuation as a placed order). `OrderStatusFlow::canMarkPaid()`/`isBankTransfer()` now also
recognize bank transfer, so staff can mark the order paid once the wire arrives, the same
"Mark Paid" action cash-on-delivery already uses — but unlike COD's version, this also
advances the order's status past `awaiting_payment`, since nothing else ever will.
## [0.25.1] - 2026-09-28
### Fixed
- `CustomerErasureActionsExtension` (Privacy) added "Request Erasure"/"Request Export" header
actions to the Customer edit/view pages but left Lunar's own plain `DeleteAction` in place
alongside them — bypassing the grace period, cascades, and audit trail an erasure request
provides. That header action is now stripped whenever Privacy is installed, so "Request
Erasure" is the only way to remove a Customer.
## [0.25.0] - 2026-09-28 ## [0.25.0] - 2026-09-28
### Added ### Added
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.25.0", "version": "0.26.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -48,7 +48,8 @@
"Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider", "Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider", "Modules\\Core\\Providers\\PrivacyServiceProvider",
"Modules\\Core\\Providers\\WishlistServiceProvider" "Modules\\Core\\Providers\\WishlistServiceProvider",
"Modules\\Core\\Providers\\StoreServiceProvider"
] ]
} }
}, },
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A single-row table for the store's own contact/legal details — edited via
* the Filament "Store Details" settings page (Modules\Core\Store\Filament\
* Pages\ManageStoreDetails) and read via Modules\Core\Store\Services\
* StoreDetailsService. Not config, since a shop owner needs to change these
* (e.g. a new IBAN, a new address) without a code deploy.
*
* name/address/bank_transfer_instructions are locale-keyed JSON — same
* shape/resolution as Modules\Core\Payment\Models\PaymentMethod::$name (see
* that model's own docblock): $storeDetails->translate('name'). tax_identifier
* (ΑΦΜ) and registration_number (ΓΕΜΗ) are legal identifiers, not
* locale-dependent text, so they stay plain strings — same for phone.
*
* No seeder inserting the singleton row — StoreDetailsService::current()
* lazily creates it (all-null) on first read, same shape as any other
* firstOrCreate()-backed singleton in this codebase.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('store_details', function (Blueprint $table) {
$table->id();
$table->json('name')->nullable();
$table->json('address')->nullable();
$table->string('phone')->nullable();
$table->string('tax_identifier')->nullable();
$table->string('registration_number')->nullable();
$table->json('bank_transfer_instructions')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('store_details');
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.25.0", "version": "0.26.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.", "description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
+4
View File
@@ -50,6 +50,7 @@ use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource; use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Store\Filament\Pages\ManageStoreDetails;
class CorePlugin implements Plugin class CorePlugin implements Plugin
{ {
@@ -74,6 +75,9 @@ class CorePlugin implements Plugin
ShipmentResource::class, ShipmentResource::class,
ManifestResource::class, ManifestResource::class,
]) ])
->pages([
ManageStoreDetails::class,
])
->plugin(ShippingPlugin::make()); ->plugin(ShippingPlugin::make());
LunarPanel::extensions([ LunarPanel::extensions([
+30 -17
View File
@@ -34,6 +34,7 @@ class OrderFulfillmentService
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow, private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions, private readonly TransactionRecorder $transactions,
private readonly OrderPaymentResolutionService $resolution,
) {} ) {}
public function markReady(Order $order): OrderFulfillmentResult public function markReady(Order $order): OrderFulfillmentResult
@@ -114,9 +115,13 @@ class OrderFulfillmentService
} }
/** /**
* Independent of `status` entirely — offered by the single "Update * For a COD order, independent of `status` entirely — offered by the
* Status" action regardless of current status (see * single "Update Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()). * OrderStatusFlow::canMarkPaid()). For a bank transfer order, status
* genuinely does advance here too (see below) — unlike COD, a bank
* transfer order has been sitting at 'awaiting_payment' since checkout
* (BankTransferPaymentDriver::pay() deliberately never advances it),
* and this click is the only thing that ever will.
*/ */
public function markPaid(Order $order): OrderFulfillmentResult public function markPaid(Order $order): OrderFulfillmentResult
{ {
@@ -125,18 +130,20 @@ class OrderFulfillmentService
} }
// canMarkPaid() only ever returns true for an order whose payment // canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see // method resolves to the cash-on-delivery or bank-transfer DRIVER
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver, // (see OrderStatusFlow::isCod()/isBankTransfer(), which check
// never the merchant-chosen `type` slug directly — a store could // PaymentMethod::driver, never the merchant-chosen `type` slug
// name that method "cod", "pay-on-delivery", anything). Such an // directly — a store could name that method "cod", "pay-on-delivery",
// order never runs through Payment's pay()/authorize() flow at // "wire", anything). Neither ever runs a Transaction-recording event
// checkout, so nothing else records a Transaction for it. Money // through to completion at checkout (COD dispatches nothing capture-
// changes hands right here, at this click, so this is the one // shaped at all; bank transfer's pay() returns Pending with no event
// place that write can happen; there is no earlier Payment event // dispatched — see that driver's own docblock). Money changes hands
// to hang it off of the way Modules\Core\Order\Listeners\ // right here, at this click, so this is the one place that write can
// RecordPaymentTransaction does for a gateway driver. See // happen; there is no earlier Payment event to hang it off of the way
// TransactionRecorder's own docblock — it already anticipated // Modules\Core\Order\Listeners\RecordPaymentTransaction does for a
// exactly this "manually-triggered ... from Filament" call site. // gateway driver. See TransactionRecorder's own docblock — it already
// anticipated exactly this "manually-triggered ... from Filament"
// call site.
// //
// $driver below is the payment method's own `type` slug (whatever // $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') — // the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
@@ -146,19 +153,25 @@ class OrderFulfillmentService
// No fallback guess here: CheckoutService::initiatePayment() always // No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and // writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far. // canMarkPaid() already guarantees this order got that far.
$type = (string) $order->meta['payment_method'];
$this->transactions->record( $this->transactions->record(
$order, $order,
type: 'capture', type: 'capture',
driver: (string) $order->meta['payment_method'], driver: $type,
result: new PaymentResult( result: new PaymentResult(
status: PaymentResultStatus::Succeeded, status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id, reference: "manual-{$type}-{$order->id}",
amount: $order->total, amount: $order->total,
), ),
); );
$this->writer->markPaid($order, self::class.'::markPaid'); $this->writer->markPaid($order, self::class.'::markPaid');
if ($this->flow->isBankTransfer($order)) {
$this->resolution->advancePastAwaitingPayment($order, self::class.'::markPaid');
}
return OrderFulfillmentResult::success('Order marked as paid.'); return OrderFulfillmentResult::success('Order marked as paid.');
} }
@@ -92,7 +92,14 @@ class OrderPaymentResolutionService
} }
} }
private function advancePastAwaitingPayment(Order $order, string $causeClass): void /**
* Also called directly by OrderFulfillmentService::markPaid() for a
* bank transfer order — unlike a COD markPaid() (which never touches
* status, since nothing was ever awaited), a bank transfer order
* genuinely sat at 'awaiting_payment' until this moment, and nothing
* else will ever advance it if this doesn't.
*/
public function advancePastAwaitingPayment(Order $order, string $causeClass): void
{ {
if ($order->status !== 'awaiting_payment') { if ($order->status !== 'awaiting_payment') {
return; return;
+25 -4
View File
@@ -54,6 +54,24 @@ class OrderStatusFlow
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery'; return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
} }
/**
* Same meta-first/Transaction-fallback resolution as isCod(). Unlike COD
* — where nothing is ever awaited, since payment happens on delivery —
* a bank transfer order genuinely sits at 'awaiting_payment' until staff
* confirm the wire arrived (see BankTransferPaymentDriver's own
* docblock and OrderFulfillmentService::markPaid()).
*/
public function isBankTransfer(Order $order): bool
{
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
if ($type === null) {
return false;
}
return PaymentMethod::where('type', $type)->value('driver') === 'bank-transfer';
}
/** /**
* @return array<string, string> value => label — every status in the * @return array<string, string> value => label — every status in the
* order's own branch (carrier or pickup), plus the refund options, * order's own branch (carrier or pickup), plus the refund options,
@@ -124,13 +142,16 @@ class OrderStatusFlow
/** /**
* Whether the "mark paid" option should be offered right now — * Whether the "mark paid" option should be offered right now —
* entirely independent of $order->status. True whenever this is a * entirely independent of $order->status for a COD order (true whenever
* cash-on-delivery order and payment hasn't been recorded yet, * payment hasn't been recorded yet, regardless of fulfillment progress,
* regardless of fulfillment progress (before OR after completed). * before OR after completed). A bank transfer order is also eligible,
* for the same "no earlier Payment event recorded this" reason (see
* OrderFulfillmentService::markPaid()), but unlike COD its own status
* genuinely does need advancing once marked paid — see that method.
*/ */
public function canMarkPaid(Order $order): bool public function canMarkPaid(Order $order): bool
{ {
return ! $order->paid && $this->isCod($order); return ! $order->paid && ($this->isCod($order) || $this->isBankTransfer($order));
} }
/** /**
@@ -9,30 +9,47 @@ use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds; use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentRefunded;
/** /**
* Manual/attested, same trust model as OfflinePaymentDriver — there is no * refund() is manual/attested, same trust model as OfflinePaymentDriver —
* bank API to call, so both pay() and refund() decide success immediately * there is no bank API to call, so it decides success immediately on a
* on a staff member's say-so (they've already sent/received the wire * staff member's say-so (they've already sent the wire outside the
* outside the system). Distinct from OfflinePaymentDriver in intent: this * system). Distinct from OfflinePaymentDriver in intent: this exists so a
* exists so a payment taken through a DIFFERENT method (e.g. * payment taken through a DIFFERENT method (e.g. cash-on-delivery) can
* cash-on-delivery) can still be REFUNDED via bank transfer — an admin * still be REFUNDED via bank transfer — an admin chooses this driver
* chooses this driver explicitly in the refund action, independent of * explicitly in the refund action, independent of which driver the
* which driver the original payment went through (see * original payment went through (see
* Payment\Support\TransactionDriverAdapter::refundVia() and * Payment\Support\TransactionDriverAdapter::refundVia() and
* Order\Filament\Extensions\OrderActionsExtension). pay() exists so * Order\Filament\Extensions\OrderActionsExtension).
* the same driver also covers receiving a payment by bank transfer, but *
* the admin UI for that (bank reference, notes, proof-of-transfer upload) * pay() is the opposite trust direction from refund(): a bank transfer
* is deliberately not built yet — see the follow-up work tracked from this * payment requires the money to arrive BEFORE the order can be
* session; pay() itself is complete and usable via the registry today. * considered paid (unlike cash-on-delivery, where payment happens on
* delivery — see CashOnDeliveryPaymentDriver's own docblock for that
* driver's mirror-image reasoning). So pay() returns Pending, dispatching
* no event at all — no PaymentCaptured (nothing has been paid yet), and
* deliberately NOT PaymentDeferred either (unlike COD, whose
* MarkOrderPlacedOnDeferredPayment listener immediately advances the
* order past 'awaiting_payment' since a COD order has nothing to await at
* checkout). A bank transfer order genuinely DOES have something to
* await: it stays at 'awaiting_payment' with Order::paid false until
* staff confirm the wire arrived via OrderFulfillmentService::markPaid(),
* which — unlike its COD path — also advances the order's status, since
* nothing else ever will (see that method's own docblock).
* CheckoutController::placeOrder() already treats a Pending result with
* no continuation as a fully placed order (see its own docblock), so the
* order is still created and visible to the shopper immediately; only its
* payment/status is what's left outstanding.
* *
* $reference is generated here for the same reason as OfflinePaymentDriver's * $reference is generated here for the same reason as OfflinePaymentDriver's
* pay(): there is no gateway to hand one back. 'notes' in $context (not * pay(): there is no gateway to hand one back. refund()'s 'notes' (in
* $data — refund() has no $data parameter) is folded into * $context — it has no $data parameter) is folded into PaymentResult::$meta,
* PaymentResult::$meta, which Order\Services\TransactionRecorder::record() * which Order\Services\TransactionRecorder::record() already writes straight
* already writes straight into Transaction.meta with no extra plumbing. * into Transaction.meta with no extra plumbing; pay() has no equivalent
* write, since nothing ever records a Transaction from its own result (see
* above) — any notes a shopper enters at checkout would need surfacing some
* other way, e.g. when staff mark the order paid.
*/ */
class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds
{ {
@@ -46,16 +63,11 @@ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRe
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{ {
$result = new PaymentResult( return new PaymentResult(
status: PaymentResultStatus::Succeeded, status: PaymentResultStatus::Pending,
reference: 'bank-transfer-'.Str::uuid(), reference: 'bank-transfer-'.Str::uuid(),
amount: $amount, amount: $amount,
meta: array_filter(['notes' => $data['notes'] ?? null]),
); );
PaymentCaptured::dispatch($type, $result, $context);
return $result;
} }
public function refund(string $reference, Price $amount, array $context = []): PaymentResult public function refund(string $reference, Price $amount, array $context = []): PaymentResult
@@ -3,6 +3,7 @@
namespace Modules\Core\Privacy\Filament\Extensions; namespace Modules\Core\Privacy\Filament\Extensions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension; use Lunar\Admin\Support\Extending\BaseExtension;
@@ -20,13 +21,18 @@ use Modules\Core\Privacy\Services\PrivacyService;
* docs/modules.md "Layering Module and App Configuration"), and this extension * docs/modules.md "Layering Module and App Configuration"), and this extension
* deliberately only implements headerActions(), so it never conflicts with an * deliberately only implements headerActions(), so it never conflicts with an
* app's own extension for the same resource. * app's own extension for the same resource.
*
* Also strips Lunar's own plain DeleteAction from these pages — with Privacy
* installed, "Request Erasure" (grace period, cascades, audit trail via
* DataErasureRequest) is the only sanctioned way to remove a Customer; a
* direct delete would bypass all of that.
*/ */
class CustomerErasureActionsExtension extends BaseExtension class CustomerErasureActionsExtension extends BaseExtension
{ {
public function headerActions(array $actions): array public function headerActions(array $actions): array
{ {
return [ return [
...$actions, ...array_filter($actions, fn ($action) => ! $action instanceof DeleteAction),
Action::make('requestErasure') Action::make('requestErasure')
->label('Request Erasure') ->label('Request Erasure')
->icon('heroicon-o-shield-exclamation') ->icon('heroicon-o-shield-exclamation')
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Listeners\FlushStoreDetailsCache;
class StoreServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(StoreDetailsUpdated::class, FlushStoreDetailsCache::class);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Store\Events;
use Modules\Core\Store\Models\StoreDetails;
/**
* Dispatched by StoreDetailsService — the only place StoreDetails is ever
* created/updated, mirroring Modules\Core\Localization\Services\
* TranslationService's own create()/update() shape. Modules\Core\Store\
* Listeners\FlushStoreDetailsCache reacts to this to invalidate
* StoreDetailsService::current()'s forever-cache.
*/
class StoreDetailsUpdated
{
public function __construct(
public readonly StoreDetails $storeDetails,
) {}
}
@@ -0,0 +1,128 @@
<?php
namespace Modules\Core\Store\Filament\Pages;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* Singleton settings page — no resource, no record list, always edits the
* one StoreDetails row (see that model's own docblock). Filament ships no
* built-in "settings page" type; this follows the same shape Filament's own
* password-reset-request page uses (see Filament\Auth\Pages\PasswordReset\
* RequestPasswordReset): a content(Schema) composed of a Form(EmbeddedSchema)
* with the save action(s) in its own footer(), rather than a hand-written
* Blade view — Filament v4 has no `x-filament-panels::form.actions` Blade
* component to fall back on for a plain Page.
*/
class ManageStoreDetails extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationLabel = 'Store Details';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-storefront';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
public ?array $data = [];
public function mount(): void
{
$this->form->fill(
app(StoreDetailsService::class)->current()->attributesToArray()
);
}
public function content(Schema $schema): Schema
{
return $schema->components([
Form::make([EmbeddedSchema::make('form')])
->id('form')
->livewireSubmitHandler('save')
->footer([
Actions::make($this->getFormActions())
->key('form-actions'),
]),
]);
}
public function form(Schema $schema): Schema
{
return $schema
->statePath('data')
->components([
Section::make('Store')
->schema([
// Deliberately not ->required(): TranslatedText's own
// state is the whole locale-keyed array, and its
// required-rule generation validates that array
// itself rather than deferring to its per-locale
// children — it fires "required" even when every
// locale sub-field is genuinely filled in. The
// column is nullable and nothing reads it yet, so
// there's no real need to enforce this here.
TranslatedText::make('name')
->label('Store name'),
TranslatedText::make('address')
->label('Address'),
TextInput::make('phone')
->label('Phone')
->tel(),
]),
Section::make('Legal')
->description('Shown on invoices and terms pages.')
->schema([
TextInput::make('tax_identifier')
->label('Tax ID (ΑΦΜ)'),
TextInput::make('registration_number')
->label('Company registration number (ΓΕΜΗ)'),
]),
Section::make('Bank transfer')
->description('Shown to a shopper on the order confirmation page when they chose to pay by bank transfer.')
->schema([
// Rich, not plain Textarea — a shop owner may want a
// formatted table (bank name / IBAN / BIC columns) or
// bold text, not just line breaks. RichEditor's
// 'table' toolbar button ships in its default toolbar
// (RichEditor::getDefaultToolbarButtons()), so this
// needs no extra config to get table insert/edit.
TranslatedText::make('bank_transfer_instructions')
->label('Instructions')
->optionRichtext(true),
]),
]);
}
protected function getFormActions(): array
{
return [
Action::make('save')
->label('Save')
->submit('save'),
];
}
public function save(): void
{
$state = $this->form->getState();
app(StoreDetailsService::class)->update($state);
Notification::make()
->title('Store details saved')
->success()
->send();
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Store\Listeners;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* Same shape as Modules\Core\Localization\Listeners\FlushTranslationCache —
* StoreDetailsService::current() caches forever (this is read on every
* storefront request that shows store details, e.g. the checkout
* confirmation page's bank transfer instructions), so the only way it ever
* becomes stale is a write through this same service. Not queued: unlike
* FlushTranslationCache (which only affects a LATER storefront request),
* StoreDetailsUpdated fires from the staff member's own save action, and
* StoreDetailsService::current() may be called again within that same
* request/response cycle.
*/
class FlushStoreDetailsCache
{
public function handle(StoreDetailsUpdated $event): void
{
Cache::forget(StoreDetailsService::CACHE_KEY);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Store\Models;
use Illuminate\Database\Eloquent\Model;
use Lunar\Base\Traits\HasTranslations;
/**
* Singleton — always exactly one row, fetched/created via
* Modules\Core\Store\Services\StoreDetailsService::current(). See that
* table's own migration docblock for why name/address/
* bank_transfer_instructions are locale-keyed JSON and the rest are plain.
*/
class StoreDetails extends Model
{
use HasTranslations;
protected $guarded = [];
protected $casts = [
'name' => 'array',
'address' => 'array',
'bank_transfer_instructions' => 'array',
];
}
@@ -0,0 +1,77 @@
<?php
namespace Modules\Core\Store\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Models\StoreDetails;
/**
* The only entrypoint that creates/updates the StoreDetails singleton — same
* shape as Modules\Core\Localization\Services\TranslationService: every
* write goes through here so it can dispatch StoreDetailsUpdated, which
* Modules\Core\Store\Listeners\FlushStoreDetailsCache reacts to. Never call
* StoreDetails::query()->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(),
);
}
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(),
]);
}
private function emptyPerLocale(): array
{
return Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all();
}
}