diff --git a/CHANGELOG.md b/CHANGELOG.md index 3745fcb..f48ffca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,165 @@ 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/). +## [0.15.0] - 2026-09-07 + +### Changed +- **Breaking:** `Modules\Core\Payment\Models\PaymentMethod` is now the full DB-instance layer for + Payment, same three-layer split (registry / DB instance / cross-cutting config) `Shipping` + already has via `ShippingMethod` — see `docs/payments.md`. Every value that used to live in + `config('lunar.payments.types.{type}.*')` (`payment_driver`, `capture_mode`, `captured_status`) + moves onto the `PaymentMethod` row itself as real columns: `driver` (the new + `PaymentDriverRegistry` key — NOT the same as `type`; two rows can share one driver), `name` + (admin-facing label, nothing played this role before), `capture_mode`, `captured_status`, + `authorized_status`, `position` (admin-controlled ordering, new — reorderable in the Filament + table), `driver_missing_at`. `config('lunar.payments.types')` is gone entirely; `config/ + payment.php` now holds only `cart_pipeline` (genuinely cross-cutting — every store gets the + same pipeline wiring regardless of how many payment methods it configures). +- **Breaking:** `Modules\Core\Payment\Services\PaymentDriverResolver` is deleted, replaced by + `Modules\Core\Payment\Services\PaymentDriverRegistry` — `register(string $key, string + $driverClass)`/`resolve(string $key): ?object`/`all(): array`. Deliberately + knows nothing about `PaymentMethod` or the database (mirrors `Lunar\Shipping\Managers\ + ShippingManager`'s built-in-methods + `Manager::extend()` split, purpose-built rather than + extending `Illuminate\Support\Manager` — Payment's drivers implement several independent + capability interfaces at once, not one uniform contract). Built-ins (`OfflinePaymentDriver` + as `'offline'`, `StripePaymentDriver` as `'stripe'`) registered in + `PaymentServiceProvider::boot()`, exactly how `Shipping::extend('acs', ...)` already works. +- **Breaking:** `Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()` now returns + `Illuminate\Support\Collection` (ordered by `position`), not + `array`. A method is offered only once three independent checks all pass — `enabled` + (admin turned it on), `driver_missing_at` is null (the driver class still exists), and the + resolved driver's own `Configurable::isConfigured()` (its runtime requirements are met) — each + failure meaning something different to an admin diagnosing why a method isn't showing up. + `initiatePayment()` resolves the driver via the selected row's own `driver` column, not `type`. +- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource` — `canCreate()`/`canDelete()` + now both `true` (previously hardcoded `false`, since a row could only ever be a config-defined + type before this release). New create/edit form (`name`, `type`, `driver` — a `Select` + populated live from `PaymentDriverRegistry::all()`, `capture_mode`, `captured_status`, + `authorized_status`); reorderable table (`->reorderable('position')`); a distinct "Driver + status" icon column (separate from the `enabled` toggle) showing whether `driver_missing_at` + is set. +- `Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus` reads `captured_status`/ + `authorized_status` off the `PaymentMethod` row (`where('type', $event->type)`) instead of + `config(...)`. +- `Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()` no longer iterates + `config('lunar.payments.types')` — it seeds exactly one opinionated `cash-on-delivery` starter + row, every value a plain literal in the command itself (not sourced from config or the + registry — a driver has no business carrying opinions about what its captured order status + should be called; that's a merchant decision). Skip-if-exists, same as before. + +### Added +- `php artisan boboko:payment:sync-drivers` — reconciles every `PaymentMethod` row's `driver` + against `PaymentDriverRegistry`, setting `driver_missing_at` when a driver no longer resolves + (a package removed, a custom `register()` call deleted) and clearing it automatically if that + driver is registered again in a later deploy. Deliberately its own standalone command, meant to + run unconditionally on every container start/deploy (Dockerfile entrypoint, alongside + `migrate`) — "did the set of registered drivers change" is a deploy-time event, cheap enough to + check every time regardless of whether anything actually changed. Verified live: flags a row + whose `driver` was manually corrupted, and auto-clears the flag once the driver resolves again. +- `docs/payments.md` — new "Registry, DB instance, and cross-cutting config" section: the + three-layer split researched against `Shipping`'s own already-existing pattern and three real + e-commerce platforms (Shopify, WooCommerce, Medusa.js), the "would a store ever plausibly want + two different answers to this" test for deciding config vs. DB-column placement, and the + three-check availability chain. + +- `Modules\Core\Payment\Services\PaymentMethodCache` (`Cache::rememberForever`, same pattern as + `Localization\Services\LanguageCache`) + `Modules\Core\Payment\Services\PaymentMethodService` + (`create`/`update`/`delete`/`list`) — the single read/write gateway for `PaymentMethod` now used + by every Filament resource action (create, edit, edit-fee, delete, the inline `enabled` toggle) + instead of the Eloquent model directly, so the cache is invalidated and + `PaymentMethodCreated`/`PaymentMethodUpdated`/`PaymentMethodDeleted`/`PaymentMethodsReordered` + dispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which + does a raw bulk SQL `UPDATE` on the position column directly via + `CanReorderRecords`/`reorderTable()`, before `afterReordering()` fires — a confirmed, unavoidable + Filament limitation; the reorder hook only clears the cache and dispatches + `PaymentMethodsReordered` afterward). `CheckoutService::getPaymentMethods()` and + `ApplyResolvedPaymentStatus` both now read through the cache instead of querying `PaymentMethod` + directly. +- `Modules\Core\Payment\Models\CoreTransaction` (a `Lunar\Models\Transaction` subclass) + + `Modules\Core\Payment\Support\TransactionDriverAdapter`, registered via + `Lunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class, + CoreTransaction::class)` — the same contract-swap mechanism already used elsewhere for + `Customer`/`Staff`. Fixes a real crash (`InvalidArgumentException: Driver [cash-on-delivery] not + supported`) the first time anything called `$transaction->refund()`/`->capture()`: + `Lunar\Models\Transaction::driver()` calls Lunar's own, entirely separate + `Lunar\Facades\Payments::driver()` manager, which had never heard of any of this codebase's + driver keys. `CoreTransaction::driver()` returns `TransactionDriverAdapter` instead, which + resolves the transaction's real `PaymentMethod`/`PaymentDriverRegistry` driver and calls it — + Lunar's own admin panel "Refund"/"Capture" buttons now transparently reach the real payment + system underneath, including correctly reporting failure (not a silently-faked success) when + the resolved driver doesn't implement `SupportsRefunds`/`SupportsCaptures`. +- `TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount, + ?string $notes = null)` — refund through an explicitly chosen driver, independent of the one + the original payment went through (e.g. a cash-on-delivery order refunded via Bank Transfer, + which has no notion of the original offline payment at all). The order page's refund action + gained a "Refund via" `Select` (every `PaymentDriverRegistry` driver implementing + `SupportsRefunds`, defaulting to the transaction's own driver) that routes through this method + instead of `Lunar\Models\Transaction::refund()`, whose fixed signature has no room for a driver + override. +- `Modules\Core\Payment\Drivers\BankTransferPaymentDriver` (registered as `'bank-transfer'`) — + manual/attested, same trust model as `OfflinePaymentDriver`: no gateway call, `pay()`/`refund()` + decide success immediately on a staff member's say-so. Implements both `SupportsPay` and + `SupportsRefunds`; exists specifically so a payment taken through a different method can still + be refunded via bank transfer. The admin UI for receiving a payment this way (bank reference, + notes, proof-of-transfer upload) is a follow-up — the driver itself is complete and usable via + the registry today. +- `Modules\Core\Order\Filament\Infolists\TransactionEntry` (swapped in for Lunar's own + `Lunar\Admin\Support\Infolists\Components\Transaction` via a new + `OrderTransactionsExtension::extendTransactionsRepeatableEntry()` hook) — the order page's + transaction cards now also show a note recorded in `Transaction.meta['notes']` when the `notes` + column itself is empty. `Order\Services\TransactionRecorder` only ever wrote `notes` from + `PaymentResult::$failureReason`, which is never set on a successful result — a manual driver's + staff-entered note (e.g. `BankTransferPaymentDriver`'s) was being recorded but had nowhere to + render. +- `Modules\Core\Payment\Listeners\LogPaymentMethodActivity` — `PaymentMethod` now has an admin + activity trail, unlike `Order`/`Transaction`/`Staff` it previously had none. Routes + `PaymentMethodCreated`/`Updated`/`Deleted` through the existing `Logging\ActivityLogService` + (the same one `Localization\Listeners\LogTranslationActivity` already uses) rather than adding + `PaymentMethod` to `Lunar\Base\Traits\LogsActivity`'s generic model-observer logging — + `PaymentMethodUpdated::$old`/`PaymentMethodDeleted::$method`'s snapshot already carry more + deliberate before/after context than Eloquent's own dirty-attribute diffing would reconstruct. + `PaymentMethodsReordered` is deliberately NOT logged — a multi-row position change doesn't fit + `ActivityLogService`'s one-`Model`-subject shape, and isn't worth a new method for a low-stakes, + purely-cosmetic setting. +- New `payment_methods.refunded_status` column + form field (same `Select` pattern as + `captured_status`/`authorized_status`) — `ApplyResolvedPaymentStatus` now also reacts to + `PaymentRefunded`, so `Order.status` actually changes on a refund; before this, only the + *derived* `Order::paymentStatus()` reflected a refund (reading `transactions` live), while the + stored `status` column — what admin filtering, customer emails, etc. actually key off — never + moved. Resolves the ORIGINAL payment method for this lookup, not the refund event's own + `$type`: a refund routed through a different driver via `refundVia()` (e.g. cash-on-delivery + refunded through Bank Transfer) carries the REFUND driver's registry key as `$event->type`, + which usually isn't even a real `PaymentMethod.type` — the listener now finds the order's + earliest successful `capture`/`intent` transaction instead and reads `refunded_status` off + *that* transaction's own `PaymentMethod` row, since that's the payment the refund is actually + reversing. Deliberately no `void_status` yet — a void never moved money, so it doesn't carry + the same "the customer needs to see this changed" weight a refund does. + +### Fixed +- Existing `PaymentMethod` rows seeded before this release (`cash-on-delivery`, `cash-in-hand`) + had `driver`/`capture_mode`/`captured_status` all `NULL` after the migration ran — a data + backfill was required (not automated by the migration itself) to restore them to a resolvable + state; flagged here since a consuming app upgrading past this release needs the same backfill + for its own pre-existing rows before `getPaymentMethods()` will offer them again. +- `Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()`/ + `getCaptureAction()` and `OrderItemsTable::getBulkRefundAction()` report a failed refund/capture + by calling `$action->failureNotification(...)`, `$action->failure()`, then `$action->halt()` — + but `Filament\Actions\Concerns\InteractsWithActions::callMountedAction()` only ever sends that + notification from a code path that runs after the action's closure returns normally; `halt()` + throws `Filament\Support\Exceptions\Halt`, caught by an earlier `catch` block that rolls back and + returns, so the notification was built but never sent — clicking "Refund" on a payment method + that genuinely can't be refunded looked like nothing happened at all, no error, no toast. Real, + pre-existing Filament/Lunar bug, invisible until this release's `TransactionDriverAdapter` made + an honest failure (rather than a hard crash or a silently-faked success) actually reachable. + Fixed via new `Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension`/ + `OrderItemsTableExtension`, which wrap the affected actions' closures to send the queued failure + notification themselves before re-throwing `Halt`. +- `TransactionDriverAdapter::refund()`/`capture()` never included `order_id` in the `$context` + passed to the driver, so `Order\Listeners\RecordPaymentTransaction`/`ApplyResolvedPaymentStatus` + (both requiring `$context['order_id']`) silently no-op'd for every admin-initiated refund/capture + through any driver — no audit `Transaction` row was ever created, regardless of whether the + refund/capture itself succeeded. Fixed by passing `$transaction->order_id` through. + ## [0.14.0] - 2026-09-03 ### Changed diff --git a/composer.json b/composer.json index 2f63ae5..53cac19 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.14.0", + "version": "0.15.0", "autoload": { "psr-4": { "Modules\\Core\\": "src/"